diff --git a/.github/workflows/aiden-on-the-go-external-testflight.yml b/.github/workflows/aiden-on-the-go-external-testflight.yml new file mode 100644 index 00000000..e82c41d7 --- /dev/null +++ b/.github/workflows/aiden-on-the-go-external-testflight.yml @@ -0,0 +1,193 @@ +# Maintainer-only: uploads a build to the Aiden On The Go app's external TestFlight. +# Requires the repo's APP_STORE_CONNECT_* secrets (App Store Connect API key), +# which forks and contributors do not have. Repository CI lives in .github/workflows/ci.yml. + +# Run manually from the Actions tab (workflow_dispatch). +name: Aiden On The Go External TestFlight + +on: + workflow_dispatch: + inputs: + confirm_external_review: + description: "Type EXTERNAL_REVIEW to confirm this upload can be submitted to Beta App Review." + required: true + type: string + build_number: + description: "Optional CFBundleVersion override. Leave blank to use the next App Store Connect build number." + required: false + type: string + +run-name: "Aiden On The Go external TestFlight from ${{ github.ref_name }}" + +permissions: + contents: read + +concurrency: + group: aiden-on-the-go-external-testflight + cancel-in-progress: false + +jobs: + upload: + name: Archive and Upload + runs-on: macos-26 + environment: aiden-on-the-go-external-testflight + timeout-minutes: 60 + + env: + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + EXPORT_OPTIONS_PLIST: ios/ci/ExternalTestFlightExportOptions.plist + PROJECT_PATH: ios/AidenOnTheGo.xcodeproj + SCHEME: AidenOnTheGo + TEAM_ID: 5WP229CBB8 + + steps: + - name: Check manual gate + env: + CONFIRM_EXTERNAL_REVIEW: ${{ inputs.confirm_external_review }} + run: | + set -euo pipefail + + if [[ "${GITHUB_REF_NAME}" != "main" ]]; then + echo "External TestFlight uploads must be run from main. Current ref: ${GITHUB_REF_NAME}" >&2 + exit 1 + fi + + if [[ "${CONFIRM_EXTERNAL_REVIEW}" != "EXTERNAL_REVIEW" ]]; then + echo "Re-run with confirm_external_review set to EXTERNAL_REVIEW." >&2 + exit 1 + fi + + - name: Check required secrets + run: | + set -euo pipefail + + missing=0 + for name in APP_STORE_CONNECT_KEY_ID APP_STORE_CONNECT_ISSUER_ID APP_STORE_CONNECT_PRIVATE_KEY; do + if [[ -z "${!name}" ]]; then + echo "Missing required secret: ${name}" >&2 + missing=1 + fi + done + + exit "${missing}" + + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + + - name: Set output paths + run: | + set -euo pipefail + + { + echo "ARCHIVE_PATH=${RUNNER_TEMP}/AidenOnTheGo.xcarchive" + echo "DERIVED_DATA_PATH=${RUNNER_TEMP}/DerivedData" + echo "EXPORT_PATH=${RUNNER_TEMP}/ExternalTestFlightExport" + } >> "${GITHUB_ENV}" + + - name: Detect marketing version + run: | + set -euo pipefail + + marketing_version="$( + xcodebuild \ + -showBuildSettings \ + -project "${PROJECT_PATH}" \ + -scheme "${SCHEME}" \ + -configuration Release 2>/dev/null | + awk -F'= ' '/MARKETING_VERSION/ { print $2; exit }' + )" + + if [[ -z "${marketing_version}" ]]; then + echo "Could not detect MARKETING_VERSION from Xcode build settings." >&2 + exit 1 + fi + + echo "MARKETING_VERSION=${marketing_version}" >> "${GITHUB_ENV}" + echo "Using marketing version ${marketing_version}" + + - name: Write App Store Connect key + run: | + set -euo pipefail + + key_path="${RUNNER_TEMP}/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8" + if [[ "${APP_STORE_CONNECT_PRIVATE_KEY}" == *"\\n"* ]]; then + printf "%s" "${APP_STORE_CONNECT_PRIVATE_KEY}" | perl -pe 's/\\n/\n/g' > "${key_path}" + else + printf "%s" "${APP_STORE_CONNECT_PRIVATE_KEY}" > "${key_path}" + fi + chmod 600 "${key_path}" + echo "APP_STORE_CONNECT_KEY_PATH=${key_path}" >> "${GITHUB_ENV}" + + # ENFORCE_OPEN_TRAIN fails this step in seconds — instead of the upload + # step after an expensive archive — when the marketing version's + # pre-release train is already closed by an approved App Store version + # (bitten 2026-06-02 with 1.0 and 2026-08-04 with 1.4). + - name: Check release train and select build number + env: + BUNDLE_ID: sbtbiswas.AidenOnTheGo + REQUESTED_BUILD_NUMBER: ${{ inputs.build_number }} + ENFORCE_OPEN_TRAIN: "1" + run: | + set -euo pipefail + + build_number="$(ruby ios/ci/select_testflight_build_number.rb)" + echo "BUILD_NUMBER=${build_number}" >> "${GITHUB_ENV}" + echo "Using build number ${build_number}" + + - name: Show upload target + run: | + set -euo pipefail + + echo "Uploading external-capable TestFlight build" + echo "Commit SHA: ${GITHUB_SHA}" + echo "Marketing version: ${MARKETING_VERSION}" + echo "Build number: ${BUILD_NUMBER}" + echo "Export options: ${EXPORT_OPTIONS_PLIST}" + echo "This workflow uploads only; App Store Connect external group assignment and Beta App Review submission remain manual." + + - name: Show Xcode version + run: xcodebuild -version + + - name: Resolve Swift packages + run: | + set -euo pipefail + + xcodebuild \ + -resolvePackageDependencies \ + -project "${PROJECT_PATH}" \ + -scheme "${SCHEME}" + + - name: Archive + run: | + set -euo pipefail + + xcodebuild \ + -project "${PROJECT_PATH}" \ + -scheme "${SCHEME}" \ + -configuration Release \ + -destination "generic/platform=iOS" \ + -archivePath "${ARCHIVE_PATH}" \ + -derivedDataPath "${DERIVED_DATA_PATH}" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "${APP_STORE_CONNECT_KEY_PATH}" \ + -authenticationKeyID "${APP_STORE_CONNECT_KEY_ID}" \ + -authenticationKeyIssuerID "${APP_STORE_CONNECT_ISSUER_ID}" \ + DEVELOPMENT_TEAM="${TEAM_ID}" \ + CURRENT_PROJECT_VERSION="${BUILD_NUMBER}" \ + archive + + - name: Upload to App Store Connect + run: | + set -euo pipefail + + xcodebuild \ + -exportArchive \ + -archivePath "${ARCHIVE_PATH}" \ + -exportPath "${EXPORT_PATH}" \ + -exportOptionsPlist "${EXPORT_OPTIONS_PLIST}" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "${APP_STORE_CONNECT_KEY_PATH}" \ + -authenticationKeyID "${APP_STORE_CONNECT_KEY_ID}" \ + -authenticationKeyIssuerID "${APP_STORE_CONNECT_ISSUER_ID}" diff --git a/.github/workflows/aiden-on-the-go-internal-testflight.yml b/.github/workflows/aiden-on-the-go-internal-testflight.yml new file mode 100644 index 00000000..e3a68080 --- /dev/null +++ b/.github/workflows/aiden-on-the-go-internal-testflight.yml @@ -0,0 +1,187 @@ +# Maintainer-only: uploads a build to the Aiden On The Go app's internal TestFlight. +# Requires the repo's APP_STORE_CONNECT_* secrets (App Store Connect API key), +# which forks and contributors do not have. Repository CI lives in .github/workflows/ci.yml. + +# Run manually from the Actions tab (workflow_dispatch). +name: Aiden On The Go Internal TestFlight + +on: + workflow_dispatch: + inputs: + confirm_internal_only: + description: "Type INTERNAL to confirm this upload is for internal TestFlight only." + required: true + type: string + build_number: + description: "Optional CFBundleVersion override. Leave blank to use the next App Store Connect build number." + required: false + type: string + +run-name: "Aiden On The Go internal TestFlight from ${{ github.ref_name }}" + +permissions: + contents: read + +concurrency: + group: aiden-on-the-go-internal-testflight + cancel-in-progress: false + +jobs: + upload: + name: Archive and Upload + runs-on: macos-26 + environment: aiden-on-the-go-internal-testflight + timeout-minutes: 60 + + env: + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + EXPORT_OPTIONS_PLIST: ios/ci/TestFlightExportOptions.plist + PROJECT_PATH: ios/AidenOnTheGo.xcodeproj + SCHEME: AidenOnTheGo + TEAM_ID: 5WP229CBB8 + + steps: + - name: Check manual gate + env: + CONFIRM_INTERNAL_ONLY: ${{ inputs.confirm_internal_only }} + run: | + set -euo pipefail + + if [[ "${GITHUB_REF_NAME}" != "main" ]]; then + echo "Internal TestFlight uploads must be run from main. Current ref: ${GITHUB_REF_NAME}" >&2 + exit 1 + fi + + if [[ "${CONFIRM_INTERNAL_ONLY}" != "INTERNAL" ]]; then + echo "Re-run with confirm_internal_only set to INTERNAL." >&2 + exit 1 + fi + + - name: Check required secrets + run: | + set -euo pipefail + + missing=0 + for name in APP_STORE_CONNECT_KEY_ID APP_STORE_CONNECT_ISSUER_ID APP_STORE_CONNECT_PRIVATE_KEY; do + if [[ -z "${!name}" ]]; then + echo "Missing required secret: ${name}" >&2 + missing=1 + fi + done + + exit "${missing}" + + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + + - name: Set output paths + run: | + set -euo pipefail + + { + echo "ARCHIVE_PATH=${RUNNER_TEMP}/AidenOnTheGo.xcarchive" + echo "DERIVED_DATA_PATH=${RUNNER_TEMP}/DerivedData" + echo "EXPORT_PATH=${RUNNER_TEMP}/TestFlightExport" + } >> "${GITHUB_ENV}" + + - name: Detect marketing version + run: | + set -euo pipefail + + marketing_version="$( + xcodebuild \ + -showBuildSettings \ + -project "${PROJECT_PATH}" \ + -scheme "${SCHEME}" \ + -configuration Release 2>/dev/null | + awk -F'= ' '/MARKETING_VERSION/ { print $2; exit }' + )" + + if [[ -z "${marketing_version}" ]]; then + echo "Could not detect MARKETING_VERSION from Xcode build settings." >&2 + exit 1 + fi + + echo "MARKETING_VERSION=${marketing_version}" >> "${GITHUB_ENV}" + echo "Using marketing version ${marketing_version}" + + - name: Write App Store Connect key + run: | + set -euo pipefail + + key_path="${RUNNER_TEMP}/AuthKey_${APP_STORE_CONNECT_KEY_ID}.p8" + if [[ "${APP_STORE_CONNECT_PRIVATE_KEY}" == *"\\n"* ]]; then + printf "%s" "${APP_STORE_CONNECT_PRIVATE_KEY}" | perl -pe 's/\\n/\n/g' > "${key_path}" + else + printf "%s" "${APP_STORE_CONNECT_PRIVATE_KEY}" > "${key_path}" + fi + chmod 600 "${key_path}" + echo "APP_STORE_CONNECT_KEY_PATH=${key_path}" >> "${GITHUB_ENV}" + + - name: Select build number + env: + BUNDLE_ID: sbtbiswas.AidenOnTheGo + REQUESTED_BUILD_NUMBER: ${{ inputs.build_number }} + run: | + set -euo pipefail + + build_number="$(ruby ios/ci/select_testflight_build_number.rb)" + echo "BUILD_NUMBER=${build_number}" >> "${GITHUB_ENV}" + echo "Using build number ${build_number}" + + - name: Show upload target + run: | + set -euo pipefail + + echo "Uploading internal-only TestFlight build" + echo "Commit SHA: ${GITHUB_SHA}" + echo "Marketing version: ${MARKETING_VERSION}" + echo "Build number: ${BUILD_NUMBER}" + echo "Export options: ${EXPORT_OPTIONS_PLIST}" + + - name: Show Xcode version + run: xcodebuild -version + + - name: Resolve Swift packages + run: | + set -euo pipefail + + xcodebuild \ + -resolvePackageDependencies \ + -project "${PROJECT_PATH}" \ + -scheme "${SCHEME}" + + - name: Archive + run: | + set -euo pipefail + + xcodebuild \ + -project "${PROJECT_PATH}" \ + -scheme "${SCHEME}" \ + -configuration Release \ + -destination "generic/platform=iOS" \ + -archivePath "${ARCHIVE_PATH}" \ + -derivedDataPath "${DERIVED_DATA_PATH}" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "${APP_STORE_CONNECT_KEY_PATH}" \ + -authenticationKeyID "${APP_STORE_CONNECT_KEY_ID}" \ + -authenticationKeyIssuerID "${APP_STORE_CONNECT_ISSUER_ID}" \ + DEVELOPMENT_TEAM="${TEAM_ID}" \ + CURRENT_PROJECT_VERSION="${BUILD_NUMBER}" \ + archive + + - name: Upload to App Store Connect + run: | + set -euo pipefail + + xcodebuild \ + -exportArchive \ + -archivePath "${ARCHIVE_PATH}" \ + -exportPath "${EXPORT_PATH}" \ + -exportOptionsPlist "${EXPORT_OPTIONS_PLIST}" \ + -allowProvisioningUpdates \ + -authenticationKeyPath "${APP_STORE_CONNECT_KEY_PATH}" \ + -authenticationKeyID "${APP_STORE_CONNECT_KEY_ID}" \ + -authenticationKeyIssuerID "${APP_STORE_CONNECT_ISSUER_ID}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efcac39c..45d1b007 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,21 @@ jobs: DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer run: npm run test:native + # The owner requires physical-device-only XCTest acceptance. Hosted CI has + # no physical iOS device, so compile the app and test bundle for generic + # hardware here; the complete XCTest suite is recorded from signed devices. + - name: Compile Aiden On The Go tests for generic iOS hardware + env: + DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer + run: >- + xcodebuild build-for-testing -quiet + -project ios/AidenOnTheGo.xcodeproj + -scheme AidenOnTheGo + -configuration Debug + -destination 'generic/platform=iOS' + -derivedDataPath '${{ runner.temp }}/AidenOnTheGoDerivedData' + CODE_SIGNING_ALLOWED=NO + - name: Build production bundles run: npm run build diff --git a/.gitignore b/.gitignore index ff13ae20..a0b212e9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,16 @@ build/ release/ native/computer-use-broker/target/ +# Xcode user and build data +DerivedData/ +*.xcuserstate +xcuserdata/ +.build/ + +# App Store Connect CLI credentials (never commit repo-local API/web auth) +.asc/config.json +.asc/sessions/ + # Agent context (auto-generated, not worth versioning) .claude/ .agents/ diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index 3efec01a..8b068c75 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -7,6 +7,8 @@ - Renderer-safe truncation markers must be normalized before their length is budgeted, then the truncated value must be sanitized again; NFKC expands `…` to `...` and can otherwise invalidate an exact-length snapshot. - Packaged builds do not initialize `aiden-dev.log`. For production subagent forensics, correlate the Pi session journal, the V2 run store, aggregate health metrics, and usage records without printing task or report contents. - Pi 0.80.10's public `AgentHarness` cannot express Aiden's global sequential-tool policy or resume an already-journaled user tail. Keep the low-level Agent behind `PiAgentRuntimeHarness` until Pi's durable Harness implements restore/resume, tool execution policy, and automatic between-turn compaction. +- Pi's remote model catalog can evolve independently of its agent/session runtime. A full package bump from 0.80.10 exposed breaking Session contracts, so backport the bounded provider-catalog wrapper and any explicitly reviewed transport adapters behind Aiden's registry; do not couple a model-list refresh to an unplanned journal/session migration. +- A provider-scoped catalog refresh must not call Pi's mutating `getAuth()` merely to test configuration: expired OAuth can rotate credentials after the caller's timeout. Use the non-refreshing auth check, resolve staleness before auth, keep provider-owned catalogs out of launch polling, cache valid empty/negative results, and bound HTTP generation timestamps against wall-clock skew so a far-future validator cannot freeze recovery. - Pi awaits `Agent.subscribe()` listeners. Never register an optional renderer/plugin observer as a critical listener: isolate it through the runtime contribution observer path, or its exception can alter the model lifecycle. - A Pi observer is not passive merely because its return value is ignored: a live `tool_execution_end` event contains mutable result references, and awaiting the observer blocks Pi settlement. Clone observer events and dispatch them outside Pi's serial lifecycle. - Global extension tools cannot be copied into a child Agent after authority is minted. Keep child contributions disabled until an adapter can include their names, effects, approvals, schemas, and budgets in the immutable child ceiling. @@ -24,3 +26,41 @@ - "Safer" compaction checks can be compatibility bugs. Before changing Aiden's Pi adapter, pin the exact upstream commit, hash the relevant sources, and mirror its tests and acceptance behavior—including empty/length summaries and usage-anchor skips—instead of preserving stricter local validators that upstream does not have. - An Electron E2E teardown deadline must exceed the app's sequential bounded shutdown phases. A 10-second fixture timeout can kill and report a healthy process while foreground, subagent, and packaged-soak drains are still inside their documented 6s + 5s + 5s ceilings. - GitHub release create/edit requests can return HTTP 503 after committing server-side state. Publication must re-read the exact tag, target SHA, draft state, and asset set before retrying or reconciling; never treat an unavailable lookup as a missing release. +- A physical XCTest transport spike can keep secrets out of the project and scheme: use a private temporary Derived Data directory, create an injected `.xctestrun` copy beside its `Build/Products` payload, inject an ephemeral canonical pairing-bootstrap JSON into that copy, then use `test-without-building`. Xcode still requires the physical device to remain unlocked through preflight and launch. +- A copied `.xctestrun` resolves `__TESTROOT__` relative to its own location. Keep the injected copy beside `Build/Products` (or deliberately rewrite every relative product path), and derive the advertised LAN address from the default-route interface instead of assuming Wi-Fi is `en0`; otherwise Xcode reports a missing test product or the phone silently times out against a link-local adapter. +- Simulator networking does not prove iOS Local Network privacy readiness. A direct physical LAN request fails as `Local network prohibited` when the host app omits `NSLocalNetworkUsageDescription`; lock both that key and the canonical `NSBonjourServices` value with an XCTest that inspects the built application bundle. +- A newly configured Tailscale Serve HTTPS handler can accept TCP before its tailnet certificate is locally available. Verify/request the node certificate through Tailscale's own CLI, retry the exact path-scoped Serve URL, then remove only that path with matching `--https` and `--set-path ... off`; never use `serve reset` as cleanup. +- Before the first handler exists, `tailscale serve status --json` can be `{}` even when tailnet HTTPS is enabled. Validate the exact normalized node DNS name against `tailscale status --json`'s certificate domains; do not require a pre-existing `TCP.443.HTTPS` listener, and still reject an explicitly incompatible 443 listener. +- Tailscale `--set-path` strips the mounted public prefix before reverse proxying. An Aiden `/api/aiden/v1` mount must target the loopback origin plus the exact same canonical API base, not the origin root; verify `/api/aiden/v1/health` through the real tailnet before claiming transport acceptance. +- `PlistBuddy Add ... string ` can strip JSON quoting when injecting a physical-test pairing payload. Use `plutil -replace ... -string`, compare the injected value's byte count/digest without printing it, and make every secret-bearing xctestrun and payload file owner-only. +- A self-signed `CA:FALSE` TLS leaf cannot be used as an Apple Security trust anchor, and an overlong server-leaf lifetime can fail Apple SSL policy even under a private anchor. Generate an installation-local CA, sign a short-lived `CA:FALSE`/server-auth leaf, present the full chain, anchor only the CA, and pin the leaf SPKI. +- A newly connected physical iPhone can remain an ineligible Xcode destination after pairing until Developer Mode is enabled, the reboot confirmation is accepted, and the phone is unlocked again. Re-read the CoreDevice/Xcode destination list before rebuilding; stale destination errors do not imply a signing failure. +- Foundation and JavaScript do not resolve duplicate JSON object keys the same way. For cross-platform security envelopes, scan raw UTF-8 JSON and reject duplicate keys—including escaped-equivalent names—before either `JSONDecoder` or `JSON.parse`; validating only the decoded object is too late. +- Cross-platform `maxLength` and date parsing need executable shared vectors: Swift `String.count` measures grapheme clusters while OpenAPI/JavaScript limits are Unicode-code-point based, and `ISO8601DateFormatter` accepts forms a strict RFC 3339 parser rejects. Use Unicode scalars for wire bounds and validate the complete timestamp grammar/calendar before constructing `Date`. +- A TTL alone is unsafe when idempotency state is pruned using wall time: after a forward jump and persisted prune, a rollback can make the same key look reusable. Persist a last-observed clock high-water mark with the ledger snapshot and fail closed for new keys until wall time advances beyond it; unresolved in-flight entries still never expire locally. +- Do not retain an unversioned idempotency-array migration path after adding a persisted clock high-water mark: even an empty legacy array is ambiguous and can reopen a pruned operation after rollback. Use an omitted snapshot only for a genuinely fresh ledger and reject every persisted shape that cannot carry the high-water value. +- WHATWG `URL` parsing removes raw and percent-encoded dot segments before exposing `pathname`. Security-sensitive canonical endpoint checks must compare the exact raw path before constructing `URL`, then apply the normal scheme, authority, query, fragment, and normalized-path checks. +- URL libraries also normalize authority bytes: controls may be stripped, Unicode hosts may become punycode, numeric hosts may change form, and padded ports lose their spelling. Cross-platform pairing identity needs a shared conservative raw authority grammar before either Foundation or WHATWG parsing, with the original endpoint string retained for equality checks. +- A `structuredClone`-safe value is not necessarily durable JSON: nested `undefined`, non-finite numbers, `-0`, sparse arrays, accessors, cycles, and nonplain objects can disappear or change. Idempotency results must be recursively validated/cloned into an exact JSON value and tested through `snapshot -> JSON.stringify/parse -> restore -> replay`. +- Entry-count limits alone do not bound durable state. Apply recursive shape limits plus per-result and aggregate serialized-byte budgets before accepting terminal replay data, and give live operation-owner registries an owner-checked terminal release path so the capacity ceiling does not become permanent exhaustion. +- Rork `asc` enables pseudonymous command telemetry by default; use `ASC_TELEMETRY_DISABLED=1` for Aiden operations and `--strict-auth` so stored profiles cannot silently mix with environment credentials. A zero-result app query is only evidence for the active key's scope, not proof of account-wide absence; check product-owned public TestFlight links and reconcile them through the correct account before creating a record. Initial app creation is now `asc web apps create`, requires an authenticated Apple web session, and must remain an explicit owner action. +- `NavigationSplitView` selection can highlight a row without pushing detail on a compact iPhone. Branch explicitly by horizontal size class: use a value-driven `NavigationStack` on compact layouts, retain the split view on regular layouts, and reconcile both selection and path across CRUD and size-class changes. +- Never convert a post-action serialization or snapshot-budget failure into a TTL-bound rejection. The external mutation may already have happened; retain its stable operation reference as an unexpiring unknown/in-flight record until authoritative reconciliation proves a terminal outcome. +- Dependency-injected application services must keep Electron-backed singleton imports type-only. Bind real stores, logging, and platform services in a separate `*-main.ts` module; otherwise focused Node tests load Electron before the pure service can be exercised. +- Local Swift caches should use their own symmetric encoder/decoder rather than the stricter network RFC 3339 decoder. A plain `JSONEncoder` persists `Date` numerically by default, so decoding that file with the wire decoder fails even though the cache is valid. +- Canonical workspace paths can differ textually on macOS (`/var` versus `/private/var`). Managed-worktree authorization must compare the shared realpath-resolved environment identity, not a raw temporary-directory spelling. +- macOS LibreSSL does not support OpenSSL's `-copy_extensions` certificate flag. For an installation-local server leaf, write a bounded temporary extension file with the reviewed SAN/key-usage values and pass it through `-extfile` instead of assuming GNU/OpenSSL CLI parity. +- A private LAN CA cannot satisfy normal iOS server trust from an SPKI fingerprint alone. Keep the leaf pin and carry the installation CA certificate in the locally displayed, versioned pairing envelope so the client can anchor that exact CA before applying hostname, validity, usage, and pin checks. +- A folder-browser selection is not safe merely because its opaque nonce is one-use. Revalidate the approved-root policy, canonical directory identity, and duplicate-workspace state inside the same serialized application-service commit that persists registration; otherwise a root removal or filesystem replacement can win between token consumption and save. +- Durable idempotency must persist the in-flight admission before invoking a workspace mutation and persist the terminal result afterward. A crash between those writes should fail closed as an unknown/in-flight operation rather than allow the same key to execute twice. +- A committed chat append and a started generation are separate durability boundaries. If provider setup fails after the append, return the accepted message with a terminal error stream; if append persistence has an indeterminate outcome, keep the idempotency entry in flight until authoritative reconciliation rather than allowing a duplicate prompt. +- Resumable token streams can produce many events faster than durable storage should be written. Coalesce journal snapshots while preserving monotonic in-memory sequence order, settle the latest snapshot during quit, and close only the revoked device's live responses; an SSE disconnect alone must not cancel server-owned generation. +- An Xcode App Intents localization file reference is not a resource by itself. Adding a missing `AppShortcuts.xcstrings` reference to Copy Bundle Resources makes metadata extraction fail at build-input validation; either provide the real catalog or leave the absent reference out of the shipping resources so extraction and shortcut training use the declared intent phrases. +- After an iOS target rewrite, imported source files can remain in the project navigator without belonging to the shipping target. Verify the active `PBXSourcesBuildPhase` before trusting or testing a configuration/UI file, and make CI compile the renamed scheme for generic hardware when simulator use is prohibited. +- Xcode can also retain unlinked Swift package references and stale `Package.resolved` pins after an imported target is narrowed. Audit the actual target dependency graph, remove unused project package/product references, then keep the resolved pins and bundled third-party notices under the same regression gate. +- `await Activity.update` does not guarantee `activity.content` has already advanced. A Live Activity manager that reduces the next rapid event from that public rendered snapshot can lose semantic transitions. Keep canonical state actor-isolated in the app process, hydrate only when adopting a persisted activity, and test rapid updates on physical hardware before immediate cleanup. +- Separate `xcodebuild test-without-building` invocations normally reinstall an app-hosted XCTest bundle, which removes its Live Activities and invalidates a relaunch-persistence proof. Build and install once, then set `UseDestinationArtifacts` with the destination-relative test bundle for the second phase; verify the first host is gone, keep a cleanup phase, and validate that the CoreDevice UUID and Xcode UDID describe the same physical device. +- A physical XCTest run can pass every test and still print a `devicectl diagnose` collection error while Xcode archives partial diagnostics. Use the XCTest summary and final `TEST EXECUTE SUCCEEDED` result as the gate; treat diagnostic collection as a separate tooling warning rather than a test failure. +- A pairing window can be closed or replaced while durable device issuance is awaiting storage. Rechecking only after issuance is too late because a hidden credential may already exist; pass an exact-session authorization fence into the serialized durable mutation and check it immediately before commit. +- Re-pairing into one fixed Keychain scope makes registry rollback non-atomic because the old credential has already been overwritten. Write each device credential to a versioned scope, durably move the installation-registry pointer, and only then best-effort remove the prior scope. +- CoreDevice and `xcodebuild` can identify the same physical iPhone with different UUIDs. Use `devicectl list devices` only to confirm presence and unlock state, then resolve the actual Xcode destination identifier from `xcodebuild -showdestinations`; passing the CoreDevice UUID directly can report that an otherwise connected phone is unavailable. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a057eafe..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,23 +0,0 @@ -# Aiden Agent - -Aiden Agent is a privately owned Electron application. Its only source repository is `https://github.com/sambitcreate/aiden-agent`. - -## Project memory - -The `.memory/` folder contains context and history from previous work on this project. Read the relevant files there before making changes, and keep them updated when work changes the implementation, architecture, decisions, or status. - -## UI design references - -Before adding or materially restyling any UI element or component, always review both `docs/chatgpt-desktop-ui-inspiration.md` and `docs/chatgpt-ui-element-specimen.html` for interaction, styling, state, motion, and accessibility inspiration. Adapt the references to Aiden's existing visual language rather than copying them blindly, and use the semantic design tokens in `renderer/styles.css` and `renderer/shared/appearance.ts` instead of introducing one-off colors. - -## Release model metadata - -`npm run models:refresh` is the explicit development refresh, and `npm run dist` invokes the same release step before packaging. Those are the only paths that may contact models.dev. Never add a models.dev call to normal development, unpacked builds, or ordinary live-app reads. Artificial Analysis data and credentials must never be bundled: the live Electron app may contact its fixed Free endpoint only after the user explicitly chooses Connect & fetch or Fetch latest with their own key, then reads the normalized device-local cache offline. - -## Papercuts - -For complex workflows, record concise implementation friction in `.papercuts/troubleshooting.md` as it occurs. - -## Tests - -When adding a feature or changing behavior, layout, configuration, or contracts, always check whether existing tests need updating and add or extend tests when coverage is missing. Run the relevant suites before finishing (`npm run test`, or the narrower scripts in `package.json` when the change is scoped). If a new test file is added, register it in the appropriate `package.json` test script so CI picks it up. diff --git a/README.md b/README.md index f9001e54..175bfb7c 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ I don't come from a coding background. I'd been bouncing between the coding agen - **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. - **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. ## Upcoming @@ -86,6 +87,8 @@ npm install npm run dev ``` +The native Aiden On The Go iPhone and iPad client lives in [`ios/`](ios/README.md). It is not part of the Electron development command; open `ios/AidenOnTheGo.xcodeproj` or use the documented physical-device `xcodebuild` commands separately. + 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. 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. diff --git a/docs/aiden-on-the-go-remote-access.md b/docs/aiden-on-the-go-remote-access.md new file mode 100644 index 00000000..c8444f0b --- /dev/null +++ b/docs/aiden-on-the-go-remote-access.md @@ -0,0 +1,52 @@ +# Aiden On The Go remote access + +Aiden Agent can expose a small authenticated API to Aiden On The Go on iPhone and iPad. Remote Access is off by default. Aiden must remain running on the Mac, although its window may be closed. + +## Local Network setup + +1. Open **Settings → Remote Access** in Aiden Agent. +2. Choose **Local Network** or **Local Network + Tailscale**. +3. Turn on **Enable Remote Access**. +4. Add only the folders the phone or iPad may explore. Selecting the entire home directory requires a second confirmation on the Mac; the filesystem root is never allowed. +5. Choose **Pair over Local Network** and scan the one-time QR code in Aiden On The Go. + +The Mac advertises `_aiden-agent._tcp` with Bonjour only while Local Network access is running. LAN traffic uses a per-install P-256 HTTPS identity. The QR contains the private CA trust anchor and the server public-key pin so the mobile client can validate the hostname, certificate chain, and pinned key. A certificate renewal keeps the server key; an identity-key change requires pairing again. + +## Tailscale setup + +Tailscale supplies reachability and network encryption, but Aiden still requires its own device credential on every request. + +1. Install Tailscale on the Mac and sign in to the intended tailnet. +2. Ensure HTTPS certificates are available for the tailnet. Aiden reports this prerequisite rather than enabling it silently. +3. In **Settings → Remote Access**, select **Tailscale** or **Local Network + Tailscale** and enable Remote Access. +4. Review the exact command-equivalent route preview, then choose **Connect**. +5. Pair with **Pair over Tailscale** after the stable `https://…ts.net/api/aiden/v1` address appears. + +Aiden owns only `/api/aiden/v1`, proxies it to the loopback-only HTTP listener's matching `/api/aiden/v1` base, and verifies the resulting route. The matching target base is required because Tailscale strips the public `--set-path` prefix before proxying. First-time connection works from an empty Serve configuration only after the node's exact Tailscale certificate domain proves HTTPS was already authorized. Aiden never enables Tailscale Funnel, never runs `tailscale serve reset`, never completes Tailscale authorization for you, and never changes unrelated Serve handlers. **Disconnect** removes only the exact route and target recorded by Aiden. A conflict is reported instead of being overwritten. + +## Devices, credentials, and revocation + +Each phone or iPad receives a separate random credential. Aiden persists only a fast lookup digest, a salted scrypt digest, and redacted device metadata—not the credential or pairing secret. Pairing QR codes expire after five minutes and work once. + +Use **Revoke** beside a paired device to invalidate it immediately. Revocation does not rotate model-provider credentials or affect other paired devices. Pair the device again to restore access. + +## Offline behavior + +Closing the Aiden window does not stop Remote Access. Quitting Aiden does. The mobile app may retain its bounded offline read cache, but it cannot start new work or mutate the Mac while Aiden is stopped or unreachable. Temporary connection loss does not grant broader access and does not make an invalid or revoked credential valid. + +## Mobile usage summary + +The **Usage** row on the Aiden On The Go home screen reads aggregate usage from the paired Mac. It reflects Aiden Agent's device-local usage store and sends only aggregate request, token, activity, and estimated-cost totals to the phone. Chat content, workspace paths, and provider credentials are not included. + +## Troubleshooting + +- **Remote Access says Off:** enable it locally in Aiden Settings. No listener or Bonjour advertisement exists while it is off. +- **Local device cannot find Aiden:** confirm both devices are on the same network, Local Network mode is selected, and local-network permission is enabled for Aiden On The Go. +- **Certificate or pin changed:** do not bypass the warning. Verify the Mac, revoke the old device record, and pair again. +- **Tailscale not found or disconnected:** open Tailscale on the Mac and confirm it reports a stable MagicDNS name. +- **Tailnet HTTPS unavailable:** complete Tailscale's HTTPS authorization flow, then retry Connect in Aiden. Aiden will not authorize it on your behalf. +- **Serve conflict:** inspect the route shown in the error. Remove or relocate the conflicting handler yourself; Aiden will not take it over. +- **A folder is missing:** add it from the Mac. The phone cannot submit an arbitrary path or approve a new browser root. +- **Port already in use:** stop the other local service or repair the saved Remote Access configuration before enabling it again. + +Remote Access diagnostics contain request IDs, route names, status codes, latency, and at most a device-ID suffix. They exclude bearer credentials, pairing secrets, provider keys, prompt bodies, and filesystem paths. diff --git a/docs/aiden-remote-api-v1.md b/docs/aiden-remote-api-v1.md new file mode 100644 index 00000000..37a17ef4 --- /dev/null +++ b/docs/aiden-remote-api-v1.md @@ -0,0 +1,267 @@ +# Aiden Remote API v1 + +Status: Phase 0 normative contract +Base path: `/api/aiden/v1` +Transport: HTTPS REST plus resumable Server-Sent Events +Schema: `protocol/aiden-remote/v1/openapi.json` +Fixtures: `protocol/aiden-remote/v1/fixtures/contract.json` + +## 1. Contract rules + +This is Aiden's native remote protocol. It is not Hermes WebUI compatibility. Aiden Agent remains the execution, persistence, permission, filesystem, and provider authority. The remote transport adapts authenticated commands to shared main-process application services; it never calls Electron IPC handlers or impersonates a `WebContents` owner. + +The URL major version changes only for an incompatible wire break. Additive fields are forward compatible only where the schema marks an envelope extensible; new capability-gated endpoints/events do not require a new major version. Clients must ignore unknown fields on the extensible SSE envelope and unknown nonterminal SSE events. Known v1 payload and mutation DTOs remain allowlisted. Clients must fail closed and fetch an authoritative snapshot for an unknown terminal state, missing required identity, invalid sequence, or mutation-precondition mismatch. + +All timestamps are RFC 3339 UTC strings. IDs are opaque strings and must not encode filesystem paths, credentials, provider details, or user names. JSON request bodies use UTF-8 and reject duplicate keys, non-finite numbers, and unknown mutation fields. + +## 2. Authentication and capabilities + +`GET /health` is the only unauthenticated read. `POST /pairing/manual-bootstrap` returns only a bounded AES-GCM sealed trust envelope while a local desktop pairing window is open; the locally displayed setup code is never sent in its request. `POST /pairing/exchange` is available only during that same window and requires its single-use 256-bit secret. Every other request, including SSE, requires: + +```http +Authorization: Bearer +Aiden-Protocol-Version: 1 +``` + +The desktop stores only a slow/strong digest of the credential plus device metadata. Revocation closes streams and rejects later requests. Credentials are installation-specific and must never be accepted by a different Aiden instance. + +Initial capability IDs: + +| Capability | Authority | +| --- | --- | +| `server:read` | Read instance/version/capability projection. | +| `chat:read` | Read projected chats and stream status. | +| `chat:write` | Create/rename/delete chats; start/cancel turns. | +| `approval:respond` | Resolve approvals owned by this device/stream. | +| `workspace:read` | Read workspace registry projection. | +| `workspace:browse` | Navigate locally approved directory roots. | +| `workspace:manage` | Create/update/unregister workspace records, including permission changes. Granted by default for the confirmed full-CRUD mobile product and disclosed during pairing. | +| `files:read` | Read the bounded file index and text documents. | +| `files:write` | Perform expected-version file writes. | +| `git:read` | Review/diff/compare/branch/worktree projection. | +| `git:write` | Confirmed commit/push/checkout/branch/worktree mutations. | +| `schedule:read` | Read scheduled tasks/settings/run history. | +| `schedule:write` | Confirmed task/settings mutations and run-now. | + +No capability can enable Computer Use, mint the reserved Assistant identity, select a hidden unattended mode, read provider/MCP credentials, execute a generic shell/Git command, or widen a workspace's tool authority. + +## 3. Error envelope + +Every non-2xx JSON response uses: + +```json +{ + "error": { + "code": "stable_machine_code", + "message": "Safe user-facing summary.", + "requestId": "req_opaque", + "retryable": false, + "details": {} + } +} +``` + +`details` is optional and endpoint-allowlisted. It never contains paths, credentials, request bodies, raw provider errors, raw Git output, or stack traces. + +Required stable codes include: + +- `invalid_request`, `payload_too_large`, `rate_limited` +- `authentication_required`, `credential_revoked`, `capability_denied` +- `pairing_closed`, `pairing_expired`, `pairing_already_used`, `server_identity_changed` +- `not_found`, `already_exists`, `revision_conflict`, `idempotency_conflict`, `idempotency_capacity`, `idempotency_in_flight` +- `workspace_unavailable`, `workspace_changing`, `permission_confirmation_required` +- `handle_invalid`, `handle_expired`, `handle_wrong_device`, `root_policy_changed`, `filesystem_identity_changed`, `path_outside_root`, `handle_capacity` +- `turn_already_active`, `stream_gone`, `approval_already_resolved`, `approval_expired` +- `operation_in_progress`, `operation_stale`, `git_capability_denied` +- `schedule_disabled`, `schedule_run_in_progress` +- `server_interrupted`, `internal_error` + +## 4. DTO allowlists + +### Server + +Allowed: instance ID/display name, app/protocol version, supported capabilities, selected connection mode, minimum client version, server time. + +The display name is a bounded, user-editable label persisted by the Mac and returned by `GET /server`; it is never an identity key. Clients scope credentials, caches, streams, App Intents, and navigation to `instanceId`, including when multiple installations use the same label or a label changes. + +Forbidden: local usernames, config/user-data paths, environment values, provider credentials, logs, process arguments. + +### Workspace + +Allowed: `id`, `name`, `permission`, `hasFolder`, `isManagedWorktree`, optional branch/repository display names, small Git summary, `createdAt`, `updatedAt`, `revision`. + +Forbidden: `folderPath`, repository/worktree/Git-admin paths, ownership token, device/inode identity, remote URL, created-from HEAD, config-store record. + +### Chat/message + +Allowed: IDs, workspace ID, visible title/provider/model selections, an optional `titlePending: true` hint while first-turn background naming is active, visible user/assistant messages, bounded attachments, safe reasoning/tool/timeline milestones, timestamps, terminal provider-failure category. + +Forbidden: Pi journals, raw diagnostics, raw tool arguments/results not already safe for renderer display, subagent private history, hidden prompts, credentials, filesystem internals. + +### File + +Allowed: opaque file handle, safe workspace-relative display path/name, kind, bounded size/language metadata, version, truncation/warning state, text content for a selected readable document. + +Forbidden: canonical/absolute path, symlink target, inode/device, recovery path, arbitrary binary bytes. + +### Git + +Allowed: safe review/diff/comparison fields, branch display names, counts, push capability/reason, managed-worktree display state, operation ID/status. + +Forbidden: absolute paths, Git admin path, ownership token, device/inode, raw command line/stdout/stderr, credential-bearing remote URL, private refs not present in the desktop projection. + +### Scheduled task + +Allowed: task ID/revision, safe name/description, workspace/provider/model IDs, schedule/timezone, mode/permission, selected MCP IDs, notification preference, validated script ID/display name, enabled/running state, next/previous dates, bounded redacted run result. + +Forbidden: provider fingerprint, resolved MCP bindings, chat ID, credentials, process environment, raw script path, unredacted stdout/stderr, internal cancellation handles. + +## 5. Endpoint inventory + +The OpenAPI document owns exact request/response shapes. This section owns behavior. + +### Bootstrap/device + +- The locally displayed QR encodes the OpenAPI `PairingPayload` envelope as canonical JSON. Its `PairingBootstrap` contains protocol version, instance ID, HTTPS API endpoint, P-256 SPKI SHA-256 fingerprint, high-entropy single-use secret, and expiry; its trust member selects the bundled private LAN CA or system trust for Tailscale. The phone must decode and validate the complete envelope, configure hostname plus SPKI verification, and only then exchange the secret. A fingerprint learned from `/pairing/exchange` is confirmation, never the trust bootstrap. +- `POST /pairing/manual-bootstrap`: returns that exact canonical `PairingPayload` encrypted with AES-256-GCM. A uniformly random 100-bit Crockford Base32 setup code is shown only through local Electron IPC and derives the encryption key with HKDF-SHA256. The client sends `{}` to the selected exact endpoint, validates the bounded response, derives and authenticates the envelope locally, requires the decrypted endpoint and expiry to match, and then uses the normal pinned `/pairing/exchange`. The setup code never appears in a URL, request, log, persistent state, Bonjour record, or public status projection. LAN users select a discovered Mac; Tailscale users provide its canonical private endpoint. QR and manual entry share one window and one synchronously consumed exchange secret. +- `GET /health`: minimal readiness and protocol version. +- `POST /pairing/exchange`: exchange a high-entropy single-use secret for device/instance IDs, bearer credential, capability list, endpoint, and P-256 SPKI SHA-256 fingerprint. A client may set `acceptsDisplayName: true` to receive the optional bounded server display name; the server omits that additive key for strict legacy v1 decoders. The display name is presentation metadata only and newer clients still verify `instanceId` as identity. +- `GET /server`: authenticated server projection. + +Device enumeration/revocation remains desktop-local in v1 unless a later explicit capability is added. + +### Workspaces and approved-root browser + +- `GET /workspaces`, `GET /workspaces/{workspaceId}` +- `POST /workspaces`: `folderless`, `scratch`, or `selected-folder` with an idempotency key. +- `PATCH /workspaces/{workspaceId}`: revision-checked name/permission patch. Permission elevation requires explicit foreground confirmation evidence. +- `DELETE /workspaces/{workspaceId}`: revision-checked unregister only; never deletes the folder. +- `GET /workspace-browser/roots` +- `GET /workspace-browser/children?location=...&cursor=...` +- `POST /workspace-browser/selections` + +Approved roots are server-side records containing random root ID, canonical path, filesystem identity, policy revision, display label, and hidden/system policy. Nested roots are deduplicated/rejected. Home requires explicit local warning/confirmation; filesystem root is disabled by default and never remotely enabled. + +Location handles are random high-entropy nonces stored as digests and bound to instance/device/root/policy/canonical internal location/filesystem identity/expiry/depth. Cursors additionally bind the parent handle and ordering snapshot. Handle stores prune expired/consumed records, enforce a hard entry ceiling, and fail closed at capacity. Listings are deterministic, paginated, rate limited, directory-only, and nonrecursive. + +Selection nonces are a separate type. Workspace creation atomically revalidates real path, directory type, root boundary, policy revision, filesystem identity, and duplicate state, then consumes the nonce once inside one synchronous storage transaction. Async mutation callbacks are prohibited; an escaped promise fails closed with the nonce consumed. Reuse or browse-to-register replacement fails closed. Selected-folder workspaces default to `ask`. + +### Chats, turns, streams, approvals + +- `GET|POST /chats` +- `GET|PATCH|DELETE /chats/{chatId}` +- `POST /chats/{chatId}/move`: empty chat only. +- `GET /models`: configured provider/model projection without credentials. +- `GET /usage?range=7d|30d|90d|1y|all`: aggregate request, token, activity, and estimated-cost totals from the Mac's device-local usage store. Requires `server:read`; never returns content, chat/workspace identifiers, paths, raw usage records, or credentials. +- `POST /chats/{chatId}/turns`: atomic append/admit/start, idempotent by client key. +- `POST /chats/{chatId}/attachments`: validate and stage one bounded image or UTF-8 text attachment. +- `DELETE /chats/{chatId}/attachments/{attachmentId}`: discard an unused staged attachment. +- `GET /chats/{chatId}/attachments/{attachmentId}/content`: return one authenticated, chat-scoped canonical PNG or JPEG for preview. +- `GET /streams/{streamId}` +- `GET /streams/{streamId}/events`: SSE replay via `Last-Event-ID` or `after`. +- `POST /streams/{streamId}/cancel` +- `POST /approvals/{approvalId}/respond`: `allow` or `deny` only. +- `GET /streams/{streamId}/approval`: current bounded approval snapshot, or `null` after resolution. + +Turn start returns `turnId`, `streamId`, accepted state, and canonical appended message. The generation owner is the authenticated device/stream, not a socket. Disconnect never resends the prompt or cancels the turn. Restart during an active remote turn records one explicit interrupted terminal state and never retries the provider call. + +First-turn title generation remains off the interactive response path. While it is active, chat list/get projections include optional `titlePending: true`; the field disappears only after the title job settles. Clients may use this hint for a bounded authoritative refresh and must not treat it as a revision or mutation precondition. + +### Attachments + +Uploads produce random, short-lived, single-use references bound to the authenticated device and exact chat. References expire after 10 minutes, are removed on device revocation, and are consumed atomically by a turn. A turn accepts at most 10 distinct references. The server retains at most 20 staged references per device/chat, 40 per device, 256 globally, and 64 MiB of staged representation data; capacity exhaustion fails closed. + +Image uploads accept only PNG or JPEG, at most 8 MiB decoded, at most 16,384 pixels on either axis, and at most 40 million decoded pixels. Text uploads accept only the documented plain-text/source MIME allowlist, at most 100,000 Unicode scalars and 400,000 UTF-8 bytes. Display names are bounded to 255 Unicode scalars and reject separators and control characters. Upload envelopes never accept a local or server path. Chat and message responses project attachment ID, display name, MIME type, kind, and size only; inline bytes and text are never returned. A client may fetch a projected raster by its opaque attachment ID through the authenticated content route. The server re-resolves it inside the requested chat, fails closed on duplicate IDs or mismatched size/MIME/signature, and returns only bounded canonical image bytes with `no-store` and `nosniff` headers. Text and filesystem content are never exposed through that route. + +Assistant message history also carries a closed exceptional outcome when a stored generation failed or was cancelled. Failure metadata is restricted to Aiden's fixed provider category, bounded attempt count, and retry-exhausted flag; provider-authored errors and diagnostics remain forbidden. Completed messages omit this field. This makes terminal state durable across SSE disconnects and app restarts without exposing the private generation journal. + +### Files and Git + +- `GET /workspaces/{workspaceId}/files`: one recursive snapshot with maximum 4,000 entries and depth 20 plus `truncated`. +- `GET|PUT /workspaces/{workspaceId}/files/{fileId}`: opaque file handle; write requires `expectedVersion`. +- Git review/diff/compare/comparison-diff/branches/checkout/create-branch/commit/push-capability/push/worktrees/create-worktree/delete-managed-worktree under `/workspaces/{workspaceId}/git/...`. + +File handles are separate from browser handles and bind instance/device/workspace/canonical root identity/relative file identity/index snapshot/expiry. Read/write re-resolves within the canonical root. Writes retain Aiden's atomic replacement and expected-version conflict behavior. There is no file create/rename/delete in v1. + +Git mutations reuse the workspace operation registry and mutation gate plus canonical common-directory serialization. Commit/push remain repository-root-only; nested workspaces expose a read/diff-only reason. Consequential actions carry an explicit foreground-confirmation field and return stable operation/snapshot IDs. Disconnect does not abandon an operation owner. There is no fetch, pull, stage/unstage, discard, generic Git, or terminal endpoint. + +### Scheduled tasks + +- `GET|POST /scheduled-tasks` +- `GET|PATCH|DELETE /scheduled-tasks/{taskId}` +- `POST /scheduled-tasks/{taskId}/pause|resume|run` +- `GET /scheduled-tasks/{taskId}/runs` +- `POST /scheduled-tasks/preview` +- `GET /scheduled-tasks/scripts?workspaceId=...` +- `GET|PATCH /scheduled-tasks/settings` + +Edits use `If-Match`/`expectedUpdatedAt`; settings use an equivalent revision. `run` requires an idempotency key and returns `202` with a durable `runId`. Socket loss does not cancel execution. Status is observed in run history. Cancellation follows existing remove/pause/global-disable/revocation/shutdown policy; v1 adds no bespoke remote cancel action. + +## 6. SSE envelope and ordering + +Every SSE `id` is the decimal sequence. `data` decodes as: + +```json +{ + "protocolVersion": 1, + "streamId": "stream_opaque", + "sequence": 12, + "timestamp": "2026-08-18T19:00:00.000Z", + "type": "text_delta", + "terminal": false, + "payload": { "text": "hello" } +} +``` + +Initial event types: + +- `snapshot`: authoritative projected turn/chat state and next sequence. +- `status`: `queued`, `running`, `waiting_for_approval`, or `reconciling`. +- `text_delta`, `reasoning_delta`. +- `tool_started`, `tool_finished`: safe name/status/milestone only. +- `timeline`: renderer-safe generation milestone. +- `approval_required`: approval ID, safe summary, deadline; no raw command/path/arguments. +- `done`: terminal persisted completion. +- `error`: terminal stable category and safe message. +- `cancelled`: terminal cancellation source. +- `heartbeat`: no semantic state change. + +The `terminal` bit is required. Known `done`, `error`, and `cancelled` events set it to `true`; every other known event sets it to `false`. An unknown nonterminal event is ignored for forward compatibility only after its required bounded payload object and envelope safety fields validate. An unknown terminal event fails closed and triggers authoritative reconciliation. Individual SSE frames are limited to 1 MiB before JSON decoding. Sequences are monotonically increasing and unique within a stream. Replay validates the caller's expected stream identity and includes only events after the acknowledged sequence. Duplicate/lower sequences are ignored. A stream mismatch or gap triggers snapshot/status reconciliation; it never triggers turn creation. Terminal events are immutable. Expired journals return `stream_gone` with the chat ID needed for snapshot recovery. + +When a stream reports `waiting_for_approval`, clients fetch its separate approval snapshot. This additive endpoint preserves the closed v1 stream-status contract while making reconnect authoritative. It returns approval, stream, and chat IDs; a safe summary; tool identity; expiry; and whether the mobile client may offer Allow. Exact privileged command, path, and external-mutation details remain host-only, so mobile renders those requests as deny-only. The snapshot becomes `null` as soon as the approval resolves, expires, is cancelled, or the stream terminates. + +## 7. Idempotency, revisions, and operation ownership + +- Create/start/run endpoints require `Idempotency-Key` (random client value, scoped to authenticated device + route + resource). The server durably stores only a bounded scope digest/request digest/state/safe-result record plus a non-secret stable operation reference; raw keys are never persisted. Fulfilled and safely classified rejected outcomes are replayable until expiry. In-flight entries never expire or evict into duplicate work and restore after restart as a fail-closed `idempotency_in_flight` state until the authoritative operation store finalizes that exact reference. Settlement starts the replay TTL; a long-running operation does not lose its replay window. Same key and same canonical request returns the original outcome; same key with different input returns `idempotency_conflict`. Capacity exhaustion fails closed rather than evicting any unexpired or in-flight record. +- Workspace, chat metadata, file, task, settings, and task pause/resume edits require server revision or content version. Stale mutation returns `revision_conflict` with current safe revision only. +- Turns and Git/file/schedule operations have stable owners independent of TCP. Revocation/shutdown/cancel policy is explicit per operation; socket close alone is never authority to mutate state. +- Approval response is idempotent by approval ID and decision. A conflicting second decision returns `approval_already_resolved`. + +## 8. Limits and logging + +The implementation must set explicit defaults and expose safe capability metadata for request bytes, JSON depth, attachment totals, active devices, streams, replay journal events/bytes/retention, browser pages/depth, file index bounds, text read/write bytes, diff bytes, schedule output bytes, rate limits, and timeouts. Ordinary JSON requests are limited to 1 MiB. Attachment uploads have a dedicated 12 MiB JSON-envelope limit. + +Logs may include request ID, route template, status, latency, instance/device ID suffix, and stable error code. Logs never include Authorization, pairing secrets, idempotency keys, opaque handles, request/response bodies, paths, prompts, message text, attachment data, tool arguments/results, provider errors, Git output, schedule output, or App Group/Keychain contents. + +## 9. Transport identity + +LAN transport uses an installation-local P-256 CA and a server-only leaf with a stable P-256 key. The server presents the leaf plus CA chain; the QR payload carries the HTTPS endpoint and `sha256/`. Certificate renewal keeps the leaf key and pin. Key rotation is explicit, invalidates the old pin, and requires recovery/re-pairing. The iOS trust path anchors only the presented local CA, validates hostname, validity, certificate signature, server-auth usage, and the expected leaf SPKI digest; a matching pin alone must not accept an expired or wrong-host certificate. + +When LAN access is enabled, discovery advertises `_aiden-agent._tcp` over Bonjour. The service label uses the bounded Mac display name plus a stable short suffix derived from the public instance identifier so same-named installations remain distinguishable. Its TXT record may expose only `v=1` and the full public Aiden instance identifier; the service port comes from Bonjour. Pairing secrets, device credentials, pins, paths, capability grants, and user content are never discovery metadata. The iOS app declares `_aiden-agent._tcp` in `NSBonjourServices` and provides `NSLocalNetworkUsageDescription`; disabling LAN access withdraws the advertisement. + +Tailscale mode uses the server-owned loopback Aiden HTTP listener behind Tailscale Serve HTTPS plus the same Aiden device credential. Because `--set-path=/api/aiden/v1` strips that public mount prefix before reverse proxying, the target must restore the exact canonical base: `http://localhost:/api/aiden/v1`, IPv4 loopback, or IPv6 loopback, with no credentials, additional path, query, or fragment. Before connect, inspect current config. An explicitly incompatible or Funnel-enabled HTTPS listener is a connect conflict. When Serve status is empty, first-connect eligibility comes from an exact normalized match between the node's stable DNS name and its Tailscale certificate domains; Aiden never completes Tailscale's HTTPS authorization flow for the user. Add one exact Aiden-owned non-Funnel route. Disconnect uses its matching route-specific `off`; never use Serve reset or replace unrelated configuration. A pre-acceptance origin-only target may be recognized solely for exact persisted-ownership cleanup before replacement with the canonical target; it is never accepted for a new connection. + +### Operating multiple devices and Aiden installations + +- One Aiden installation accepts multiple paired phones and iPads. Each device receives a distinct credential and has independent activity, streams, approvals, attachment references, scheduled-task operations, and revocation. Revoking one device must not interrupt or re-authorize another. +- Aiden On The Go stores each Mac as a separate installation keyed by its public `instanceId`. The display name is only a label, so same-named Macs remain distinct. Credentials, caches, navigation work, App Intents, and Live Activities stay scoped to that identifier during repeated switching and removal. +- Multiple fresh Aiden profiles on one physical Mac may use distinct persisted LAN/loopback port pairs. A profile with paired devices never moves its endpoint automatically; a collision is reported as `remote_port_in_use` with recovery guidance. +- Only one profile on a physical Mac may own the canonical Tailscale Serve path. A live incumbent cannot be taken over. A stale exact Aiden handler may be replaced only after an explicit review and immediate verification; unrelated Serve handlers and Funnel state remain unchanged. +- If the Tailscale CLI returns an ambiguous result or route visibility is delayed, Aiden records an unknown outcome before mutation and blocks ordinary route actions and pairing. Use **Verify update** in Remote Access after Tailscale settles. Verification either commits the exact route owner or proves the old state is unchanged; it never guesses or resets Serve. +- The desktop connection summary may show active, inactive, pending, and revoked device labels plus bounded activity times. It never includes bearer credentials, pairing secrets, certificate material, opaque handles, approved folder paths, prompts, or attachment contents. + +When moving a phone between Macs, select the intended saved Mac before starting work. If a Mac is offline, the saved entry and its credential remain isolated and another installation can be selected. Removing a saved Mac from the phone deletes only that installation's local credential and caches; pairing again creates a new device credential. Removing or revoking a device from the Mac invalidates only that device. Removing an Aiden workspace does not delete its folder from disk. + +## 10. Contract change process + +Change this document, OpenAPI, TypeScript contract constants/types, shared fixtures, and Swift/TypeScript fixture tests in one phase. Additive changes increment `contractRevision`. Breaking changes require `/v2`. No handler may ship an undocumented route or field. diff --git a/docs/plans/README.md b/docs/plans/README.md index 375007c8..c028c0c1 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -7,10 +7,13 @@ This directory is the source of truth for Aiden's implementation plans. The engi | Plan | Status | Current state | | -------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Aiden Assistant](aiden-assistant-plan.md) | Partial | The dock, Markdown rendering, and confirmed provider-connection/model-pinned project-or-MCP automation creation/editing ship; settings tools and proactivity remain planned. | +| [Aiden On The Go](aiden-on-the-go-plan.md) | Active | Version 0.1.0 build 16 is `VALID` and `IN_BETA_TESTING` for Internal Testers. iOS now has progressive onboarding, swipeable pairing, bidirectional media, reliable mobile approvals, Mac-aligned typed activity timelines, and sparse semantic interaction haptics; physical iPad/manual system-UI acceptance, privacy publication, final store assets, and external/public-release decisions remain open. | +| [Aiden Manual Pairing](aiden-manual-pairing-plan.md) | Implemented | The reviewed 100-bit setup-code path, shared one-use QR window, staged iOS activation, and adversarial coverage ship; hands-on LAN/Tailscale UI and physical-iPad acceptance remain open. | | [Compaction](compaction-plan.md) | Partial | Pi-native checkpoints, lifecycle/crash recovery, and exact audited-upstream compatibility ship; durable memory and provider-native paths remain open. | | [Designer Mode](designer-mode-plan.md) | Planned | Phase 0 validation has not started in the runtime. | -| [Dynamic Model Catalog](dynamic-model-catalog-plan.md) | Partial | Stored Pi catalogs, cache-only hydration, and explicit provider refresh ship; remote overlays for otherwise-static providers remain open. | +| [Dynamic Model Catalog](dynamic-model-catalog-plan.md) | Implemented | Validated pi.dev overlays, offline `0600` cache hydration, scoped setup refresh, four-hour launch refresh, force refresh, Pi metadata fallback, and Mac/iOS projection ship on pinned Pi 0.80.10. | | [Generation Progress Notes](generation-progress-notes-plan.md) | Planned | No implementation yet. | +| [Onboarding Authentication and Provider Validation](onboarding-auth-and-provider-validation-plan.md) | Active | Codex onboarding now uses its dedicated auth surface, completion state is main-owned, and hosted/local endpoint readiness validation is under focused verification and fresh review. | | [Performance, Stability, Battery, and Efficiency](performance-stability-efficiency-plan.md) | Planned | Whole-app source audit is complete; implementation starts with instrumentation, durable state, and hard memory bounds. | | [Pi Provider Integration](pi-provider-integration-plan.md) | Partial | Pi built-ins, stores, auth, native routing, custom provider composition, canonical assistant provenance, and voice credential lookup ship; scalable UX and rollout cleanup remain. | | [Subagent Orchestration Expansion](subagent-orchestration-expansion-plan.md) | Active | Phases 0–6, Phase 7A durable lifecycle, and the canonical Phase 7B1 storage seam are complete; app-lifetime coordinator activation is next. | @@ -22,6 +25,8 @@ This directory is the source of truth for Aiden's implementation plans. The engi | Plan | Status | Completion note | | ---------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Aiden iOS Interaction Haptics](completed/aiden-ios-interaction-haptics-plan.md) | Complete | A scoped semantic SwiftUI haptic lane now covers authoritative high-value interactions with hardware, preference, foreground, microphone, replay, cancellation, and dismissal gates. | +| [Aiden Remote Multi-Instance Hardening](completed/aiden-remote-multi-instance-hardening-plan.md) | Complete | Authenticated pairing completion, multi-device/Mac isolation, transactional listeners, exact Tailscale ownership, durable revocation, and physical-iPhone acceptance all pass. | | [Keyboard Command System](completed/keyboard-command-system-plan.md) | Complete | One command catalog now powers transactional global hotkeys, scoped app shortcuts, native menus, canonical settings, and the `Command-K` palette. | | [Slash Commands and Skill Invocation](completed/slash-commands-and-skill-invocation-plan.md) | Complete | Separate `/` command and `$` skill palettes dispatch canonical app actions and fresh workspace-bound one-turn skills with safe provenance. | | [Aiden-Native Subagents](completed/aiden-native-subagents-plan.md) | Complete | All five phases passed focused/package gates, two final fresh reviews, and the default 100-cycle packaged lifecycle soak. | @@ -36,4 +41,3 @@ This directory is the source of truth for Aiden's implementation plans. The engi | [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. | 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/aiden-assistant-plan.md b/docs/plans/aiden-assistant-plan.md index 23b1f6d6..9844dbd0 100644 --- a/docs/plans/aiden-assistant-plan.md +++ b/docs/plans/aiden-assistant-plan.md @@ -52,7 +52,7 @@ Tailwind + semantic tokens in `renderer/styles.css`. equality_ between live broadcast sites and that list, so a missed entry fails CI. - Adding `"assistant:"` to `INVOKE_PREFIXES` fails the "every INVOKE_PREFIX has at least one live handler" test until a handler exists. Prefix and first handler land together. -- Every new test file is registered in a `package.json` test script (per `CLAUDE.md`). +- Every new test file is registered in the appropriate `package.json` test script so CI runs it. - UI work reviews `docs/chatgpt-desktop-ui-inspiration.md` and `docs/chatgpt-ui-element-specimen.html` first, and uses semantic tokens from `renderer/styles.css` / `renderer/shared/appearance.ts` — no one-off colors. diff --git a/docs/plans/aiden-manual-pairing-plan.md b/docs/plans/aiden-manual-pairing-plan.md new file mode 100644 index 00000000..d445b5b5 --- /dev/null +++ b/docs/plans/aiden-manual-pairing-plan.md @@ -0,0 +1,51 @@ +# Aiden Manual Pairing + +Status: Implemented; manual LAN/Tailscale UI acceptance remains open + +## Objective + +Add a human-readable setup-code path beside the existing QR flow without weakening Aiden Remote's certificate-pinned trust bootstrap, one-use credential issuance, or multi-Mac isolation. + +## Protocol decision + +Aiden will not send a low-entropy numeric bearer password and will not implement a new elliptic-curve PAKE in application code. There is no maintained Swift-native PAKE dependency that interoperates cleanly with Electron without adding a Rust/XCFramework/native-addon supply chain. + +The manual path therefore uses a uniformly random 100-bit Crockford Base32 setup code. The code is never sent to the Mac. It locally derives an AES-256-GCM key with HKDF-SHA256 and decrypts the existing canonical QR trust envelope fetched from the selected Mac. An offline attacker receives only an authenticated ciphertext and must search the full 100-bit code space. After decryption, iOS applies the existing strict endpoint, expiry, private-CA/system-trust, SPKI-pin, and 256-bit one-use-secret validation before using the existing pinned pairing exchange. + +The QR and setup-code paths share one pairing window and one exchange secret, so exactly one device can win. A code cannot locate a Mac: LAN users select a Bonjour-discovered exact instance, while Tailscale users provide the canonical endpoint. + +## Invariants + +- Pairing remains off by default and locally initiated on the Mac. +- Setup codes expire after five minutes, are replaced atomically, and never enter logs, persistence, public snapshots, Bonjour TXT records, URLs, query strings, or App Group state. +- The manual bootstrap endpoint returns only a bounded, static encrypted envelope while a pairing window is open. +- The setup code is normalized with an exact ASCII allowlist; Unicode lookalikes are rejected. +- The decrypted envelope must name the exact selected endpoint before any credential exchange. +- QR and manual flows consume the same 256-bit secret synchronously before durable device issuance. +- A device is presented as connected only after its first authenticated callback. +- Failed pairing never replaces or damages an existing installation or credential. + +## Phases + +1. Align the documented QR envelope, manual-bootstrap schema, errors, threat model, and shared fixtures. +2. Add the in-memory setup-code lifecycle, sealed-envelope creation, bounded unauthenticated route, IPC-only code delivery, and adversarial TypeScript tests. +3. Add Mac scan/copy/manual-code presentation with expiry, regeneration, accessibility, and certificate-check separation. +4. Add iOS Scan, Paste Setup Payload, and Enter Code paths; nearby exact-instance selection; canonical Tailscale endpoint entry; sealed-envelope decryption; and existing pinned exchange reuse. +5. Add Swift contract, cryptographic-vector, endpoint-binding, lifecycle, Keychain rollback, permission, and compatibility coverage. +6. Run focused TypeScript and Swift gates, three fresh-memory reviews, fix every actionable finding, and update project memory and plan status. + +## Acceptance + +- Existing QR-only clients remain compatible. +- The same pairing window cannot issue two credentials through any QR/manual race. +- Captured manual-bootstrap traffic contains no reusable bearer secret and cannot be modified without authenticated-decryption failure. +- Wrong, malformed, expired, replaced, cancelled, replayed, oversized, and cross-Mac inputs fail with typed behavior and no state corruption. +- LAN private-CA and Tailscale system-trust pairing pass on physical devices. + +## Implementation and review result + +Implemented 2026-08-21 across Electron main, the authenticated Remote API contract, Remote Access settings, and Aiden On The Go. The Mac creates one five-minute QR/setup-code window; the setup code is displayed only through IPC, while the unauthenticated endpoint returns a bounded AES-256-GCM envelope. iOS can select a discovered Mac or enter an exact Tailscale endpoint, decrypts locally, then reuses the existing pinned one-use exchange. + +Three fresh-memory reviews covered server races/security, iOS transport and credential persistence, and cross-platform UX/contracts. Their accepted findings are closed: credential issuance has an in-mutation session fence; iOS stages and authenticates the new installation before promotion; re-pair credentials use versioned Keychain scopes with an atomic registry pointer; bootstrap reads are stream-bounded; ASCII/base64url inputs are canonical; stale discovery selection and pairing-task cancellation fail closed; Tailscale endpoints, expiry, regeneration, and selected-state accessibility are explicit; and both runtimes execute the shared crypto vector under contract revision 3. + +Automated verification passes: 216 Aiden Remote tests plus seven transport proofs, TypeScript type-check, lint, iOS release policy, diff check, and the complete physical iPhone 13 Pro XCTest target with 101 passes, five expected environment-gated skips, and zero failures. No simulator was used. A real manual setup-code entry through the shipping UI over LAN and Tailscale, plus physical-iPad acceptance, remains an explicit hands-on acceptance step; no claim is made that those UI checks occurred in this implementation run. diff --git a/docs/plans/aiden-on-the-go-plan.md b/docs/plans/aiden-on-the-go-plan.md new file mode 100644 index 00000000..7a08daf2 --- /dev/null +++ b/docs/plans/aiden-on-the-go-plan.md @@ -0,0 +1,774 @@ +# Aiden On The Go Plan + +Status: Active — Phases 0–4, 7, 9, 10, and 11 are complete; Phases 5/6/8 are implemented, LAN and real Tailscale are proven on a physical iPhone, and version 0.1.0 build 15 is `VALID` and `IN_BETA_TESTING` for Internal Testers with Mac-aligned typed activity timelines; physical-iPad and external/public-release acceptance remain open +Date: 2026-08-18 +Owners: Aiden Electron main process and the SwiftUI app under `ios/` + +## Goal + +Turn the imported Hermex SwiftUI application into **Aiden On The Go**, a native iPhone and iPad client for the locally running Aiden Agent desktop application. + +The desktop app remains the execution authority. The mobile app connects over the local network or Tailscale, pairs to a specific Aiden installation, and controls Aiden through a narrow authenticated API. The complete product scope is deliberately limited to Aiden capabilities that have a real desktop equivalent: + +- Pairing and connection management. +- Aiden chats: list, open, create, rename, delete, compose, stream, cancel, and approve or deny tool requests. +- Aiden workspaces: list, create folderless or managed-scratch entries, explore approved folders on the paired Mac, register a selected folder, rename, change permission, and unregister. +- The provider/model choices required to start an Aiden chat, without exposing credentials. +- Aiden branding and the mobile-applicable Aiden appearance choices. +- Aiden workspace files, Git workflows, scheduled tasks, App Intents, Live Activities, and local voice controls, delivered after the core chat/workspace transport is stable. + +Kanban and every other Hermex screen without a deliberate Aiden contract are removed rather than left disconnected or filled with placeholder behavior. + +## Audited starting point + +### Aiden desktop + +Aiden does not currently expose an inbound application server. Its production control path is: + +```text +React renderer + -> sandboxed preload + -> allowlisted Electron IPC + -> Electron main process + -> chat/workspace services and Pi runtime +``` + +The existing chat notifications (`chat:delta`, `chat:reasoning-delta`, `chat:tool`, `chat:timeline`, `chat:approval`, `chat:done`, and `chat:error`) are delivered only to the Electron renderer that owns the generation. Workspace operations are likewise tied to a live renderer document. An HTTP handler must not impersonate or bypass that ownership model. + +Relevant sources: + +- `main/services/renderer-document-owner.ts` +- `main/services/chat-generation-owner.ts` +- `main/services/llm-client.ts` +- `main/handlers/chat.ts` +- `main/handlers/chats.ts` +- `main/handlers/workspaces.ts` +- `main/services/chat-store-core.ts` +- `main/services/config-store-core.ts` + +### Imported SwiftUI app + +Hermex already provides a strong native shell, iPhone/iPad navigation, REST client, resumable SSE client, chat transcript, reasoning/tool rendering, approvals, Keychain storage, offline cache, and workspace-registry manager. It is currently coupled to the unrelated Hermes WebUI endpoint and event vocabulary. + +Retain as foundations: + +- `ios/HermesMobile/ContentView.swift` +- `ios/HermesMobile/Features/SessionList/` +- `ios/HermesMobile/Features/Chat/` +- `ios/HermesMobile/Networking/APIClient.swift` +- `ios/HermesMobile/Networking/SSEClient.swift` +- `ios/HermesMobile/Features/Workspace/WorkspaceManagerView.swift` +- `ios/HermesMobile/Features/Workspace/WorkspaceRegistryViewModel.swift` +- `ios/HermesMobile/Persistence/` +- `ios/HermesMobile/Config/AppTheme.swift` +- `ios/HermesMobile/Features/Shared/AdaptiveGlassModifier.swift` + +The imported code remains MIT-licensed upstream work. Preserve its copyright and third-party notices while renaming and adapting it. + +## Product boundary + +### Ship in the first complete release + +1. Desktop Remote Access settings and lifecycle. +2. QR/one-time-code pairing, per-device credentials, device list, and revocation. +3. Local-network discovery plus manual host entry. +4. Tailscale-compatible connection through a stable HTTPS address. +5. Chat and workspace registry surfaces that represent Aiden's real models and rules. +6. Resumable streaming with tool activity, reasoning, terminal state, and allow/deny approvals. +7. Aiden light/dark styling, presets, icons, naming, and iPad layouts. +8. Offline display of previously fetched chats; mutations remain disabled while disconnected. +9. Normal workspace turns retain Aiden's server-side chat capabilities (including configured skills, MCP tools, and subagents where the existing permission model allows them) without adding separate mobile management screens. Computer Use remains unavailable remotely in the first release. +10. An approved-root Mac folder explorer for adding folder-backed workspaces without accepting free-form server paths. +11. Aiden's existing workspace file list/read/write and Git workflows, adapted to the retained Hermex native views. +12. Aiden scheduled-task list, create, edit, remove, pause, resume, run-now, preview, and run-history workflows. +13. App Intents, Live Activities, and local voice dictation/read-aloud behavior that never grants background authority beyond the paired device and workspace permission. + +### Remove from the mobile product + +- Kanban models, endpoints, event stream, views, tests, scripts, and navigation. +- Hermes Skills, Memory, Insights, and server-panel screens. +- Hermes projects and path-as-workspace identity. +- Hermes personalities/profiles and server-specific commands. +- Hermes goals, Btw/background controls, clarification flows, and other actions without an Aiden equivalent. +- Hermes Cloudflare onboarding, branding, URLs, copy, icons, and gold theme settings. +- Terminal and arbitrary shell controls. + +### Defer until the core client is stable + +- Share extension. +- Push notifications that require an external relay or cloud service. +- Voice-note upload and server TTS. The planned voice surface is local dictation into the composer plus optional on-device read-aloud. +- Computer Use controls. +- Subagent management UI. +- Chat copy/export, branching, pinning, archiving, and history truncation unless Aiden first ships matching service semantics. +- File create/rename/delete and Git/terminal actions without an existing Aiden desktop service contract. + +## Target architecture + +```text +Aiden On The Go (SwiftUI) + -> HTTPS REST for commands and snapshots + -> resumable SSE for generation events + -> device bearer credential from Keychain + +Aiden Agent (Electron main) + -> Remote API transport (off by default) + -> remote device/session ownership + -> shared chat/workspace application services + -> existing stores, mutation gates, llmClient, and Pi runtime +``` + +The API belongs to Aiden and is versioned at `/api/aiden/v1`. Do not permanently reproduce the entire Hermes `/api/*` protocol. Swift models and endpoint functions are converted to Aiden DTOs. Hermex code is reused for implementation patterns, not treated as the new protocol source of truth. + +## Remote API contract draft + +Before implementing handlers, add a checked-in protocol specification and JSON fixtures. The specification owns field names, nullability, limits, status codes, error codes, and event ordering. TypeScript and Swift tests consume the same fixtures. + +### Public bootstrap surface + +- `GET /health` + - Minimal availability and protocol version only. + - Must not expose paths, chats, providers, host usernames, or configuration. +- `POST /api/aiden/v1/pairing/exchange` + - Accepts a short-lived single-use pairing secret and client public metadata. + - Returns the Aiden instance ID, device ID, device credential, API capabilities, and server identity needed for pinning. + - Rate-limited; pairing must already be open in the desktop app. + +### Authenticated server/device surface + +- `GET /api/aiden/v1/server` + - Instance name, app/protocol version, capabilities, and connection mode. +- `GET /api/aiden/v1/devices` + - Desktop Settings only unless a later capability explicitly grants it remotely. +- `DELETE /api/aiden/v1/devices/:deviceId` + - Revokes the selected device and closes its streams. + +### Workspace registry + +- `GET /api/aiden/v1/workspaces` +- `GET /api/aiden/v1/workspaces/:workspaceId` +- `POST /api/aiden/v1/workspaces` +- `PATCH /api/aiden/v1/workspaces/:workspaceId` +- `DELETE /api/aiden/v1/workspaces/:workspaceId` +- `GET /api/aiden/v1/workspace-browser/roots` +- `GET /api/aiden/v1/workspace-browser/children?location=...&cursor=...` +- `POST /api/aiden/v1/workspace-browser/selections` + +Mobile workspace DTOs use Aiden IDs, names, permission (`full`, `ask`, `none`), folder-presence metadata, a small Git summary, and timestamps. Managed-worktree projection is explicitly allowlisted to display-only state such as `isManagedWorktree` and branch/repository display names. Absolute paths, worktree Git/admin paths, ownership tokens, device/inode values, remote URLs, and other config-store internals never cross the API. Absolute filesystem paths are not identifiers or writable request fields. + +The supported create modes are: + +- `folderless`: create a named Aiden context without a folder. +- `scratch`: ask Aiden to create and own a scratch directory. +- `selected-folder`: register a directory chosen through the server-controlled workspace browser. + +The phone never submits a free-form Mac path. Remote Access settings on the Mac own an explicit list of browsable roots, initially suggested from parents of already registered workspaces and extended only by a local desktop action. Each approved-root record stores a random root ID, canonical path, device/inode identity, policy revision, display label, and hidden/system-directory policy. Dedupe or reject nested roots. Broad roots such as the home directory require a specific local warning and confirmation; the filesystem root is disabled by default and cannot be enabled remotely. + +The browser returns friendly root labels, high-entropy opaque location tokens, bounded/paginated directory children, and breadcrumbs; it does not return file contents or recursively index a root. Store only token digests server-side and bind each handle to the Aiden instance, authenticated device, root ID/policy revision, canonical internal location, device/inode identity, expiry, and maximum depth. Never embed or log paths in client-visible tokens. Cursors bind to the exact root and parent-location token; listing has deterministic ordering, fixed maximum page/depth limits, hidden/system filtering, and per-device rate limits. + +Canonicalize every location with `realpath`, reject traversal and symlink escape, and re-check the approved-root boundary and device/inode identity after every navigation. A root, directory, or symlink replacement invalidates the handle and fails closed. + +`POST /workspace-browser/selections` exchanges the current location handle for a separate short-lived, single-use selection nonce bound to the authenticated device, root/policy revision, canonical directory identity, and expiration. `POST /workspaces` accepts that nonce rather than a path. Selection consumption and workspace creation are one atomic/idempotent operation: immediately before mutation, re-check `realpath`, directory type, root boundary, policy revision, device/inode identity, and duplicate-workspace state; then consume the nonce exactly once. Concurrent reuse, a policy change, or a browse-to-register filesystem race fails closed. The new workspace uses Aiden's existing default `ask` permission unless the server contract deliberately changes that default. Existing folder-backed workspaces can be read, renamed, permission-changed, and unregistered. Deleting a workspace record never deletes its folder. Managed-worktree deletion stays a separate Git operation with its own confirmation. + +Workspace mutation must reuse Aiden's existing cancellation, schedule-restoration, operation-registry, default-workspace, and safe-removal rules. A paired device receives `workspace:manage` by default to satisfy the confirmed full-CRUD behavior; pairing UI discloses that authority. Raising a workspace from `none`/`ask` to a stronger permission requires an explicit foreground confirmation and an audit event, but not a second Mac confirmation. Add authoritative workspace-created/updated/removed broadcasts for both Electron and mobile consumers; do not assume the current IPC handlers already broadcast, and do not carry over Hermex-only workspace reordering. + +### Chats + +- `GET /api/aiden/v1/chats?workspaceId=...` +- `POST /api/aiden/v1/chats` +- `GET /api/aiden/v1/chats/:chatId` +- `PATCH /api/aiden/v1/chats/:chatId` for supported metadata such as title +- `DELETE /api/aiden/v1/chats/:chatId` +- `POST /api/aiden/v1/chats/:chatId/move` only for an empty chat, matching Aiden's existing rule + +The mobile projection includes Aiden's renderer-safe chat fields only: IDs, workspace ID, provider/model IDs, timestamps, visible messages, safe reasoning, safe timeline/tool milestones, attachments, and closed provider-failure metadata. Private Pi journals, raw diagnostics, provider credentials, subagent private history, and filesystem internals never cross the API. + +Ordinary remote chat creation cannot mint Aiden's reserved Assistant workspace identity or unattended modes. Remote workspace turns follow the same main-owned capability composition and permission checks as an attended desktop workspace chat; the transport cannot request hidden modes or widen tool authority. + +### Provider/model discovery + +- `GET /api/aiden/v1/models` + +Return only connected/configured provider identities, selectable models, capabilities needed by the composer, thinking-level choices, and the server's current defaults. Never return provider tokens, base authentication headers, credential payloads, or private provider configuration. + +### Turns, streams, and approvals + +- `POST /api/aiden/v1/chats/:chatId/turns` + - Atomically validates the chat/workspace/provider/model, appends the user message, acquires the Aiden turn lease, and starts generation. + - Returns `turnId`, `streamId`, accepted state, and the canonical appended message. +- `GET /api/aiden/v1/streams/:streamId` + - Active/terminal status and authoritative chat ID. +- `GET /api/aiden/v1/streams/:streamId/events` + - SSE stream; supports `Last-Event-ID` and explicit `after` sequence. +- `POST /api/aiden/v1/streams/:streamId/cancel` +- `POST /api/aiden/v1/approvals/:approvalId/respond` + - First release accepts only `allow` or `deny`, matching Aiden's enforceable scope. + +Every event has a monotonically increasing sequence number, stream ID, event type, timestamp, and typed payload. Initial vocabulary: + +- `snapshot` +- `status` +- `text_delta` +- `reasoning_delta` +- `tool_started` +- `tool_finished` +- `timeline` +- `approval_required` +- `done` +- `error` +- `cancelled` +- `heartbeat` + +The client never resends a user message because an SSE connection dropped. It asks stream status, reconnects with its last sequence, and reconciles the final authoritative chat snapshot. + +### Attachments + +Aiden already models bounded inline image and text attachments. The remote API should use bounded multipart upload or a bounded JSON upload step that produces a short-lived attachment reference, then translate that reference into Aiden's existing attachment model during the atomic turn operation. + +Do not accept arbitrary server-side file paths from the phone. Enforce content type, byte size, decoded image size, text truncation, aggregate turn limits, expiry, and cleanup. Attachments are included only after the core text-turn path is stable. + +### Workspace files and Git + +Expose only operations already owned by Aiden's workspace and Git services: + +- `GET /api/aiden/v1/workspaces/:workspaceId/files` +- `GET /api/aiden/v1/workspaces/:workspaceId/files/:fileId` +- `PUT /api/aiden/v1/workspaces/:workspaceId/files/:fileId` +- `GET /api/aiden/v1/workspaces/:workspaceId/git/review` +- `POST /api/aiden/v1/workspaces/:workspaceId/git/diff` +- `GET /api/aiden/v1/workspaces/:workspaceId/git/branches` +- `POST /api/aiden/v1/workspaces/:workspaceId/git/checkout` +- `POST /api/aiden/v1/workspaces/:workspaceId/git/branches` +- `POST /api/aiden/v1/workspaces/:workspaceId/git/commit` +- `GET /api/aiden/v1/workspaces/:workspaceId/git/push-capability` +- `POST /api/aiden/v1/workspaces/:workspaceId/git/push` +- `POST /api/aiden/v1/workspaces/:workspaceId/git/compare` and `/comparison-diff` +- `GET|POST /api/aiden/v1/workspaces/:workspaceId/git/worktrees` +- `DELETE /api/aiden/v1/workspaces/:workspaceId/git/managed-worktree` + +Match Aiden's current file-index semantics: one bounded recursive snapshot, currently capped by the service at 4,000 entries and depth 20, with explicit `truncated` metadata. Do not silently pretend it is a complete tree or reuse workspace-browser location tokens. The index may display safe workspace-relative names, but read/write requests use separate high-entropy, server-side opaque file handles bound to instance, device, workspace, canonical root identity, relative file identity, expiry, and index snapshot. Every operation re-resolves the handle under the current canonical workspace root and fails on root/file replacement or escape. Writes require Aiden's `expectedVersion` value and return the authoritative document or a stable conflict result. The first release matches desktop file semantics—index, read, and version-checked atomic write—and does not invent file create, rename, or delete actions. + +Git DTOs use an explicit field allowlist for Aiden's review, bounded diff, comparison, branch, push-capability, and managed-worktree display results. They omit absolute paths, Git admin paths, credentials, remote URLs containing secrets, ownership tokens, device/inode values, head metadata, and raw command output. Managed-worktree deletion accepts only the persisted workspace ID and re-resolves server-owned metadata; it never accepts client-supplied filesystem/admin fields. + +Commit, push, checkout, branch creation, worktree creation, and managed-worktree deletion require an explicit foreground confirmation in the phone UI and must not be callable from App Intents. Commit and push retain Aiden's repository-root-only rule; nested workspaces surface the server's read/diff-only capability reason. Reuse Aiden's workspace operation registry, mutation gate, canonical Git-common-directory serialization, cancellation rules, worktree ownership validation, rollback, and schedule restoration. Network operations return stable `operationId`/snapshot metadata and stale-conflict errors; a TCP disconnect does not implicitly invalidate the authenticated remote operation owner. The remote transport does not expose fetch, pull, stage/unstage, discard, a generic Git command, or a shell endpoint because Aiden has no matching service contract. + +### Scheduled tasks + +Mirror Aiden's existing scheduled-task services rather than Hermes Cron payloads: + +- `GET|POST /api/aiden/v1/scheduled-tasks` +- `GET|PATCH|DELETE /api/aiden/v1/scheduled-tasks/:taskId` +- `POST /api/aiden/v1/scheduled-tasks/:taskId/pause` +- `POST /api/aiden/v1/scheduled-tasks/:taskId/resume` +- `POST /api/aiden/v1/scheduled-tasks/:taskId/run` +- `GET /api/aiden/v1/scheduled-tasks/:taskId/runs` +- `POST /api/aiden/v1/scheduled-tasks/preview` +- `GET /api/aiden/v1/scheduled-tasks/scripts?workspaceId=...` +- `GET /api/aiden/v1/scheduled-tasks/mcp-servers` +- `GET|PATCH /api/aiden/v1/scheduled-tasks/settings` + +Mobile supports list, create, edit, remove, pause, resume, run now, preview, and run history using Aiden's validated task schema, workspace/provider/model bindings, timezone rules, script inventory, global enable switch, permissions, notification preference, and selected MCP IDs. Remote task DTOs explicitly omit provider fingerprints, resolved MCP bindings, chat IDs, provider credentials, raw script paths, and other internal runtime metadata. Project run output/errors through a bounded, redacted display DTO because stored output can still contain paths or secrets. The client selects only server-inventoried script IDs; it never names a path. + +Creating or editing unattended work is a consequential foreground action with a final review screen. Edits require `If-Match`/`expectedUpdatedAt`, and settings mutations require an equivalent revision or serialized mutation gate, producing stable conflict responses for concurrent Electron/mobile changes. `POST .../:taskId/run` is an idempotent accepted operation: an idempotency key returns one `runId`, execution is owned durably by the schedule service, status is observed through run history, and a TCP disconnect does not cancel it. Cancellation follows the existing task remove/pause/global-disable/revocation/shutdown policy; do not add a bespoke mobile cancellation action unless Aiden desktop first exposes matching semantics. App Intents cannot create, edit, remove, enable, or run tasks in the first release. Remote mutations broadcast the same authoritative updates used by the Electron UI. + +## Shared desktop application services + +Do not call Electron IPC handlers from the HTTP layer. Extract or introduce main-process application services whose inputs are already parsed and whose ownership is explicit: + +- `ChatApplicationService` + - list/get/create/rename/delete/move-empty + - append-and-start as one transaction +- `WorkspaceApplicationService` + - list/get/create-folderless/create-scratch/create-from-selection/update/remove + - existing mutation and operation gates remain authoritative +- `WorkspaceBrowserService` + - approved roots, bounded directory navigation, opaque location/selection tokens, canonical-path revalidation +- `WorkspaceFileApplicationService` + - safe index/read/version-checked write over the existing file services +- `GitApplicationService` + - review/diff/compare/branches/commit/push/worktrees over the existing operation and mutation gates +- `ScheduledTaskApplicationService` + - validated list/save/remove/pause/resume/run/preview/history/settings operations and broadcasts +- `RemoteGenerationOwner` + - stable device/session identity independent of `WebContents` + - event journal and subscribers + - cancellation and approval ownership + - lifecycle invalidation on revocation or server shutdown +- `RemoteDeviceStore` + - instance identity, paired device records, credential hashes, capabilities, revocation, and last-seen metadata + +Electron IPC remains renderer-owned. Both IPC and the remote transport should call shared service operations where their semantics truly match; renderer-only actions keep their renderer requirements. + +## Stream durability and concurrency + +The remote owner is tied to a paired device and stream, not to one TCP connection. A temporary network loss must not cancel a turn. + +- Maintain a bounded per-stream sequence journal for replay. +- Persist enough terminal metadata to reconcile after app or network restart. +- Persist the assistant response through the existing chat store as the authority. +- If Aiden restarts during an active remote turn, close it with an explicit interrupted terminal state; never silently retry the model call. +- Expire terminal journals after a documented retention window while keeping chat history. +- A replayed approval retains one approval ID and deadline; duplicate responses are idempotent or return a stable already-resolved result. +- Chat deletion, workspace mutation, cancellation, provider failure, renderer activity, and multiple paired devices need explicit race tests. +- Remote-started chat metadata changes must use the existing desktop broadcasts so the Electron sidebar stays current. + +## Pairing, transport, and security + +### Desktop controls + +Add a Remote Access settings page using existing Aiden settings components and semantic tokens: + +- Master enable switch, off by default. +- Listen mode: Tailscale, local network, or both. +- Port/status and reachable addresses. +- Explicit Tailscale `Connect` / `Disconnect` controls with a preview of the exact Aiden-owned Serve route. +- Approved workspace-browser roots, editable only on the Mac. +- `Pair device` action that shows a QR code and short code with expiration. +- Paired device list with name, type, last seen, and Revoke. +- Clear warnings about remote workspace authority and what each workspace permission means. + +The service starts only after explicit enablement and valid configuration. It remains available while the Aiden process is running even when the main window is closed, and settles cleanly on app quit. + +The production QR payload carries the instance ID, selected HTTPS endpoint, short-lived high-entropy pairing secret, protocol version, and pinned server public-key fingerprint. A human-sized numeric code is not sufficient authentication on an untrusted LAN by itself: its fallback flow must use a reviewed PAKE/SAS-style exchange or a separate fingerprint confirmation. Do not silently downgrade QR pairing to numeric-code-plus-plain-HTTP. + +### Device credentials + +- Generate one random credential per device. +- Store only a strong credential digest and device metadata on desktop; protect sensitive local material with Aiden's existing safe-storage patterns. +- Store the issued credential in the iOS Keychain. +- Authenticate every REST request and SSE connection. +- Bind stream/approval ownership to the authenticated device. +- Issue explicit device capabilities for chat, workspace browsing/registry, files, Git, and schedules; reject a route when the credential lacks its capability. +- Support revocation and rotation without changing provider credentials. +- Redact credentials and pairing secrets from logs and errors. +- Apply request body limits, endpoint-specific rate limits, timeouts, and a small maximum number of clients/streams. +- Do not enable permissive browser CORS; this is a native-client API. + +### Tailscale + +Tailscale supplies reachability and network encryption, not application authorization. Aiden binds to loopback behind Tailscale Serve HTTPS. The confirmed product behavior is an explicit `Connect` / `Disconnect` flow: before changing anything, inspect the current configuration, show the exact non-Funnel Serve route and command-equivalent action, record ownership of only the route Aiden creates, and on disconnect use the matching route-specific `off` operation. Never invoke `tailscale serve reset`, replace the whole Serve config, or modify unrelated routes. If the existing Tailscale configuration conflicts, stop and explain the conflict instead of taking over. If tailnet HTTPS certificates are not enabled, explain the prerequisite and let the user complete Tailscale's own authorization flow instead of enabling it silently. The desktop UI shows the exact stable HTTPS URL used for pairing and must never enable Tailscale Funnel. + +### Local network + +- Advertise availability with Bonjour/mDNS only while local-network access is enabled. +- Discovery publishes instance identity and port, never a pairing secret or bearer token. +- Add the required iOS local-network usage description and Bonjour service declaration. +- Production LAN transport should use HTTPS with a per-install server identity pinned during QR pairing. A Phase 0 transport spike must prove certificate generation, renewal, pinning, and recovery before the API is exposed broadly. +- Pin the stable server public key rather than a short-lived certificate when feasible. Certificate renewal may retain that key; key rotation requires an explicit re-pair/recovery flow. +- Plain HTTP is limited to an explicit development configuration and must not be the production default. + +## SwiftUI conversion + +### Project identity + +Replace the imported identity throughout the main app, tests, configs, entitlements, localization, URL routes, and retained extensions: + +- Product/display name: `Aiden On The Go`. +- Swift/Xcode targets and schemes: rename from `HermesMobile` after the first green protocol slice so the rename is mechanically isolated. +- Apple development team: `5WP229CBB8`, matching the checked-in Contact Sheet Generator Xcode project. +- Signing: automatic, with a Phase 0 physical-device provisioning check for the main app, Live Activity widget, and App Group. +- Main bundle ID: `sbtbiswas.AidenOnTheGo`. +- Test bundle IDs: `sbtbiswas.AidenOnTheGoTests` and `sbtbiswas.AidenOnTheGoUITests`. +- Live Activity widget bundle ID: `sbtbiswas.AidenOnTheGo.LiveActivityWidget`. +- App Group: `group.sbtbiswas.AidenOnTheGo`. +- Keychain service: `sbtbiswas.AidenOnTheGo.pairing`. +- URL scheme: `aiden-otg`. +- App Store SKU: `aiden-on-the-go-ios`. +- New Aiden app icons and in-app brand assets; remove every Hermes/Hermex icon, banner, gold mask, and alternate icon. +- Rename Hermes-prefixed Swift types and files in bounded mechanical passes. +- Replace `ios/README.md`, `ios/PROJECT_SPEC.md`, and the Hermex-specific working agreement with Aiden-owned documentation after this plan is approved. Preserve required upstream attribution. + +The main target already declares iPhone and iPad support. Keep that device-family setting and validate real split-view behavior rather than treating iPad as a scaled iPhone. + +These values were derived from `/Users/sambitbiswas/projects/contactsheet/Contact-Sheet-Generator/ContactSheetGen.xcodeproj/project.pbxproj`, whose shipped targets use automatic signing, team `5WP229CBB8`, and the `sbtbiswas.*` namespace. The repository does not contain an App Store Connect SKU, App Group, or URL scheme, so the SKU/group/scheme above are new Aiden choices rather than copied metadata. The locally installed Apple Development certificate currently reports team `7EK65FX44E`; Phase 0 must refresh or select credentials that can provision the confirmed `5WP229CBB8` team before identity work proceeds. + +### Networking + +- Keep the generic URLSession and error-handling foundation. +- Replace cookie/password auth with pairing credential injection. +- Replace path-based Hermes endpoint definitions with Aiden `/api/aiden/v1` definitions. +- Replace Hermes session/workspace DTOs with tolerant Aiden DTOs. +- Keep the SSE parser/reconnect structure but decode the Aiden event vocabulary and sequence contract. +- Keep multi-server registry concepts only if they are reframed as multiple paired Aiden installations. +- Keep Keychain-backed per-server credentials and ensure credentials/cookies never cross between servers. +- Add Bonjour discovery and manual Tailscale URL entry. + +### App navigation + +The root shell contains only: + +- Chats +- Workspaces +- Scheduled Tasks +- Settings / paired Aiden installations + +Files and Git are workspace-scoped destinations opened from a workspace rather than new global tabs. On iPhone, use the retained navigation-stack patterns. On iPad, preserve `NavigationSplitView` with workspace/chat selection in the sidebar and the selected conversation in detail. Empty, disconnected, loading, and reconnecting states must work in compact and regular size classes. + +### Chat surface + +Retain Hermex's native components only where the Aiden contract supports them: + +- Transcript and Markdown rendering. +- User and assistant messages. +- Streaming text and safe reasoning. +- Compact tool/timeline activity. +- Allow/Deny approval overlay. +- Composer, send, stop, model/provider selection, thinking level, and bounded attachments. +- Offline read cache. + +Add a top-right ellipsis in the conversation toolbar. Its workspace-backed menu opens `Workspace Settings` and the other workspace-scoped destinations; the permission control lives inside that settings screen beside name, folder/managed-worktree status, Files, Git, and management links. It must not appear in the composer. The composer remains limited to message/voice input, attachments, model/thinking choices, send, and stop. Use the retained Hermex toolbar, menu, form, and sheet patterns rather than inventing a custom control. + +Remove Hermes goals, Btw/background mode, clarification UI, server TTS, server commands, profiles/personalities, and unsupported message actions. + +### Workspace surface + +Show Aiden workspaces by stable ID and name. Support: + +- List and refresh. +- Create folderless workspace. +- Create Aiden-managed scratch workspace. +- Explore approved folders on the paired Mac and register a selected folder. +- Rename. +- Change permission with the same `full`, `ask`, and `none` meaning as desktop, from `Workspace Settings` reached through the conversation-toolbar ellipsis or workspace management screen—not from the composer. +- Unregister with explicit confirmation and copy that the folder is not deleted. + +Build folder exploration from the existing Hermex list/navigation vocabulary: approved root picker, breadcrumb/back navigation, paginated directory rows, and a native `Use This Folder` action. Do not show an arbitrary absolute path field. A mobile mutation waits for server settlement and reconciles from the returned authoritative list. + +### Files and Git surfaces + +Retain and adapt `FileBrowserView`, `GitWorkspaceView`, `GitDiffView`, `GitCommitView`, `GitBranchPickerView`, and their existing native supporting components only where they map to Aiden's DTOs. Files support index, read, edit, save, conflict/reload, and offline read cache. Git supports repository review, diff, compare, branch switching/creation, commit, push when capability permits, worktree creation, and confirmed deletion of Aiden-managed worktrees. Consequential actions use native confirmation sheets and show authoritative success/error results. No terminal or arbitrary Git command field is introduced. + +### Scheduled Tasks surface + +Adapt the Hermex Tasks screen structure and native forms, but replace every Cron DTO, endpoint, label, and assumption with Aiden's scheduled-task model. Support list/filter, create/edit, pause/resume, remove, run now, previewed next runs, and run history. The editor exposes only server-projected provider/model/workspace, mode, permission, selected MCP, notification, timezone, schedule, prompt, and validated script choices. It must clearly label that enabled tasks can run while the phone is disconnected. + +### App Intents, Live Activities, and voice + +- Retain the imported App Intent deep-link/router idea and rename it for Aiden, but migrate away from the imported deprecated `openAppWhenRun` pattern to the current SDK's `OpenIntent`/`OpenURLIntent`/URL-representable navigation contract. Ship `New Chat`, `New Chat with Voice`, and `New Chat in Workspace` intents backed only by App Group-cached Aiden installation/workspace entities. Entity lookup is cache-only and stable-ID-only: the intent process does not read pairing credentials, call the network, create a chat, embed a path/token in a deep link, or send a prompt. A stale/revoked installation opens the connection UI. Intents otherwise open the app to the requested destination and cannot alter permissions, run Git, or mutate scheduled tasks in the first release. +- Retain the ActivityKit target and reconciliation architecture. Start a Live Activity for a turn initiated on this device; update its sub-4 KB state from authoritative status/tool/approval/stream events; mark it stale on loss; reconcile by `streamId` after relaunch using the selected paired installation's Keychain credential and pinned transport; and end on done, error, cancellation, revocation, or server interruption. The widget extension cannot access the network. With no cloud push relay in scope, it displays the last known stale state while the app is suspended/terminated and reconciles only when Aiden On The Go next runs. Default Lock Screen content to title and safe status; make assistant excerpts an explicit local privacy preference; never include paths, tool arguments, raw approval details, credentials, or provider errors. +- Retain only on-device voice dictation as composer input and the `New Chat with Voice` entry point. Phase 11 removes/disables the imported server-STT provider, audio upload code path, voice-note recording/attachment path, and hold-to-record gesture. Prefer `SpeechAnalyzer` where the deployment target permits; otherwise require an on-device-capable recognizer with `requiresOnDeviceRecognition`. Request Speech and Microphone access only when the user starts dictation. Add optional on-device read-aloud of assistant text with system speech APIs. Permission denial or unavailable on-device recognition must degrade to the text composer. Do not upload raw recordings to Aiden or Apple, and do not add an Aiden server TTS endpoint. + +## Aiden appearance on iOS/iPadOS + +Use semantic Swift theme tokens, not colors scattered through views. Port the canonical Aiden, Slate, Berry, and Moss palettes from `renderer/shared/appearance.ts` into a versioned shared appearance fixture that both platforms test. + +Mobile-applicable parity: + +- System, Light, and Dark modes. +- Aiden, Slate, Berry, and Moss presets. +- Separate light/dark accent, background, and foreground configuration if custom themes are in scope. +- System, rounded, and humanist UI font choices. +- SF Mono, Menlo, and Monaco code font choices where available. +- Contrast, reduced-motion preference, UI size, and code size. +- Sidebar translucency mapped to the iPad navigation shell. +- Diff marker preference for the retained Git diff surface. + +Desktop-only settings do not become fake mobile controls: + +- Pointer cursor mode. +- macOS dock icon selection. If alternate mobile icons ship, expose only real Aiden and Monochrome iOS icons. +- Browser font-smoothing toggle. + +Appearance is confirmed to be independent and device-local on iPhone/iPad. Do not add a desktop appearance endpoint, implicit mirroring, or silent bidirectional synchronization. A later explicit `Follow desktop` mode would require its own contract and product decision. + +## Desktop onboarding and documentation + +Remote access is a durable, setup-critical feature, so update Aiden onboarding: + +- Add a concise Remote Access explanation and opt-in path. +- Explain that Aiden must be running and that Tailscale is optional transport. +- Explain workspace permissions and paired-device revocation. +- Add a data-driven final-tour tile with its own optimized 1024 x 1024 transparent PNG. +- Keep all network actions behind the user's explicit enable/pair action. + +Document local-network setup, Tailscale Serve setup, no-Funnel policy, device revocation, offline behavior, and troubleshooting without adding a normal-startup network dependency. + +## Delivery phases and acceptance gates + +### Phase 0 — Freeze decisions and prove transport + +1. Record the confirmed product decisions below in the shared protocol and iOS project specification. +2. Write the API schema, event vocabulary, capability document, error envelope, DTO allowlists, opaque-handle claims/storage, idempotency/revision rules, remote-operation ownership, and shared fixtures. +3. Prove Tailscale Serve HTTPS against a loopback test endpoint. +4. Prove LAN HTTPS identity generation, QR fingerprint transfer, URLSession pinning, renewal, and recovery on a physical device. +5. Verify that Xcode can automatically provision `sbtbiswas.AidenOnTheGo`, its Live Activity widget, and `group.sbtbiswas.AidenOnTheGo` under team `5WP229CBB8`; resolve the currently installed `7EK65FX44E` Apple Development identity mismatch. +6. Threat-model pairing, device theft, replay, revocation, LAN interception, malicious browser requests, approved-root browsing, selection-token replay, path/symlink escape, and approval races. + +Acceptance: reviewed protocol/threat model plus a correctly team-signed physical-device transport spike; no chat/workspace production endpoint yet. + +### Phase 1 — Shared Aiden service boundary + +1. Extract chat and workspace application operations from IPC-specific handlers where semantics match. +2. Add a remote owner abstraction without weakening renderer ownership. +3. Preserve all mutation, deletion, reconciliation, and approval gates. +4. Add focused unit tests showing IPC behavior is unchanged. + +Acceptance: desktop type-check, focused tests, full `npm run test`, and normal Electron chat/workspace smoke remain green. + +### Phase 2 — Remote service, pairing, and desktop UI + +1. Add the off-by-default server lifecycle. +2. Add device store, one-time pairing, credential verification, revocation, limits, and redacted diagnostics. +3. Add health/server/capability endpoints. +4. Add explicit Aiden-owned Tailscale Serve Connect/Disconnect with preview, conflict detection, ownership tracking, and unrelated-route preservation. +5. Add Remote Access Settings, approved browser roots, onboarding, feature-tour asset, and documentation. + +Acceptance: disabled means no listener; paired test client authenticates; unpaired/revoked clients fail; packaged Aiden keeps the service alive with the window closed and stops it on quit; disconnect removes only the route Aiden created and Funnel is never enabled. + +### Phase 3 — Workspace registry and safe folder-browser API + +1. Add list/get/create-folderless/create-scratch/create-from-selection/update/remove endpoints. +2. Use existing Aiden mutation gates and default-workspace rules. +3. Add desktop-approved roots, opaque location navigation, bounded directory listing, and short-lived device-bound selection tokens. +4. Exclude raw path mutation and disk deletion; canonicalize and revalidate root membership at navigation and registration time. +5. Add concurrency, stale-ID, active-chat, schedule, managed-worktree, traversal, symlink-escape, root-policy-change, and replay tests. + +Acceptance: full supported registry CRUD and approved folder selection work from a contract test client; paths outside approved roots cannot be selected; the Electron workspace UI reconciles immediately. + +### Phase 4 — Chat CRUD and remote generation + +1. Add chat list/get/create/rename/delete and optional empty-chat move. +2. Add atomic remote turn start and stable remote ownership. +3. Add resumable SSE journal, status, cancellation, reasoning/tool/timeline events, terminal reconciliation, and allow/deny approvals. +4. Add provider/model read projection. +5. Add desktop refresh broadcasts for remote mutations. + +Acceptance: a network test client completes, reconnects to, cancels, approves, and denies real mocked turns without duplicate messages or cross-device ownership leaks. + +### Phase 5 — Swift cleanup and Aiden identity + +1. Remove every out-of-scope feature and test from the Xcode project. +2. Replace product docs and the protocol source of truth. +3. Apply the confirmed Contact Sheet-derived team/bundle namespace plus the new Aiden app-group/keychain/URL/SKU identities; rename targets, schemes, symbols, and retained resources. +4. Install Aiden icons/assets and preserve license notices. +5. Keep the project buildable after each mechanical rename slice. + +Acceptance: no Hermes/Hermex user-facing identity remains; the signed app launches on iPhone and iPad; retained unit tests are green. + +### Phase 6 — Swift connection and workspace client + +1. Add discovery, manual URL, QR/short-code pairing, Keychain credential storage, multiple Aiden installations, switching, and revoke handling. +2. Implement Aiden workspace DTOs and full supported registry CRUD. +3. Implement approved-root folder exploration and registration with breadcrumbs, pagination, selection-token expiry recovery, and no editable absolute-path field. +4. Add the conversation-toolbar ellipsis and `Workspace Settings`; keep workspace permission out of the composer. +5. Add offline/error/retry states and local-network permission copy. + +Acceptance: physical iPhone and iPad pair over LAN and Tailscale, switch installations without credential leakage, explore only approved Mac roots, register a selected folder, and perform supported workspace CRUD. + +### Phase 7 — Swift chat client + +1. Implement chat list/detail/create/rename/delete. +2. Implement atomic turn send, stream replay, status reconciliation, stop, tool/timeline rendering, reasoning, and approvals. +3. Add model/provider/thinking selection. +4. Add bounded attachments after text turns are stable. +5. Rework offline cache keys around Aiden instance and chat IDs. + +Acceptance: disconnect/reconnect never resends a prompt; terminal history matches desktop; chat/workspace changes appear in both clients; revoked devices stop receiving events. + +Completed 2026-08-19. Chat CRUD, provider/model/thinking selection, resumable SSE, approvals, cancellation, authoritative reconciliation, instance-scoped caching, and bounded attachments now ship. Attachment uploads mint ten-minute, one-use references bound to the exact device and chat; turns consume at most ten unique references and translate them into Aiden's canonical image/text attachment model. The server caps formats, bytes, dimensions, Unicode scalars, retained memory, and counts; cleans references on discard, consumption, expiry, and revocation; and projects metadata only. The native composer uses system Photos/file pickers, bounded image transcode and text-prefix reads, attachment-only turns, fail-closed reference validation, and exact-request idempotency-key reuse. The signed iPhone 13 Pro suite executes 64 tests: 59 pass, five environment-gated live proofs skip, and zero fail. Production-router HTTP tests cover attachment upload, discard, turn creation, replay, and projection. An additional opted-in physical-iPhone test proves upload, discard, one-use consumption, replay rejection, authoritative metadata, streaming reconnect, approval, duplicate-approval rejection, cancellation, and cleanup over real LAN HTTPS. Phase 12 later proves real Tailscale pairing, authenticated workspace transport, and canonical route cleanup on the same phone. Evidence is in `docs/testing/aiden-on-the-go/phase-7.md` and `docs/testing/aiden-on-the-go/phase-12.md`. + +### Phase 8 — Appearance and iPad polish + +1. Introduce Swift semantic tokens and shared preset fixtures. +2. Apply Aiden branding without redesigning retained Hermex controls. +3. Implement the confirmed independent, device-local mobile appearance options. +4. Validate Dynamic Type, VoiceOver, keyboard navigation, Reduce Motion, contrast, split view, rotation, Stage Manager sizing, and offline states. + +Acceptance: visual and accessibility matrix passes for all four presets in light/dark on iPhone and iPad; no desktop appearance setting is mutated. + +### Phase 9 — Workspace files and Git + +1. Extract Aiden workspace-file and Git application services from IPC-only ownership without changing desktop behavior. +2. Add remote index/read/version-checked-write plus review/diff/compare/branches/commit/push/worktree endpoints and device capabilities. +3. Adapt the retained Hermex file and Git views to Aiden DTOs and move their entry points into workspace navigation/settings. +4. Add device/workspace-bound opaque file handles, snapshot/operation IDs, confirmations, conflict handling, disconnect-safe ownership, bounded output, explicit DTO allowlists, worktree ownership, rollback, and multi-client race tests. + +Acceptance: mobile can inspect the bounded/truncation-aware file index, version-safely edit files, complete supported Git workflows, reconnect to operation state, and reconcile with Electron; path escape, stale writes/snapshots, cross-workspace tokens, internal Git metadata projection, and unconfirmed consequential operations fail safely. + +### Phase 10 — Scheduled Tasks + +1. Extract/share Aiden scheduled-task application operations and authenticated remote routes. +2. Adapt the Hermex Tasks list/detail structure to Aiden task DTOs, validation, settings, preview, script inventory, and run history. +3. Implement create/edit/remove/pause/resume/run-now workflows with final review for unattended execution, revision-checked edits/settings, and idempotent durable `runId` ownership across disconnects. +4. Test timezone/DST behavior, workspace deletion/permission changes, provider/MCP projection, redacted run output, global disable, concurrent desktop/mobile edits, duplicate run retries, disconnect during execution, and offline UI. + +Acceptance: the phone exposes every supported Aiden scheduled-task operation without leaking internal metadata/credentials or accepting arbitrary script paths; a duplicate retry creates one run; execution survives phone disconnect; edit conflicts are explicit; and authoritative desktop/mobile state converges. + +### Phase 11 — App Intents, Live Activities, and voice + +1. Rename and adapt App Intent entities, shortcuts, deep links, phrases, and cold/warm launch routing for installations/workspaces using the current non-deprecated SDK navigation APIs. +2. Rename/sign the Live Activity widget; adapt its bounded state, reducer, privacy controls, deep links, last-known/stale behavior, stream reconciliation, and terminal states to Aiden events. +3. Retain dictation only where on-device recognition is available, remove every server/cloud/audio-upload path and hold-to-record gesture, and add optional on-device response read-aloud with correct microphone/speech/privacy lifecycle behavior. +4. Verify background execution cannot bypass pairing, workspace permission, approval, Git confirmation, or scheduled-task review. +5. Replace Hermex privacy strings and remove the imported production HTTP exception; any plain-HTTP allowance is development-build-only and narrowly scoped. + +Acceptance: App Intents open the correct Aiden destination using cached IDs without network or credential access; voice is on-device-only and degrades safely to text; and a Live Activity shows honest last-known/stale state while disconnected, then authenticates, reconciles, and ends correctly when the app next runs, without exposing response text by default. + +Completed 2026-08-19. The shipping app now has cache-only installation/workspace App Intent entities and current-SDK deep-link navigation; a signed, embedded Aiden Live Activity widget with bounded private-by-default state and authenticated relaunch reconciliation; explicit on-device-only composer dictation; and system read-aloud. Stable IDs are separated by installation, stale or revoked destinations fail closed, response excerpts default off, and neither App Intents nor the widget receive endpoints or credentials. The final shipping bundle contains generated shortcut metadata, the required privacy strings, and no production ATS exception. The full signed physical-iPhone suite passed 56 tests with four expected environment-gated skips, the focused Phase 11 suite passed 6/6 after the final reducer cleanup, and the clean app installed and launched on the iPhone 13 Pro. No simulator or iPhone 16 Pro Max was used. Evidence is in `docs/testing/aiden-on-the-go/phase-11.md`. + +### Phase 12 — End-to-end hardening and release readiness + +1. Run Electron unit/integration/full test suites, type-check, lint, build, packaged smoke, and listener-disabled verification. +2. Run the full retained XCTest suite and signed physical-device build/launch; simulator use remains excluded by user direction. +3. Run shared contract fixtures against TypeScript and Swift decoders. +4. Run physical-device LAN and Tailscale scenarios, sleep/wake, IP change, token rotation/revocation, server restart, duplicate approval, and multi-device concurrency. +5. Complete privacy strings, Local Network/Bonjour/Speech/Microphone descriptions, ActivityKit/App Group/Keychain entitlements, team provisioning, App Store metadata, and third-party notices. + +Acceptance: release checklist is green with no Hermes-only endpoint, brand asset, localization, or dead navigation remaining. + +Progress 2026-08-19: Desktop type-check, lint, canonical tests, production build, strengthened Remote Access Electron E2E, hardened package/verification, and an isolated packaged listener-off smoke pass. The final focused Remote Access run passes 119/119 production tests plus 7/7 deterministic transport tests, including production-service revoke-and-pair-again coverage, first-time Tailscale Serve setup from an empty configuration, and cleanup-only migration of the exact origin-only target persisted by pre-acceptance builds. Real-tailnet testing exposed and fixed two production blockers: HTTPS eligibility now comes from the exact certificate domain instead of a pre-existing listener, and the loopback target restores `/api/aiden/v1` after Tailscale strips the public mount prefix. A 1.611-second physical iPhone 13 Pro proof used system trust plus the live Tailscale leaf SPKI pin, paired, authenticated, browsed an approved root, completed workspace CRUD, rejected selection replay, and removed only Aiden's route back to `{}`. Phase 7's bounded-attachment requirement is implemented end to end with one-use device/chat-bound references, canonical Aiden attachment translation, native Photos/file selection, attachment-only turns, path-free metadata, and retry-safe exact-request idempotency. The complete signed iPhone suite executes 70 tests: 65 pass, five live-environment tests are expected skips, and zero fail. One opted-in physical-iPhone proof passed the attachment upload/discard/consume/replay/history flow plus streaming reconnect, approval, duplicate-approval rejection, cancellation, and cleanup over real LAN HTTPS. A second 105.983-second proof passed authenticated reconnection after same-endpoint process replacement, typed `403 credential_revoked` durable revocation, explicit local re-pair with an independent one-use secret, replay rejection, a distinct replacement credential, and another process restart where the new credential remained authoritative and the old one remained revoked. Persisted spike state contains only the active credential SHA-256 digest and a bounded set of prior revoked SHA-256 digests; restart does not reopen pairing, and the deterministic transport suite proves atomic digest replacement plus owner-only marker/state cleanup. A fresh test-free attachment build passed strict deep code-sign verification, contained no XCTest bundle, installed, and launched on the physical iPhone 13 Pro. A fresh generic-hardware attachment Release archive passed store validation, App Intent generation, strict deep code-sign verification, and no-test-bundle inspection. It is development-signed with `get-task-allow=true`, so distribution signing/export is still open. Physical iPad, sleep/wake/address-change/multi-device acceptance, Siri/dictation/direct server-streamed Live Activity system-UI acceptance, and App Store Connect owner metadata/review assets remain open. Evidence is in `docs/testing/aiden-on-the-go/phase-12.md`. + +Release-readiness follow-up 2026-08-19: the CI mobile gate no longer references the removed Hermex project or an iOS Simulator; it compiles the shipping Aiden app/test bundle for generic iOS hardware and passes locally. The active Aiden shell now includes the reused `AppConfig` foundation and exposes native Privacy Policy and Support links. The canonical live destinations are locked by the focused physical-iPhone integration suite. Contact Sheet's owner identity plus Aiden's live site/repository resolve the public metadata values. Hermex's separate internal/external export-option, manual workflow, and App Store build-selector patterns are adapted for Aiden: separate protected environments, exact confirmation gates, main-only execution, pinned checkout, closed-version-train protection, generic-hardware archive/upload, and upload-only external behavior are locked by 20 Ruby tests/42 assertions plus three deterministic policy tests registered in `npm test`. The published privacy policy still needs mobile-specific copy and the support page needs a visible working contact; no workflow was dispatched and no distribution credential, App Store record, export, upload, tester assignment, or external-review action was created implicitly. + +ASC/metadata follow-up 2026-08-19: current Apple definitions resolve the store draft to Developer Tools / Productivity, no developer-collected data, and a conservative 13+ override matching the published under-13 policy. Four additional registered policy tests lock metadata limits/links, manifest/SDK privacy alignment, ASC credential ignores, and telemetry-off strict command guidance. The stable local Rork `asc 3.4.0` client was used read-only: the main app bundle ID and App Groups capability exist under team `5WP229CBB8`, but the active Parsely-named profile exposes no Aiden App Store record, widget identifier, or Aiden distribution profile. Aiden's live website links to an active public **Aiden - Quick AI** TestFlight beta outside that profile's scope; its page describes the existing macOS product and says it is available on iOS, but does not expose the numeric App ID or bundle ID. The correct Aiden credential must reconcile that record before any new record is created. One remote iOS Distribution certificate exists while no local Apple Distribution private-key identity does, and no Apple web session is authenticated. A provisional physical-iPhone `1170 × 2532` pairing-screen capture converted to opaque JPEG passes ASC screenshot validation, proving the real-device pipeline without using a simulator; final distribution-candidate iPhone/iPad assets remain open. No ASC mutation or Codex automation was created because no exact reviewed Aiden App ID/build exists. The owner-gated ASC and future read-only automation runbook is `ios/ASC_CLI.md`. + +ASC automation follow-up 2026-08-19: a registered `ios:asc-monitor` repository command now provides the exact future Codex-automation boundary. It requires a named Aiden Keychain profile, a numeric App Store Connect App ID, and an exact build resource ID for processing/TestFlight modes; sets telemetry off; uses strict authentication and read-only CLI verbs; and summarizes TestFlight crashes/feedback as counts, newest timestamps, and fingerprints without persisting tester identity, feedback text, screenshot URLs, or crash content. Processing uses exact `builds info --build-id` rather than an app-wide latest-build dashboard. Six focused tests lock command scope, identifier/profile validation, telemetry policy, read-only review behavior, and privacy-safe output. No automation was created because the authoritative Aiden record/build remains unresolved. + +Internal TestFlight follow-up 2026-08-19: the owner-authorized distinct Aiden On The Go App Store record, widget identifier, Apple Distribution identity, and App Store profiles are now provisioned. The train is `0.1.0`; build `1` was rejected before processing with `ITMS-90717` for an alpha-bearing icon. Build `2` replaces it with the exact opaque RayChat Icon Composer package, passes release tests plus archive/export bundle, entitlement, signing, internal-only, and compiled-icon inspection, and App Store Connect reports the exact build `VALID` and `IN_BETA_TESTING`. The internal group contains the account holder. External TestFlight, App Review, and public release were not enabled. + +Compact-navigation follow-up 2026-08-19: build `3` replaces the always-on `NavigationSplitView` shell with the retained Hermex adaptive pattern: a value-driven `NavigationStack` on compact iPhone and a split view with a stacked detail column on regular iPad layouts. Workspace taps, creation, folder registration, deep links, deletion, and compact/regular transitions now reconcile explicit selection and path state. Five focused navigation/appearance tests and nine chat tests pass on the physical iPhone 13 Pro; the exact `0.1.0 (3)` app was installed and launched without using a simulator. Its internal-only distribution IPA passed identity, entitlement, signing, widget, and opaque-icon inspection, and App Store Connect build `e5f0ae7e-35aa-451e-be87-bc039885b2de` is `VALID` and `IN_BETA_TESTING` for `Internal Testers`. + +ASC localization/privacy follow-up 2026-08-19: canonical `en-US` App Info and version-localization JSON now exist under `ios/app-store/metadata` for the shipping `1.5` draft. Rork ASC's offline validator reports zero errors and zero warnings, and registered policy tests lock the canonical files to the documented name, subtitle, privacy/marketing/support URLs, keywords, description limits, and intentionally omitted optional fields. `ios/app-store/MOBILE_PRIVACY_SUPPORT_COPY.md` supplies ready-to-review live-site replacement/addition copy for mobile pairing/transport, Keychain and device-local caches, attachments and provider forwarding, Apple permission surfaces, App Intents, Live Activities, external media, and a visible support email. Version confirmation, owner/legal approval, and publication on the separately owned website remain external gates; no metadata was applied. + +Shipping-target/package follow-up 2026-08-19: the app, test, and widget `PBXSourcesBuildPhase` memberships are now locked by a registered exact-allowlist test that also rejects imported product identity and non-Aiden `/api/*` literals in shipping Swift. At this checkpoint, unlinked packages retained from the Hermex import were removed and KeychainAccess 4.2.2 was the only resolved and linked Swift package. The later Markdown parity follow-up deliberately restored Hermex's MarkdownUI 2.4.1 rendering foundation plus its locked NetworkImage 6.0.1 and swift-cmark 0.8.0 transitive dependencies and notices. A clean generic-hardware Release compile and the focused signed integration suite on the physical iPhone 13 Pro passed after the cleanup without using a simulator. + +Native-shell refresh follow-up 2026-08-19: onboarding is now a concise Aiden welcome, Mac preparation, and secure QR-pairing flow. The adaptive home places Scheduled Tasks, privacy-safe Mac Usage, all workspaces, and globally chronological chats behind a Hermex-informed native list; its profile control opens the single App Settings surface, while per-workspace permission/files/Git remain in Workspace Settings. New Agent creates an Aiden-managed scratch workspace, the compact composer restores Hermex's attachment/model/thinking/voice rhythm with the Aiden logo, and transcript activity groups reasoning and tool calls without brain/sparkle identity. Berry dark uses an accessible berry accent shared exactly by Electron, protocol fixture, and Swift. The top-level iOS project is Aiden-owned MIT while required upstream notices remain bundled. All 215 non-shipping imported Swift/test files and their 430 isolated Xcode objects were removed; a registered gate now enforces the exact 31-file app/test tree, brand asset hash, shell ordering, composer affordances, and absence of brain/sparkle glyphs. The new `/usage` route uses existing `server:read` authority and projects aggregate Mac totals only. The full physical iPhone 13 Pro suite executed 73 tests: 68 passed, five environment-gated live proofs skipped, and zero failed. A clean `0.1.0 (5)` app installed and launched on that phone. A cold real connection proved successful `chats`, `scheduledTasks`, and `usage` loads after correcting the connected-state trigger discovered in build 4. The internal-only Apple Distribution IPA passed app/widget identity, App Group, entitlement, no-test-bundle, and strict deep-signature inspection; App Store Connect build `6173d5e2-0e58-4d0a-92fa-fc804fc82c37` is `VALID` and `IN_BETA_TESTING` for `Internal Testers`, with external testing `NOT_APPLICABLE`. + +Final shell follow-up 2026-08-19: build `6` closes the remaining locally actionable migration defects found by a fresh source and physical-device audit against `/Users/sambitbiswas/projects/opp/hermex`. New Agent now creates a managed scratch workspace before creating its chat. Cold navigation requests are retained until the coordinator is connected instead of cancelling their own task, and handoff chats have an explicit native Close action. The Live Activity extension now embeds the exact tintable Aiden sidebar artwork and uses it for starting/thinking in every size instead of brain/sparkle glyphs. The registered shipping-target test locks all four behaviors. The complete physical iPhone 13 Pro suite again executed 73 tests with 68 passes, five expected live-environment skips, and zero failures; no simulator or iPhone 16 Pro Max was used. The iOS release suite passes 20 Ruby tests/42 assertions plus 23 Node tests, and the desktop Remote Access suite passes 120 production tests plus seven transport proofs. The internal-only local IPA reports `0.1.0 (6)`, exact app/widget/App Group identities, `TFInternalTestingOnly=true`, Apple Distribution team `5WP229CBB8`, `get-task-allow=false`, no XCTest content, the widget Aiden logo resource, and a valid strict deep signature. Exact App Store Connect build `aa994233-1bf3-4482-86f5-b8b0356eee25` is `VALID`, assigned to `Internal Testers`, `IN_BETA_TESTING`, and external testing remains `NOT_APPLICABLE`. + +Interaction and streaming follow-up 2026-08-19: the retained Hermex implementation was audited and confirmed to stream through resumable Server-Sent Events, not WebSockets. Aiden On The Go keeps its stricter SSE path with event IDs, replay cursors, reconnect, reasoning/tool/approval events, and authoritative terminal reconciliation. The exact dependency-free Thinking Orbs `0.3.1` iOS SwiftUI port is vendored with its MIT notice and maps queued, reasoning, tool, approval, token, and completion phases to the same visual states used by Aiden Agent, including reduced-motion behavior. New Agent is now a native Liquid Glass action offering an existing workspace, a new reusable workspace, or a managed scratch workspace; selecting an existing workspace creates only the chat and avoids registry bloat. Chat send, rename, delete, approval, cancellation, and workspace mutations render hopeful local state immediately, then replace it with the server's canonical result or roll back/reconcile on failure. Home and workspace chat timestamps update from `just now` through minutes, hours, and days; home message counts and top-level workspace/scheduled-task badges are removed. The shipping-target suite passes 6/6, Thinking Orbs passes its 72-case/70,115-value golden-vector suite, and the signed physical iPhone 13 Pro suite executes 74 tests with 69 passes, five expected environment-gated skips, and zero failures. A separate clean test-free build passed strict deep-signature inspection, installed, and launched on that phone. No simulator, iPhone 16 Pro Max, App Store Connect mutation, archive, or TestFlight upload was used for this local refresh. + +Composer-overlay follow-up 2026-08-19: screenshot review exposed that the chat used an opaque `.bar` safe-area inset beneath a material composer, cutting the selected Aiden canvas off above the control, while the send arrow relied on the system `.background` role and lost contrast when disabled. The chat now follows the retained Hermex overlay geometry: its transcript remains the full theme canvas and scrolls beneath a bottom-aligned composer, with a measured transparent tail spacer keeping the latest content reachable above the control. The composer uses native interactive Liquid Glass on supported iOS versions, a theme-raised opaque fallback under Reduce Transparency, and regular material on older systems. Send states are explicit Aiden palette roles: active uses accent/canvas contrast, disabled uses foreground/secondary contrast, and stop uses foreground/canvas. A registered source regression rejects an opaque `.bar` return and locks the overlay/glass/theme roles. All six shipping checks and the complete physical iPhone 13 Pro suite pass: 74 total, 69 passed, five expected environment skips, and zero failed. A clean test-free app passed strict signing inspection, installed, and launched on that phone; no simulator or TestFlight upload was used. + +Composer-focus/model follow-up 2026-08-19: composer focus is owned by the chat shell so tapping anywhere in the transcript dismisses the keyboard, while interactive downward scrolling retains native swipe-to-dismiss behavior. The model menu now makes every model with declared `thinkingLevels` a submenu whose children are those exact server-projected levels; selecting a child atomically updates provider, model, and thinking level. Models without levels remain direct actions. The separate thinking sibling control is removed, and the model trigger shows the Aiden thinking mark plus the selected level when applicable, including a combined accessibility value. Registered source checks lock focus handoff, tap/swipe dismissal, nested thinking menus, and absence of the former sibling picker. All six shipping checks and the full physical iPhone 13 Pro suite pass at 74 total, 69 passed, five expected environment skips, and zero failures. A clean test-free build passed strict signing inspection, installed, and launched on that phone. No simulator, iPhone 16 Pro Max, ASC mutation, archive, or TestFlight upload was used. + +Internal TestFlight build 7 follow-up 2026-08-19: after explicit owner authorization, the project build number advanced to `7` while the internal train remained `0.1.0`. The release gate passed 20 Ruby tests/42 assertions plus 23 Node tests, and the latest physical iPhone 13 Pro suite remained green at 74 total with 69 passes, five expected environment skips, and zero failures. A generic-iOS Release archive was exported with the internal-only policy and the resulting IPA reported `TFInternalTestingOnly=true`, exact app/widget/App Group identities, Apple Distribution team `5WP229CBB8`, `get-task-allow=false` for both targets, no XCTest content, valid strict deep signing, and opaque compiled iPhone/iPad icons. The exact IPA was uploaded through telemetry-off strict authentication with the named `Parsely ASC` profile. App Store Connect build `717b6381-4dec-4cce-85d1-72b503c28590` processed as `VALID`, is related to exact group `Internal Testers` (`3f90ffa7-29bb-429e-80a4-88422eb85b6d`), reports `internalBuildState=IN_BETA_TESTING`, and keeps `externalBuildState=NOT_APPLICABLE`. No external group, Beta App Review, App Review, public availability, metadata, pricing, or screenshot mutation occurred. + +App Intent handoff follow-up 2026-08-19: a cold physical-iPhone launch through `aiden-otg://new-chat` exercised the same bounded URL emitted by the New Chat App Intent. With no paired installation, the installed app failed closed before creating or sending a chat and presented the correct connect-first action. Physical screenshot review exposed a misleading “Couldn’t Pair” alert title inherited from the pairing shell; the title is now the neutral Aiden product name, an eighth focused integration assertion locks the copy, and a repeated cold launch confirmed the corrected result. Temporary screenshots and DerivedData were moved to Trash. Siri/Shortcuts phrase invocation itself remains a manual system-UI gate. + +ActivityKit follow-up 2026-08-19: a real signed physical-iPhone lifecycle test exposed that ActivityKit's rendered `activity.content` can lag an awaited update. The production manager previously derived each event from that public rendered snapshot, so rapid tool/token/stale events could overwrite semantic state. It now maintains canonical MainActor-isolated state per Activity ID, adopts persisted state only during reconciliation, refuses updates to ended/dismissed activities, and clears terminal cache entries. One regression performs a real request, fires rapid tool → token → stale transitions, confirms private-by-default responding/stale state, and immediately removes the activity. A second physical regression releases the original manager, creates a fresh manager in the same test host, adopts the system-persisted activity, and verifies the exact bearer-authenticated, protocol-versioned stream-status request and reconciled rendering through `AidenRemoteClient`. The guarded `ios:activitykit-process-proof` command then builds once, starts a unique activity in one host process, proves that host has exited, reuses the installed destination artifacts without app reinstallation, and passes the same authenticated adoption and terminal cleanup in a distinct host process. It validates matching physical CoreDevice/Xcode identities, has failure cleanup, and is locked by four registered Node tests. The focused suite passes 11/11 and the full physical target passes 70 total with 65 passes, five intentional live-environment skips, and zero failures. Only direct system-UI observation of a real server-driven activity remains manual for this path. + +Post-proof release-gate refresh 2026-08-19: the complete canonical `npm test` lifecycle passes after the ActivityKit runner registration, including the Aiden Remote pretest matrix, iOS release policy suite, Electron tests, and native helper suites. A telemetry-off, strict-auth ASC refresh remains unchanged: only the Parsely-named Keychain profile is active; the exact Aiden bundle query exposes no App Store Connect record; filtered Developer Portal results contain the main identifier but not the Live Activity widget; no Aiden iOS App Store profile exists; and the local Keychain has no Apple Distribution identity. No ASC mutation or Codex automation was created. The owner authorizes using `Parsely ASC` for Aiden within its visible scope, with explicit profile selection and exact identifiers; its zero-app result remains insufficient authority to create a duplicate record. + +Hermex Markdown parity follow-up 2026-08-19: transcript inspection against `/Users/sambitbiswas/projects/opp/hermex` found that Aiden On The Go was parsing completed replies with inline-only `AttributedString`, leaving block headings and lists visible as punctuation. The same assistant layout used a trailing spacer that could squeeze long content and clip the stored leading `I` from `I'll explore…`. Completed and streaming replies now use the exact Hermex `swift-markdown-ui` 2.4.1 renderer, receive the full proposed transcript width, and retain Aiden palette typography. User prompts remain literal content-sized bubbles like Hermex rather than being stretched by the full-width renderer. Parsing is bounded to 80,000 characters and 2,000 lines with literal-text fallback; Markdown images are deliberately suppressed through a no-network provider. Required MarkdownUI, NetworkImage 6.0.1, and swift-cmark 0.8.0 notices are bundled and release tests lock the resolved graph. Fourteen focused chat tests pass, and the complete physical iPhone 13 Pro target passes 78 total with 73 passes, five expected environment-gated skips, and zero failures. A generic physical-iOS compile and the release policy suite (20 Ruby tests/42 assertions plus 23 Node tests) also pass. The app was launched on the phone for visual confirmation. This is a local follow-up after TestFlight build 7; no simulator, archive, ASC mutation, or upload occurred. + +Markdown/attachment correction follow-up 2026-08-20: fresh device screenshots showed a first assistant glyph still touching the renderer boundary and exposed that the `+` menu embedded a `PhotosPicker` directly inside `Menu`, unlike Hermex's state-driven external presentation. The assistant row now owns full width while the MarkdownUI renderer keeps its intrinsic block layout without a root `fixedSize`; both completed and live text continue through the same locked MarkdownUI 2.4.1 path. The composer now uses Hermex's deferred UIKit menu bridge to open external Photos and Files presentations. A separate send-path defect was also corrected: the turn request is built from the captured uploaded references before hopeful UI clearing, so attachment IDs reach the Aiden server and remain single-use. Source checks reject nested `PhotosPicker` wiring and lock request construction order. Sixteen focused chat tests pass, including rendered first-glyph bounds and attachment-reference preservation, and the complete physical iPhone 13 Pro suite passes 80 total with 75 passes, five expected environment-gated skips, and zero failures. No simulator, archive, ASC mutation, or TestFlight upload was used. + +Home glass-navigation follow-up 2026-08-20: the provided `/Users/sambitbiswas/Downloads/CustomTB/CustomTB` reference established a separate prominent bottom action with an anchored popover, while Hermex established native interactive Liquid Glass with reduced-transparency and pre-iOS-26 fallbacks. Aiden's New Agent action now opens an anchored, compact-popover-preserving three-row chooser for an existing workspace, reusable new workspace, or managed scratch workspace; its existing creation and server-confirmation flows are unchanged. The chooser hides during search and uses a zoom transition from the prominent action. The search/settings capsule now uses native interactive `.glassEffect(.regular.interactive())` on supported devices, ultra-thin material on older systems, and an opaque palette-raised surface under Reduce Transparency. Registered source coverage locks the popover and glass path, and XCTest locks the exact three choices. The iOS release suite passes 20 Ruby tests/42 assertions plus 23 Node tests, and the complete physical iPhone 13 Pro suite passes 81 total with 76 passes, five expected live-environment skips, and zero failures. No simulator, archive, ASC mutation, or TestFlight upload was used. + +Compact approval follow-up 2026-08-20: the live iOS approval card now mirrors Aiden Agent Mac's reviewed hierarchy instead of using oversized default SwiftUI controls. It uses the `shield` SF Symbol in a 32-point warning badge, a small-semibold approval title, caption helper copy, a compact monospaced summary well, and right-aligned `Deny` / `Allow once` actions. Both actions render as native interactive Liquid Glass on supported devices; older and Reduce Transparency modes retain themed material/opaque fallbacks. Visible action capsules are 34 points tall while outer padding preserves a 44-point touch target. Registered source coverage locks the shield, typography, labels, and both native glass paths. The iOS release suite passes 20 Ruby tests/42 assertions plus 23 Node tests, and the complete physical iPhone 13 Pro suite remains 81 total with 76 passes, five expected live-environment skips, and zero failures. No simulator, archive, ASC mutation, or TestFlight upload was used. + +## Test inventory + +### Electron + +- Remote service disabled/listen lifecycle. +- Pairing expiry, single use, rate limits, token digest, rotation, and revocation. +- Authorization on every route and SSE reconnect. +- DTO projection excludes secrets and unsafe paths. +- Workspace CRUD invariants, approved-root identity/policy changes, token-digest isolation, cursor binding, atomic selection consumption, expiry/replay, traversal/symlink/root-replacement escape, permission elevation audit, broadcasts, and mutation races. +- Chat CRUD/deletion/reconciliation races. +- Atomic append/start behavior. +- SSE ordering, replay bounds, heartbeat, terminal persistence, and restart interruption. +- Approval ownership, expiry, duplicate decision, device revocation, and desktop/remote overlap. +- Request size, attachment, malformed JSON, path injection, CORS/origin, and log-redaction tests. +- Workspace file-handle instance/device/workspace isolation, 4,000-entry/depth-20 truncation projection, root replacement, read bounds, expected-version conflicts, and save reconciliation. +- Git allowlist projection, nested-workspace capability, bounded diff, stale snapshots, canonical-repository serialization, branch/commit/push confirmation, disconnect-safe ownership, worktree ownership/rollback, and concurrent desktop/mobile operations. +- Scheduled-task schema validation, revisions/CAS, DST/timezone preview, internal-field/credential/output projection, idempotent durable run ownership, settings, lifecycle actions, run history, disconnect behavior, workspace/provider changes, and concurrent edits. +- Tailscale Serve conflict/ownership tests proving that unrelated routes and Funnel state are untouched. +- Onboarding and 1024 x 1024 asset contract. + +### Swift + +- Pairing and certificate/token storage. +- Multi-installation credential isolation. +- Aiden endpoint request and tolerant response decoding. +- SSE sequence, replay, reset/snapshot, duplicate suppression, and terminal convergence. +- Chat list/detail/mutation/send/cancel/approval. +- Workspace list/create/update/remove and authoritative reconciliation. +- Approved-root folder navigation, pagination, selection-token expiry, and registration. +- Offline cache scoping. +- Theme preset/token fixtures and accessibility settings. +- iPhone/iPad navigation-state coverage. +- Workspace-settings ellipsis routing and absence of permission controls in the composer. +- File index/read/edit/version-conflict and Git review/diff/branch/commit/push/worktree flows. +- Scheduled-task list/editor/preview/lifecycle/run-history flows. +- Cache-only App Intent entities/current-SDK navigation/deep links/cold/stale launch with assertions that the intent process makes no network/Keychain access; Live Activity sub-4 KB reducer/stale/reconciliation/privacy behavior; on-device-only dictation authorization; removal of audio upload/voice-note paths; and read-aloud lifecycle. + +Retain and adapt the useful Hermex API, auth, session, chat, SSE, cache, navigation, appearance, workspace, Git, file, Tasks, App Intents, Live Activity, and voice tests. Remove Kanban, Skills, Memory, Insights, and other deleted-feature tests from the target rather than leaving skipped suites. Hermes Cron fixtures do not survive unchanged; only the native Tasks UI/test patterns are reused against new Aiden DTOs. + +## Research inputs + +- Local Apple identity baseline: `/Users/sambitbiswas/projects/contactsheet/Contact-Sheet-Generator/ContactSheetGen.xcodeproj/project.pbxproj`. +- Current App Intents navigation APIs: [AppIntent](https://developer.apple.com/documentation/appintents/appintent) and [OpenIntent](https://developer.apple.com/documentation/appintents/openintent). +- Live Activity lifecycle and extension limits: [Displaying live data with Live Activities](https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities). +- Speech authorization and privacy behavior: [Asking Permission to Use Speech Recognition](https://developer.apple.com/documentation/speech/asking-permission-to-use-speech-recognition). +- Current Serve lifecycle, status, route-specific `off`, and destructive reset semantics: [Tailscale Serve CLI](https://tailscale.com/docs/reference/tailscale-cli/serve). + +## Device-only workspace archive follow-up — 2026-08-20 + +Workspace rows now use Hermex's reviewed long-press plus bidirectional swipe pattern with full-swipe disabled. Archive state is local to each paired installation on the current iPhone/iPad, persists across relaunch, and presents a one-time disclosure before the first archive. Active and archived workspaces have separate searchable views; archived workspace chats, New Agent choices, adaptive navigation destinations, deep links, and cached App Intent entities are hidden on that device until unarchived. + +Rename keeps the existing hopeful revision-checked server mutation, retains a failed quick-rename draft, and explicitly distinguishes the shared Aiden display name from the unchanged disk-folder name. Remove from Aiden Agent reconciles revision conflicts and ambiguous completions against a fresh canonical registry and explains that folders, files, and chat data remain on the Mac while removed-workspace chats become unlisted. Managed scratch workspaces retain the separate managed deletion path because generic record removal would discard crash-recovery authority, and ordinary removal is hidden for the sole registry workspace to match the desktop invariant. + +Fresh-memory iOS, persistence, and server reviews closed cross-installation response races, optimistic-pruning rollback loss, authoritative-empty pruning, archived-row navigation, pre-shell App Intent filtering, sole-workspace removal, and operational-server-error state gaps. Coordinator loads and mutations are installation-generation-bound; archive pruning happens only inside a confirmed authoritative snapshot. Eleven focused archive/navigation/coordinator tests pass on the physical iPhone 13 Pro, including rejected-removal rollback, empty-snapshot pruning, and reversed installation completion. The filesystem-boundary HTTP test now proves unregister leaves a real file intact. No simulator was used. + +## Usage dashboard and home continuity follow-up — 2026-08-20 + +The privacy-safe Mac Usage destination now presents the server's real 30-day aggregate as an Aiden-native dashboard: request/token summaries, active-day and streak metrics, a token heatmap, token and activity breakdowns, and top-model rows. Every surface uses the selected appearance palette and accessible semantic labeling. Reference-only fields that Aiden does not expose—such as longest task, skills, and plugins—remain absent rather than being fabricated. + +The home list now owns a palette-backed bottom safe-area inset for New Agent instead of adding a synthetic clear list row. This preserves the action's scroll clearance without exposing a mismatched white band after the final chat. Shipping source checks pass 6/6, the signed app/test bundle builds for the connected physical iPhone 13 Pro, and focused tests cover usage decoding and presentation math. No simulator, archive, App Store Connect mutation, or TestFlight upload was used. + +## Provider identity and reply-copy follow-up — 2026-08-20 + +The iOS asset catalog now mirrors all 40 reviewed provider SVGs from Aiden Agent. A shared resolver preserves desktop aliases, model-specific Claude/Grok identities, numbered local-provider fallbacks, multicolor rendering, and a neutral initial fallback. Usage model rows, the chat composer model menu, and scheduled-task provider/model selectors now present provider icons with provider-grouped model names. + +Completed and streaming assistant replies expose a Hermex-style long-press Copy action plus an accessibility action; the clipboard receives the original Markdown rather than rendered text. A requested GPT-5.6 Terra xhigh review verified byte-for-byte asset parity and found the scheduled-task, streaming-copy, and iOS-notice gaps; all three were corrected. The registered source/asset gate passes 7/7, a signed build-for-testing succeeds, and 23 focused chat, scheduling, and usage tests pass on the physical iPhone 13 Pro. No simulator, archive, ASC mutation, or TestFlight upload was used. + +## Confirmed product decisions + +1. **Workspace scope:** Mobile has full Aiden workspace-registry CRUD, including folderless and managed-scratch creation, may explore approved folders on the paired Mac and register a selected folder, and later gains Aiden's existing file index/read/version-checked-write capabilities. It does not accept free-form Mac paths or invent file create/rename/delete operations. +2. **Remote permissions:** Mobile turns honor the workspace's saved `full`/`ask`/`none` permission and show approvals on the phone. Permission lives in `Workspace Settings` under the conversation-toolbar ellipsis, never in the composer. +3. **Workspace creation:** The phone uses a server-controlled approved-root directory browser and a short-lived opaque selection token. New selected-folder workspaces default to `ask`. +4. **Appearance:** Aiden On The Go appearance is independent and stored locally on the phone/iPad. +5. **Expanded native scope:** App Intents, Live Activities, voice, Aiden Git/files, and scheduled tasks are planned after the stable chat/workspace core. Share Extension, terminal, Computer Use, and cloud push remain deferred. +6. **Apple identity:** Use automatic signing with team `5WP229CBB8`, the `sbtbiswas.*` bundle namespace, `group.sbtbiswas.AidenOnTheGo`, URL scheme `aiden-otg`, and SKU `aiden-on-the-go-ios`, subject to the Phase 0 provisioning preflight. +7. **Tailscale ownership:** Aiden provides explicit Connect/Disconnect, previews the exact non-Funnel Serve change, records ownership, and removes only the route it created without altering unrelated routes. + +Implementation may begin with Phase 0. Later phases remain gated by the acceptance criteria above rather than unresolved product decisions. + +## First-class pairing choices follow-up — 2026-08-22 + +The iOS pairing surface now exposes the same connection choices as Aiden Agent's Add Device flow. The primary list presents Scan QR Code, Nearby Mac + Setup Code for local Wi-Fi/Bonjour, and Private Address + Setup Code for Tailscale; Paste Pairing Payload remains an advanced camera-unavailable fallback. Each route reuses the reviewed five-minute, one-use trust protocol rather than defining a second authentication mechanism. The QR path explicitly states that it already carries the Mac-selected Local Network or Tailscale endpoint, while manual paths request the exact address and setup code shown on the Mac. + +Focused contract tests lock the complete method inventory and Mac/iOS labels, while the shipping-source gate requires both canonical endpoint forms. The full Remote Access, release, repository, type, lint, build, generic iPhoneOS, and signed physical-iPhone gates pass. Remaining physical-iPad and hands-on cross-network acceptance stay open under the existing Phase 12 gates. + +## Progressive iOS onboarding and pairing follow-up — 2026-08-22 + +First launch now introduces Aiden On The Go through three concise, swipeable capability pages derived from Aiden Agent's Mac onboarding groups: Build in your workspace, Choose and extend, and Automate and stay in control. The pages reuse the reviewed Mac workspace, model-freedom, and scheduled-automation PNGs byte for byte, omit provider setup because credentials remain Mac-owned, and follow the focused-page, dominant-artwork, single-bottom-action pattern reviewed in `LPOnBoarding`. Completion is device-local and recorded only after the user reaches connection setup, so an interrupted introduction resumes while settings-based Add Device remains direct. + +Connection setup now progressively discloses the three primary paths in a native segmented tab bar backed by a swipeable page container: QR, Nearby, and Tailscale. Each page retains its existing one-use pairing protocol and validation. Full-payload paste remains available from the overflow menu as a recovery path rather than competing with primary setup. Source contracts lock the three-tab order, swipe container, one-time onboarding behavior, Liquid Glass primary action, exact artwork parity, and the advanced fallback. A generic iPhoneOS test build, the complete iOS release-policy gate, and 14 focused native integration tests pass on the physical iPhone 13 Pro without using a simulator. Physical-iPad and hands-on visual acceptance remain open. + +All onboarding identity now uses the actual shipping Aiden app icon rather than the sidebar thinking mark. The capability Continue/Set Up Connection action, Prepare Your Mac's Choose How to Connect action, and QR setup's Open Camera action share one native Liquid Glass prominent-button primitive on supported systems with the reviewed bordered fallback on older iOS versions. + +The onboarding tour is window-adaptive rather than device-branched: compact and narrow Stage Manager widths use all available space, while wide iPad windows center a stable maximum 620-point content measure and 760-point height. Artwork is stable across slides, copy stays within a readable measure, the primary action caps at 360 points, and a vertical `ViewThatFits` falls back to scrolling for constrained height or larger Dynamic Type. QR's Open Camera action now sits centered at the bottom safe area outside Form rows with no surrounding list container. + +All onboarding primary actions now use one shared 360-point bottom-safe-area layout with identical 24-point horizontal and 12-point bottom spacing. The capability pager keeps its page indicator above the action without moving the button baseline, so Continue/Set Up Connection, Choose How to Connect, and Open Camera remain visually stationary between steps. + +## Bidirectional chat-image and terminal-outcome follow-up — 2026-08-22 + +Chat attachment metadata remains path-free and inline-data-free, while a new authenticated `chat:read` content route returns only the exact PNG or JPEG bytes for an attachment already present in that authoritative chat. The route binds chat and opaque attachment identity, fails closed on duplicate projected identifiers, verifies MIME signatures, stored sizes, and the 8 MiB bound, and never exposes text attachments or local paths. iOS stores validated images in a protected installation/device/chat-scoped cache with bounded pruning and corrupt-entry eviction. + +Sent text keeps its own compact message bubble, while image media is a separate sibling beneath it on the chat background. One image renders aspect-fit without a surrounding message container. Multiple images use a bounded, interactive native SwiftUI card deck adapted from BigUIPaging's CardDeck example: every selected photo remains fully visible in an aspect-fit viewport, one neighbor per side stays visibly offset, scaled, and rotated, horizontal movement tracks the finger directly with resisted edge over-drag, and a short interruptible spring snaps the chosen card into place. Only two or three masked images remain in the live render tree, only the selected card casts a reduced shadow, and a content-addressed 32 MiB thumbnail cache prevents repeat 960-pixel decoding while paging. Tapping the selected card opens the system page viewer. Saving is user-initiated, requests add-only Photos permission at the action point, stages one bounded image at a time, and commits Save All as one Photos transaction. PNG bytes and transparency are preserved when possible. Large Photos selections use a bounded file transfer instead of first materializing the entire source in memory, and view dismissal cancels preparation and decode work. The unmaintained BigUIPaging package is not a dependency; only its interaction model informed Aiden's native implementation, and its temporary checkout was removed after review. + +Remote terminal outcomes now survive disconnect and replay. Mac initialization cancellation carries an explicit cancellation signal even when no generation timeline exists; completed, failed, and cancelled assistant messages expose only a fixed renderer-safe outcome projection. iOS consumes the terminal SSE event before cleanup, retains the durable cursor/live response when authoritative reconciliation is temporarily unavailable, and retries reconciliation without presenting cancellation as success. Provider diagnostics, reasoning, tool payloads, and raw errors remain Mac-private. + +If a retained stream is definitively gone after a Mac restart or journal expiry, iOS falls back to the authoritative chat instead of retrying the missing stream forever. It resolves only the assistant response after the latest user turn; when that response was never persisted, the current run is marked interrupted rather than borrowing an older turn's outcome. Photos transfers preserve the original display filename independently of their extensionless temporary file. + +## Sender-edge image-deck refinement — 2026-08-22 + +The inline deck now preserves the sender-side spatial anchor during interaction: user media stays trailing, assistant media stays leading, and only the selected front card consumes live drag progress. Rear cards remain fixed until selection commits, eliminating whole-stack drift and reducing per-frame transforms while preserving edge resistance, flick selection, reduced-motion behavior, rounded media, and the full-screen viewer. + +The fitted-image mask is applied before the sender-aligned layout frame expands. Portrait and landscape images therefore keep all four continuous corners instead of allowing the frame boundary to square off the edge opposite the sender. + +The complete repository suite, type-check, lint, production build, generic iPhoneOS build, iOS release policy, Remote Access and attachment suites, and the full Apple Development-signed XCTest target pass. The final native run used the connected physical iPhone 13 Pro; no simulator or iPhone 16 Pro Max was used. diff --git a/docs/plans/completed/aiden-ios-interaction-haptics-plan.md b/docs/plans/completed/aiden-ios-interaction-haptics-plan.md new file mode 100644 index 00000000..6ec50971 --- /dev/null +++ b/docs/plans/completed/aiden-ios-interaction-haptics-plan.md @@ -0,0 +1,38 @@ +# Aiden iOS Interaction Haptics + +Status: Complete. Released to Internal Testers in version 0.1.0 build 16. + +## Goal + +Add sparse, semantic Taptic Engine feedback to Aiden On The Go without duplicating native control feedback, vibrating during background reconciliation, or disturbing speech capture. + +## Policy + +- Use SwiftUI `sensoryFeedback` from one app-scoped dispatcher and Apple’s standard selection, start, stop, success, warning, and error patterns. +- Emit only for a visible, user-initiated interaction or its authoritative outcome. +- Keep navigation, standard controls, token/thinking/tool updates, loading, reconnects, App Intents, Live Activities, and ordinary response completion silent. +- Suppress feedback when disabled, unsupported, backgrounded, outside the initiating view scope, or while the microphone is capturing. +- Deduplicate replayable outcomes by semantic event plus stable operation identity. + +## Delivery + +1. Inventory interactions and map each eligible event to an Apple semantic pattern. +2. Add the device-local preference, capability gate, active-view scopes, microphone gate, and bounded deduplication. +3. Integrate pairing, New Agent, workspace/chat CRUD, send/stop/approval, attachment batches and carousel paging, scheduled tasks, workspace files, and Git mutations. +4. Add focused unit coverage for persistence, gating, scopes, unsupported hardware, and replay deduplication. +5. Verify compilation and tests on the physical iPhone 13 Pro; review the finished diff; ship build 16 to Internal Testers. + +## Acceptance + +- A single explicit action produces at most one appropriate pulse per semantic transition. +- Cancellation, stale contexts, dismissed views, reconnect replay, and background work remain silent. +- Standard controls do not receive duplicate custom feedback. +- iPad and hardware without haptics safely no-op. +- Users can disable Interaction Haptics in App Settings. + +## Verification and release + +- A final GPT-5.6 Sol Max review found and verified the delivery-time scope fence: the originating view scope now travels with every queued pulse and is rechecked before SwiftUI delivers it. +- Ten focused haptics tests pass on the connected physical iPhone 13 Pro, including emit-then-dismiss, exactly-once local stream approval/terminal/stop convergence, restored-stream silence, and dismissed pairing. The complete signed physical suite previously passed 144 tests with five environment-gated skips and no failures. +- `npm run test:ios-release` passes 20 Ruby tests with 42 assertions and 25 Node policy tests. No simulator was used. +- Internal-only build `0.1.0 (16)` is App Store Connect build `4389a0ee-430b-46e7-97ca-bea553b6f335`, assigned to Internal Testers with `internalBuildState=IN_BETA_TESTING` and `externalBuildState=NOT_APPLICABLE`. diff --git a/docs/plans/completed/aiden-remote-multi-instance-hardening-plan.md b/docs/plans/completed/aiden-remote-multi-instance-hardening-plan.md new file mode 100644 index 00000000..734dddd7 --- /dev/null +++ b/docs/plans/completed/aiden-remote-multi-instance-hardening-plan.md @@ -0,0 +1,175 @@ +# Aiden Remote Multi-Instance Hardening + +Status: Complete + +This plan is the authoritative delivery contract for pairing-completion UX, multiple mobile devices, multiple Aiden installations on iOS, multiple Macs, and multiple Aiden runtime profiles on one Mac. It extends the active [Aiden On The Go plan](../aiden-on-the-go-plan.md) without weakening its protocol, credential, TLS, approved-root, or Tailscale ownership guarantees. + +## Product decisions + +1. Pairing is complete only after the newly issued device successfully authenticates back to the Mac. Issuing a credential alone is not shown as “connected.” +2. A successful pairing dismisses the one-time QR, closes the pairing window, announces the connected device, and immediately renders the device as active. +3. Multiple phones and iPads may connect to one Aiden installation with independent credentials, activity, streams, approvals, and revocation. +4. Aiden On The Go treats multiple Macs as first-class installations keyed by the server’s stable `instanceId`; display names are labels, never identity. +5. Fresh unpaired Aiden profiles on one Mac may choose a different stable LAN/loopback port pair. A paired profile never silently changes its endpoint. +6. One physical Mac may publish only one canonical `/api/aiden/v1` Tailscale Serve route at a time. Other profiles remain available over LAN. Aiden never silently steals or resets a route. +7. A stale Aiden-owned route may be taken over only through an explicit, reviewed action that removes exactly the canonical Aiden handler and preserves every unrelated Serve/Funnel configuration. + +## Security and correctness invariants + +- Pairing remains five-minute, one-use, 256-bit, pinned-identity bootstrap authentication. +- Device bearer credentials remain digest-only on the Mac, instance-scoped in the iOS Keychain, absent from renderer state, logs, discovery, and notifications. +- The renderer consumes allowlisted projections and typed IPC only; UI state is never treated as authorization. +- Device-connected UI is driven by a successful authenticated request observed by Electron main, not by optimistic renderer state. +- `instanceId` scopes credentials, caches, App Intents, workspaces, streams, and installation switching. +- Port selection is bounded, deterministic after persistence, and fails closed on exhaustion or paired-endpoint collision. +- Tailscale ownership is exact-path and exact-target. Funnel and unrelated routes are never modified. +- An old profile cannot disconnect or overwrite a route that has since been claimed by another profile. +- Bonjour exposes only the protocol version, public instance identifier, service port, and a bounded display label. +- Existing v1 clients remain compatible; any additive projection field is optional until both TypeScript and Swift fixtures prove parity. + +## Phase 1 — Authoritative pairing completion + +### Implementation + +- Separate credential issuance from first authenticated contact in the device state model. +- Persist a new device as pending without falsely advancing `lastSeenAt`. +- On the first successful bearer-authenticated request, atomically mark the device connected, persist its real `lastSeenAt`, and broadcast the existing bounded remote-state invalidation. +- In Remote Access settings, correlate the open one-time pairing window with the newly issued device while ignoring unrelated device activity and settings changes. +- After first authentication, close the pairing window, dismiss the QR, show “ connected,” focus/highlight its row, and expose it as Active. +- Keep the QR visible for incomplete exchange, Keychain persistence failure, offline first load, cancellation, and expiry. Expose “Finishing connection” without calling it Active. + +### Tests and gate + +- Pairing issue → pending → first authentication → active transition. +- First authenticated request emits one durable transition even when concurrent requests arrive. +- Unrelated device activity, route changes, and approved-root changes never dismiss the QR. +- Expired, cancelled, failed, duplicate, and already-used pairing windows remain honest. +- Focused main/renderer tests, type-check, lint, and React Doctor pass before review. +- Two fresh-memory GPT-5.6 Sol medium reviews must be resolved before Phase 2. + +### Accepted result — 2026-08-21 + +- Credential issuance now persists a pending device (`lastSeenAt = 0`); the first successful authenticated request atomically records the real contact and concurrent requests produce one durable transition. +- A main-owned, non-secret pairing session identifier correlates the exact pairing window and issued device. Conditional close prevents one Settings window from closing another, including unmount and in-flight begin races. +- The desktop keeps the QR visibly disabled during finishing/failure/expiry, dismisses it only after authenticated contact, announces and focuses the Active row, and renders pending devices honestly in Settings and the global connection popover. +- Save-failure retry, authentication/revocation interleaving, post-deadline first contact, replacement/closure, revoked pending, and lifecycle presentation tests are registered in `test:aiden-remote`. +- Gate accepted after three independent two-review passes and targeted re-checks. `test:aiden-remote` passes 138 tests plus seven transport proofs; type-check and lint pass; React Doctor has no new pairing-specific correctness diagnostic. + +## Phase 2 — Stable Mac identity and multi-installation UX + +### Implementation + +- Add a bounded persisted server display name, defaulting to the macOS computer name with a safe fallback. +- Project that name through `/server`, pairing/install metadata, Bonjour presentation, Remote Access settings, and the iOS installation switcher without changing identity semantics. +- Disambiguate identical Bonjour labels with a stable short public instance suffix. +- Show each iOS installation’s display name, reachability state, endpoint type, and last successful connection. +- Audit all iOS caches, navigation requests, streams, Live Activities, and App Intent catalog entries for exact `instanceId` scoping and stale-generation cancellation during installation switches. + +### Tests and gate + +- Two installations with the same display name remain distinct. +- Renaming a display label does not rotate credentials or instance identity. +- Switching while requests/streams are in flight cannot leak results into the newly active installation. +- TypeScript/Swift fixture parity and focused iOS tests pass. +- Two fresh-memory GPT-5.6 Sol medium reviews must be resolved before Phase 3. + +### Accepted result — 2026-08-21 + +- A bounded persisted Mac label now defaults from macOS Computer Name and projects through `/server`, Settings, Bonjour, and the iOS installation registry without replacing stable `instanceId` identity. Same-name Bonjour and switcher rows use a stable public suffix. +- Pairing display metadata is opt-in so strict legacy v1 clients remain compatible; current iOS retries the frozen four-field request only after an early-v1 exact-shape rejection. Production-shaped state initialization durably seeds missing files and migrates legacy labels without rewriting current profiles. +- iOS activation-generation leases now scope workspace browsing, chats, Git, files, schedules, navigation, App Intents, cache fallbacks, and Live Activity reconciliation. A→B and A→B→A requests cannot apply stale state or send opaque handles to another Mac; accepted background turns retain their instance-scoped resumable stream handle. +- Scheduled Tasks binds its entire retained modal/model lifecycle to one activation lease, dismisses on installation changes, persists deletion/settings changes, and prevents delayed history writes from restoring removed run summaries. +- Gate accepted after repeated independent two-review passes. `test:aiden-remote` passes 146 tests plus seven transport proofs, type-check and diff checks pass, and a generic physical-iOS compile-only test build succeeds without using a simulator. + +## Phase 3 — Same-Mac listener and port hardening + +### Implementation + +- Introduce a bounded Aiden port-pair allocator for a fresh profile and persist the selected LAN port only after both required listeners bind successfully. +- Retain the persisted port across restarts. +- Auto-select another pair only when the profile has no active or previous device credentials and no externally established endpoint ownership. +- Fail a paired profile closed with a typed `remote_port_in_use` error and remediation instead of moving its endpoint. +- Make listener startup transactional so partial LAN/loopback/Bonjour state is always rolled back. +- Present the selected port and collision remediation through progressive disclosure rather than raw `EADDRINUSE` text. + +### Tests and gate + +- Two fresh profiles race to enable and receive distinct stable pairs. +- Restart retains the pair; exhaustion fails closed; partial bind rolls back. +- Paired profiles never silently move. +- Bonjour advertises the actual committed port only after successful startup. +- Two fresh-memory GPT-5.6 Sol medium reviews must be resolved before Phase 4. + +### Accepted result — 2026-08-21 + +- Fresh profiles now choose a bounded even LAN/loopback port pair, commit it only after both listeners and Bonjour are healthy, and retain that pair across restarts and connection-mode changes. Inactive transports remain leased but reject new work, and existing keep-alive/SSE sockets are destroyed when their transport becomes inactive. +- Paired or externally owned profiles never move silently. Port exhaustion and committed-endpoint collisions fail closed with typed, bounded remediation; legacy committed odd ports and `65535` remain restart-compatible. +- Candidate allocation excludes every exact canonical or legacy Aiden Tailscale handler target, including ambiguous multi-authority Serve status. Startup and persistence failures roll back listeners, sockets, Bonjour, and state without leaking a half-bound pair. +- The final gate passes 170 TypeScript remote tests plus seven transport proofs, type-check, lint, and diff checks. Two fresh-memory GPT-5.6 Sol medium reviewers found no remaining Phase 3 correctness, security, lifecycle, concurrency, or coverage issue. + +## Phase 4 — Tailscale ownership and explicit takeover + +### Implementation + +- Classify Serve state as owned by this profile, owned by another live Aiden target, stale Aiden target, unrelated conflict, Funnel conflict, or available. +- Keep the canonical public path `/api/aiden/v1` and one-owner-per-Mac policy. +- Replace raw `tailscale_route_conflict` renderer errors with typed, bounded, actionable status. +- Block takeover while the incumbent loopback target is healthy. +- Offer an explicit Take Over action only after a bounded loopback health check proves the incumbent target stale; re-check immediately before mutation. +- Remove and replace only the exact Aiden handler. Persist new ownership only after post-command verification. +- Preserve old-owner safety: disconnect remains exact target + exact ownership and cannot clear a successor’s route. + +### Tests and gate + +- Live owner blocks takeover; stale owner permits confirmed takeover. +- TOCTOU changes between review and mutation fail closed. +- Unrelated handlers and Funnel state are byte-for-byte preserved. +- Old owners cannot disconnect new owners; failed commands never persist false ownership. +- Two fresh-memory GPT-5.6 Sol medium reviews must be resolved before Phase 5. + +### Accepted result — 2026-08-21 + +- Tailscale Serve state is classified by exact canonical path, exact loopback target, explicit TCP 443 HTTPS state, Funnel state, persisted profile ownership, and bounded health checks. Live owners cannot be taken over; stale owners require a one-use reviewed token and an immediate pre-mutation re-check. +- Every mutation is serialized across local Aiden processes with a kernel-owned exclusive UDP loopback mutex, preserving compatibility with every retained TCP listener port. Aiden fingerprints and verifies all non-Aiden Serve state, permits listener scaffolding changes only for the first/final Aiden handler, and never resets Funnel or unrelated routes. +- Route ownership is persisted only after verified mutation. Persistence failures conditionally restore the exact predecessor without overwriting a successor. Ambiguous CLI outcomes use bounded status retries and one durable pending-outcome record written before mutation; pairing and all ordinary route actions remain blocked until the explicit Verify update action proves an unchanged not-applied state or an exact applied state and atomically commits the result. +- Malformed HTTPS listeners, delayed daemon visibility, external interleavings between verification reads, process crashes, concurrent takeovers, retained legacy ports, legacy origin-only migration, and old-owner disconnects have focused regressions. +- The accepted gate passes 199 TypeScript remote tests plus seven transport proofs, type-check, and lint. Two fresh-memory GPT-5.6 Sol medium reviewers independently report no remaining Phase 4 correctness, security, lifecycle, concurrency, or coverage finding. + +## Phase 5 — Multiple-device and multi-Mac acceptance + +### Implementation + +- Verify independent lifecycle ownership for simultaneous devices, streams, approvals, attachment uploads, scheduled tasks, and revocation. +- Improve desktop/mobile connection summaries for multiple active and inactive devices without exposing secrets or private paths. +- Exercise multiple saved Mac installations, failover, removal, re-pair, App Intents, and cache isolation. +- Update Remote Access operator documentation, project memory, onboarding/feature-tour copy only where first-run behavior materially changed. + +### Tests and completion gate + +- Two phones concurrently use one Mac; revoking one leaves the other unaffected. +- One phone pairs with two Macs and switches repeatedly over LAN and Tailscale. +- Same-name and offline-Mac cases remain understandable. +- Full `test:aiden-remote`, service-boundary, onboarding, iOS, type-check, lint, build, and relevant physical-device gates pass. +- React Doctor reports no new correctness findings in changed React code. +- Two final fresh-memory GPT-5.6 Sol medium reviews are resolved. +- The plan moves to `docs/plans/completed/` only after every phase and gate above is complete. + +### Accepted result — 2026-08-21 + +- Device authorization now has a synchronous admission fence and mutation drain. Revocation is durably persisted before admitted mutations drain, then retries device-owned chat attachments, workspace operations, streams, and approvals without affecting another device. +- Stream revocation is durably journaled, surfaces persistence failures, retries safely, and filters revoked-device records during startup. A production-shaped restart test proves one device stays revoked while another device's streams and credential remain usable. +- iOS retains every request, cache, active stream, Live Activity, App Intent projection, and removal/re-pair transition under exact `instanceId` plus `deviceId` identity. An installation data gate serializes accepted writes against removal so a late response cannot recreate purged data. +- Installation-scoped chat, scheduled-task, archive, and workspace-environment caches purge only the removed Mac. Legacy flat workspace cache migration deletes only records attributable to that installation and preserves another Mac's data. +- The final gate passes 209 TypeScript remote tests plus seven transport proofs, 18 service-boundary tests, 30 onboarding tests, 20 Ruby release-policy tests with 42 assertions plus 24 Node release tests, type-check, lint, build, and diff checks. The connected physical iPhone 13 Pro passes 100 XCTest cases with five configuration-only skips and zero failures; no simulator was used. +- Two final fresh-memory GPT-5.6 Sol medium reviewers independently found no remaining server or iOS correctness, security, privacy, lifecycle, concurrency, or coverage issue after the repair rounds. React Doctor completed with no new hardening-specific correctness finding. + +## Phase review protocol + +Every implementation phase uses the same mandatory gate: + +1. Implement only the current phase and its tests. +2. Run focused tests plus type-check/lint appropriate to the diff. +3. Give two fresh-memory GPT-5.6 Sol medium reviewers the authoritative plan, current phase scope, and exact diff. +4. Resolve every correctness, security, privacy, lifecycle, concurrency, accessibility, and test-coverage finding. +5. Re-run the phase gates and record the accepted result in this plan and `.memory/aiden-on-the-go.md`. +6. Advance only after both reviews are clean or all actionable findings are fixed and re-reviewed. diff --git a/docs/plans/dynamic-model-catalog-plan.md b/docs/plans/dynamic-model-catalog-plan.md index 1646e359..94ef9aee 100644 --- a/docs/plans/dynamic-model-catalog-plan.md +++ b/docs/plans/dynamic-model-catalog-plan.md @@ -1,8 +1,8 @@ # Dynamic Model Catalog Plan -Status: Partial — Pi's device-local models store, cache-only hydration, and explicit -provider refresh are implemented; the remote overlay for otherwise-static hosted -providers is not. +Status: Implemented — trusted pi.dev overlays, durable offline hydration, scoped +setup refresh, nonblocking four-hour launch refresh, explicit Settings/command +refresh, Pi-native metadata, and Mac/iOS projection ship on the pinned Pi runtime. Date: 2026-07-24 Source audited: local `/Users/sambitbiswas/projects/pi` (`packages/coding-agent` remote catalog + `packages/ai` Models/ModelsStore) Related: `docs/plans/pi-provider-integration-plan.md` (Phases 1–2 already call for `ModelsStore` + refresh; this plan owns the **remote overlay** that plan deferred) @@ -11,6 +11,40 @@ Related: `docs/plans/pi-provider-integration-plan.md` (Phases 1–2 already call Aiden users should get newly published hosted models (for example Opus 5) **without waiting for an Aiden app release**, by refreshing a device-local catalog overlay — the same product idea as Pi’s `pi update --models` / `/model` refresh. +## Implementation record (2026-08-22) + +- Option A is accepted: Aiden reads full executable model records only from the fixed + `https://pi.dev` provider endpoint. Requests carry a static versioned Aiden/Pi + User-Agent and never provider credentials, chat content, install IDs, or models.dev data. +- `@earendil-works/pi-ai` and agent-core remain deliberately pinned to `0.80.10`. + A full Pi package bump also changes Session/runtime contracts, so Aiden backports + only the reviewed remote-catalog behavior and OpenCode Go Responses transport. +- The main process restores normalized `0600` device-local catalogs offline before + first paint. A soft refresh runs after first paint when the four-hour TTL is stale; + Settings and the command palette provide force refresh. The stale launch pass includes + only stale Aiden pi.dev overlays, excluding Radius, Concentrate, and every other + provider-owned discovery path. Partial provider failures still publish + successes, retain each failed provider's last-known-good catalog, and surface a + nonfatal retry warning after credentials have already been saved. +- Remote routing fields are validated against each shipped provider's HTTPS origins + and executable API set. Responses are streamed through a 5 MiB limit, catalog/store + cardinality is bounded, validators are safe, downgrade and far-future timestamps are + rejected, and an already-poisoned future timestamp can be replaced by a current valid + generation, while a system-clock rollback retains the accepted catalog and downgrade + high-water mark. Scoped refresh uses Pi's non-refreshing auth check, so cancellation cannot + leave an OAuth rotation running after timeout. Empty 200 and negative 404/501 results + honor the same four-hour TTL; timeout/malformed responses never replace a good cache. + Renderer refresh results contain only bounded provider IDs and app-owned recovery copy; + raw upstream/auth error bodies remain main-process-private. +- Provider-owned metadata is the fallback when a new model is absent from the bundled + models.dev snapshot. OpenCode Go's `ox-alpha-free` therefore appears as + “Ox Alpha Free (Unlimited)” with low/high/max thinking on Mac and paired iOS. + The remote-v1 projection also carries the effective saved/default level and whether + “off” hides required thinking so both clients make the same initial selection. +- `npm run models:refresh` and `npm run dist` remain the only models.dev network paths. + The refreshed bundled capability snapshot is still offline display/ranking data, + not live selectable inventory. + When this ships: 1. Built-in Pi providers still ship with a static baseline catalog from `@earendil-works/pi-ai`. @@ -31,10 +65,10 @@ When this ships: | Layer | Today | Gap | | --- | --- | --- | -| Selectable hosted models | Pi `builtinModels()` is authoritative for built-ins and provider-owned dynamic catalogs | Providers without their own dynamic fetch still require a Pi pin bump **and** an Aiden release | +| Selectable hosted models | Pi `Models` plus Aiden's trusted pi.dev overlay are authoritative for built-ins | Shipped baseline remains the offline fallback; no release is required for compatible new remote records | | Capability metadata | Bundled `resources/model-capabilities.json` via `npm run models:refresh`; AA device cache on Connect & fetch | Offline by design; not the Opus-5 availability problem | -| Pi registry | `ProviderRegistry` + `builtinModels({ credentials, modelsStore })`; cache-only hydration and explicit force refresh are wired | No Aiden-owned remote overlay for otherwise-static providers | -| Device-local store | `pi-models-store.ts` persists Pi `ModelsStoreEntry` snapshots under Electron `userData` | The store can retain provider-owned dynamic results, but it cannot create a remote path for a static provider | +| Pi registry | `ProviderRegistry` wraps static providers with a validated overlay, compatible transports, offline hydration, scoped setup refresh, TTL launch refresh, and explicit force refresh | A future full Pi package upgrade remains a separate Session/runtime migration | +| Device-local store | `pi-models-store.ts` persists bounded normalized `ModelsStoreEntry` snapshots under Electron `userData` with mode `0600` | None for the implemented overlay scope | Pi coding-agent reference (do not import the package; mirror the pattern): diff --git a/docs/plans/onboarding-auth-and-provider-validation-plan.md b/docs/plans/onboarding-auth-and-provider-validation-plan.md new file mode 100644 index 00000000..e2a27839 --- /dev/null +++ b/docs/plans/onboarding-auth-and-provider-validation-plan.md @@ -0,0 +1,393 @@ +# Onboarding Authentication and Provider Validation + +Status: Active + +## Goal + +Make first-run onboarding truthfully establish a usable Aiden setup: ChatGPT/Codex +sign-in must be reachable and recoverable, API-key and endpoint setup must report +what was actually verified, and dismissing onboarding must never be recorded as a +completed configuration. + +The delivery should reuse Aiden's existing provider, credential, model-selection, +and visual primitives. It must not create a second provider catalog, leak secrets +to the renderer, perform billable validation requests, or make network calls on +ordinary startup. + +## Audit result + +### P0 — ChatGPT sign-in is deterministically unreachable + +- Onboarding looks for `openai-codex` with `isBuiltin === true` in + `renderer/components/onboarding-flow.tsx`. +- `main/services/provider-list-core.ts` intentionally omits signed-out Codex and + synthesizes a configured Codex item without `isBuiltin` or `authMethods`. +- The resulting branch can only show “ChatGPT sign-in is unavailable.” Even a + configured entry cannot render actions in the generic `BuiltinProviderEditor`, + because that editor requires `authMethods`. +- The dedicated Settings implementation already has the correct Codex-owned + session and view state in `renderer/components/settings/codex-provider-settings.tsx`. +- Current onboarding tests only inspect source text. They never render the real + signed-out provider-list shape, so they assert that the dead branch exists + rather than proving it works. + +### P1 — onboarding can complete without a usable provider + +- The global Skip button writes a renderer `localStorage` completion bit from any + step. There is no readiness check, confirmation, deferred state, or + non-destructive way to reopen onboarding. +- OpenAI and Anthropic accept any nonempty string and save hard-coded custom + provider records without contacting the provider. +- Tailscale setup can save a provider with zero models and still advance. +- Generic built-in authentication advances even when the refreshed provider is + missing a key or models. +- Completion is not derived from the main process's authoritative profile, + credential, provider, and model state. + +### P1 — onboarding is not an application modal + +- The routed shell, assistant dock, native commands, and command palette remain + active behind onboarding. +- Command-K, Command-N, Settings, and menu navigation can create hidden stacked + UI and focus conflicts during setup. + +### P2 — auth, recovery, and accessibility gaps + +- Partially completed setup is durable, but step progress is component-local. + Reloading after a profile or provider save restarts at step one. +- Generic auth cancellation loses the Codex-specific `finishing` state around + the credential commit boundary. +- Successful selected-provider auth can appear to fail when an unrelated global + catalog refresh fails. +- Step transitions do not move or announce focus, progress lacks + `aria-current`, and the feature gallery creates an unnecessarily long keyboard + path. +- Browser-open failure is not surfaced clearly. Pi uses a fixed localhost port + (`1455`) and swallows callback bind errors into a manual-code fallback, so + Aiden's existing `port_busy` diagnosis cannot fire. +- Retry status currently rereads local credential state; it does not prove that + the remote token remains accepted. + +## Product decisions + +1. **Setup completion is authoritative.** `completed` means the main process can + identify at least one selectable model on a configured provider whose setup + state satisfies that provider class's readiness contract. +2. **Do not keep the current global Skip.** Required profile/provider steps + cannot be dismissed as complete or deferred. The `deferred` state exists only + to migrate old renderer completion markers that never established a usable + provider; Settings offers a non-destructive **Show onboarding** recovery path. +3. **The tour is optional.** Once required setup is ready, the user may choose + **Start using Aiden** without traversing the entire feature gallery. +4. **Use native provider identities.** OpenAI and Anthropic onboarding configure + Pi's native `openai` and `anthropic` providers, not + `custom:onboarding-openai` or `custom:onboarding-anthropic` clones. +5. **Validation is explicit and non-billable.** Never send a chat, completion, + malformed generation, or zero-token generation as a credential probe. +6. **Validation claims match evidence.** OAuth exchange, authoritative account or + catalog checks, custom endpoint discovery, local endpoint reachability, and + ambient credential resolution are different assurance levels in both data and + copy. +7. **No background credential checks.** Validation runs only after a user chooses + **Validate & continue**, **Check connection**, or performs a real request. + Startup only loads stored evidence and computes whether it is stale. +8. **One Codex credential owner.** The P0 repair reuses Aiden's dedicated Pi Codex + session. A later architecture gate must choose between the Pi-owned auth and + inference path or an official packaged Codex CLI/app-server path. Do not run + official `codex login` while continuing Pi inference through a separate hidden + credential store. + +## Target onboarding state + +Persist a versioned, main-owned, non-secret record: + +```ts +type OnboardingState = { + version: number; + outcome: "incomplete" | "deferred" | "completed"; + lastSatisfiedStep: "none" | "profile" | "provider" | "tour"; + selectedProviderId?: string; +}; +``` + +At launch, derive the actual next step from authoritative profile and provider +readiness. The profile checkpoint is a resume hint; the provider checkpoint is +recorded by main only after one of onboarding's guarded validation, OAuth, or +catalog-discovery paths succeeds for that exact provider ID. Ambient credentials +and static Pi catalogs are not evidence. Never persist API-key drafts, OAuth +prompts, manual authorization codes, or provider response bodies. + +```text +boot.checking + -> profile.editing/saving/error + -> provider.catalog_loading/choosing + -> codex.starting/waiting/prompt/responding/cancelling/finishing + -> api_key.validating/saving/reconciling + -> endpoint.validating/saving/reconciling + -> tour + -> completion.persisting/error/completed + +any idle setup state + -> close only after authoritative completion +``` + +Normal completion is allowed only after a fresh selected-provider reconciliation +passes the shared usable-provider predicate. Migrated deferred records reopen +required setup and cannot be written through renderer IPC. + +## Implementation checkpoint + +The immediate onboarding repair now ships in the working tree: + +- onboarding renders the dedicated Codex sign-in surface and advances only from + its configured, healthy model-bearing status; +- profile/provider progress and completion are versioned in main-owned settings, + required setup has no Skip/defer action, migrated false-completion records + reopen, and Settings can reopen onboarding without deleting configuration; +- OpenAI and Anthropic use their native Pi identities and validate an + authenticated bounded model catalog before replacing the stored key; +- LM Studio, Ollama, and Tailnet routes must discover a usable model before they + can advance, and every successful path persists a selected model; +- secret-bearing discovery rejects redirects, bounds response/model data, emits + closed errors, and distinguishes cancellation from timeout; +- malformed model IDs and credential control characters fail closed, transport + errors cannot echo key material, and first-run Tailnet discovery is restricted + to `.ts.net` names and Tailnet address classes; +- generic Pi credential entry remains available in Settings but cannot satisfy + required onboarding until that provider has an authoritative non-generation + validator; +- onboarding blocks the workbench while main-owned state loads, fences state + writes to the active renderer document, blocks deep-link navigation, and + reconciles setup that finishes after a close/cancel race; +- the onboarding shell is an application modal, nested confirmations render in + the onboarding layer, browser-launch failures retain a manual link, step + headings receive focus, progress exposes `aria-current`, and the feature + gallery no longer adds 24 noninteractive tab stops. + +The broader cross-provider evidence registry, Settings evidence UI, and the +Phase 6 credential-owner decision remain future architecture work; they are not +required for the repaired first-run completion contract. + +## Validation contract + +Configuration and evidence are separate: + +```ts +type ConfigurationState = "missing" | "configured" | "needs_attention"; +type ValidationState = + | "unverified" + | "validating" + | "validated" + | "stale" + | "rejected" + | "unreachable"; +type Assurance = "authoritative" | "capability_probe" | "configuration_only"; +type Evidence = + | "oauth_exchange" + | "oauth_refresh" + | "identity" + | "catalog" + | "custom_catalog" + | "local_catalog" + | "request_success"; +``` + +Evidence stores only `checkedAt`, `freshUntil`, `strategyVersion`, an opaque +credential revision, a canonical connection fingerprint, optional bounded model +count, and a closed sanitized error. It contains no key, key hash, complete URL, +account identifier, raw upstream response, or prompt content. + +### Assurance matrix + +| Provider class | Safe check | Honest result | +| --------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| ChatGPT/Codex OAuth | Successful OAuth exchange; later refresh or real request | Signed in at that time | +| OpenAI API key | Authenticated `GET /v1/models` | Credentials accepted and catalog accessible | +| Anthropic API key | Authenticated Models list endpoint | Credentials accepted and catalog accessible | +| Other built-in static-key providers | Provider-specific allowlisted identity/catalog endpoint, when documented | Authoritative only if the endpoint enforces auth; otherwise unverified | +| Authenticated dynamic catalogs such as Concentrate/Radius | Intended-origin authenticated catalog | Credentials and catalog capability accepted | +| AWS/Vertex/Azure/Cloudflare ambient or multi-field auth | Local resolution plus documented identity/control-plane check when available | Configuration-only or provider-specific capability | +| Custom OpenAI/Anthropic-compatible server | Exact-origin bounded model discovery | Endpoint reached and models listed; key enforcement not guaranteed | +| LM Studio/Ollama/Tailnet keyless server | Native or generic bounded model discovery | Local/private endpoint reached; not credential validation | + +`401` is rejected credentials. `403` is a permission/capability failure, not +automatically a bad key. `429`, timeout, DNS/TLS failure, and `5xx` are +inconclusive and must not erase earlier trustworthy evidence. A `2xx` response +with no usable models may validate authentication while still failing onboarding +readiness. + +## Architecture + +Add a main-process `ProviderValidationService` with an immutable strategy registry +for every Pi built-in plus Aiden's first-class providers and custom/local classes. +Candidate secrets remain main-process memory until validation completes. + +The service must: + +- bind attempts to the initiating renderer document and cancel on navigation, + destruction, shutdown, or explicit cancellation; +- fence results by provider draft fingerprint and opaque credential revision so a + late result cannot overwrite newer input; +- use per-attempt and total deadlines, bounded concurrency, and closed result + codes; +- commit provider configuration, credential, catalog, default model, and evidence + atomically, extending the existing credential-rotation journal for custom + connections; +- preserve the previously working provider when a candidate fails or becomes + inconclusive; +- reject cross-origin redirects for secret-bearing requests, validate the exact + intended origin, retain TLS verification, and apply the existing private-host + policy deliberately for local/Tailnet providers; +- bound response bytes, pages, model count, model ID length, and displayed error + data; +- log only provider ID, validation strategy, closed result code, and coarse + duration. + +Expose owner-bound start/cancel/status IPC rather than returning raw upstream +errors. Settings and onboarding consume one shared renderer state model and one +accessible status component. + +## Delivery phases + +### Phase 0 — executable regression harness + +- Replace source-regex onboarding assertions with rendered interaction tests. +- Add a fixture using the actual signed-out `providers:list` projection. +- Add auth/session fakes for success, prompt, browser failure, device flow, + cancellation, finishing, expiry, and terminal failure. +- Register all new files in a CI-invoked test script; keep the focused onboarding + script for local iteration. + +**Exit gate:** a failing test reproduces the current ChatGPT dead branch and a +second test proves the existing global Skip marks an unusable setup complete. + +### Phase 1 — P0 Codex onboarding repair + +- Extract the dedicated Codex status/session UI from Settings into a shared + component/state machine and use it directly in onboarding. +- Start `createCodexAuthSession` by the immutable `openai-codex` identity; never + discover signed-out Codex through the configured-provider list. +- Offer Browser and Device Code as clear actions, with manual-code fallback, + retry, cancel, `finishing`, and actionable browser-open/callback failure states. +- Restrict automatically opened Codex auth URLs to the expected OpenAI origins in + addition to HTTPS validation. +- Reconcile only Codex after terminal success. Treat other catalog refreshes as + best-effort warnings. +- Advance only when a fresh Codex status is configured, not attention-needed, and + exposes at least one usable model. +- Add a packaged lazy-module/auth canary so the exact Pi OAuth path is verified + inside ASAR. + +**Exit gate:** a fresh install can sign in by browser or device code, cancel and +retry safely, relaunch, and select a Codex model in a packaged build. + +### Phase 2 — authoritative onboarding lifecycle + +- Move the versioned onboarding outcome/checkpoint to the main process. +- Derive resume position from profile and provider readiness after reload/crash. +- Remove the global Skip and do not expose a defer action during required setup. +- Preserve migrated `deferred` records without treating them as completed, and + expose a non-destructive **Show onboarding** recovery path in Settings. +- Make onboarding an application modal: inhibit command palette, navigation, + native menu mutations, assistant dock actions, and hidden stacked dialogs. +- Separate required setup from the optional feature tour. + +**Exit gate:** no path records `completed` without a usable provider; reload and +defer/re-entry behavior remain consistent. + +### Phase 3 — validation service and secure network boundary + +- Add validation DTOs, evidence storage, strategy registry, owner-bound IPC, + deadlines, cancellation, revision fencing, and sanitized closed errors. +- Harden shared discovery with exact-origin redirect rejection, SSRF review, + bounded bodies/catalogs/pages/IDs, and distinct cancel/timeout outcomes. +- Migrate existing configured providers to `unverified`; cached catalogs never + become validation evidence. +- Clear validation evidence during onboarding reset/provider removal and recover + atomic rotations after interrupted writes. + +**Exit gate:** security and race tests prove no secret or raw provider response +can reach URL, argv, environment, renderer notifications, logs, cache, or crash +text. + +### Phase 4 — onboarding provider integrations + +- Replace onboarding's OpenAI/Anthropic custom clones with native providers. +- Implement documented non-billable catalog validation for OpenAI and Anthropic, + then atomically save credential, discovered models, and default selection. +- Route LM Studio, Ollama, and Tailnet/custom setup through the same service while + labeling results as endpoint capability rather than credential acceptance. +- Persist a selected default model for every successful route, not only local + runtimes. +- Classify every installed Pi provider and Concentrate. Add authoritative probes + only where current primary documentation supports them; show **Not checked** or + configuration-only elsewhere rather than inventing a generation probe. + +**Exit gate:** invalid hosted keys cannot advance, unreachable or zero-model +endpoints cannot advance, and capability-only endpoints never claim that a key +was accepted. + +### Phase 5 — shared Settings UI and runtime evidence + +- Use one inline status component and copy across onboarding and Settings: + **Checking**, **Credentials accepted**, **Endpoint reached**, **Not checked**, + **Checked previously**, **Credentials rejected**, and **Could not reach + provider**. +- Offer Retry and an explicit **Save without checking** in Settings. In required + onboarding, unverified save may persist a draft but does not count as ready. +- Preserve input and focus after failures; use `role="status"`/`role="alert"`, + focus the new step heading, set `aria-current="step"`, and respect reduced + motion. +- Let successful real requests and normalized auth failures update evidence + without issuing additional provider calls. Do not auto-switch models, unhide + models, or change existing chat provenance. + +**Exit gate:** onboarding and Settings show the same trustworthy provider state, +and keyboard/VoiceOver navigation passes focused acceptance. + +### Phase 6 — Codex credential-owner decision + +The official Codex contract currently exposes `codex login`, +`codex login --device-auth`, `codex login --with-api-key`, `codex login status`, +and `codex logout`. Aiden currently uses Pi's OAuth implementation and encrypted +`pi-provider-credentials.json`; the two sessions are independent. + +- Decide whether Pi remains Aiden's declared Codex credential/inference owner or + whether Codex moves as a unit to an officially packaged CLI/app-server path. +- Do not scrape or copy `~/.codex/auth.json`; official storage may be keyring + backed. +- Do not use a user-installed `codex` from `PATH`. +- If migrating, pin and sign per-architecture executables outside ASAR, verify + hashes/provenance/mode/signature, feed API keys only through stdin, and move + inference to the same official owner rather than bridging credentials into Pi. +- Add bounded child lifecycle, output, timeout, cancellation, shutdown, and + package tests before changing the production owner. + +**Exit gate:** authentication status, logout, refresh, and inference all observe +one credential owner, with no hidden divergence from the user's Codex CLI. + +## Required test matrix + +| Area | Required cases | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Codex | Fresh signed-out start; configured; attention-needed; browser success/failure; device success/expiry; manual fallback; callback collision; wrong state; cancel; finishing; retry; relaunch | +| API keys | Valid; empty; `401`; `403`; `429` with bounded Retry-After; `5xx`; DNS/TLS; timeout; cancel; stale/late result; old-key preservation | +| Endpoint discovery | `2xx` models; `2xx` empty; malformed/oversized body; too many pages/models; redirect; origin change; private/metadata targets; protocol/auth mutation | +| Lifecycle | Reload after profile save; reload after provider save; document navigation; window destruction; shutdown; safe-storage unavailable; reset success/failure/cancel | +| Completion | No-provider block; zero-model block; confirmed defer; non-destructive re-entry; completion-write failure; back after save; duplicate click prevention | +| Commands and accessibility | Command-K/menu blocked; focus on step changes/errors; `aria-current`; live status; full keyboard path; reduced motion | +| Cross-provider isolation | Unrelated catalog failure does not block selected provider; one validation cannot overwrite another; visibility/default/existing chats stay stable | +| Security | No secret in URL/argv/env/log/IPC/cache; fixed renderer copy; bounded diagnostics; no generation endpoint invoked by validation | +| Packaging | Exact lazy auth module loads from ASAR; packaged sign-in works with empty `PATH`; arm64/x64 artifact verification if official CLI is adopted | + +## Rollout and observability + +- Ship Phase 1 behind focused automated and packaged acceptance before widening + provider validation. +- Record only aggregate local outcomes: provider ID, strategy, closed status, + coarse latency, and onboarding step. No tokens, URLs, account IDs, prompt data, + or upstream bodies. +- Track completion, defer, validation rejection, unreachable endpoint, auth cancel, + and recovery rates locally so UI dead ends can be diagnosed. +- Update onboarding copy and the final feature-tour gallery only for capabilities + that have shipped and passed their exit gates. diff --git a/docs/security/aiden-remote-threat-model.md b/docs/security/aiden-remote-threat-model.md new file mode 100644 index 00000000..dd7d9cd7 --- /dev/null +++ b/docs/security/aiden-remote-threat-model.md @@ -0,0 +1,124 @@ +# Aiden Remote Access Threat Model + +Status: Phase 0 baseline +Scope: Aiden Agent desktop remote service and Aiden On The Go iPhone/iPad client +Protocol: `/api/aiden/v1` +Review trigger: any new route, capability, transport, background surface, credential store, filesystem projection, or execution owner + +## 1. Security goals + +1. A remote device cannot control Aiden until the Mac user explicitly enables Remote Access and completes pairing. +2. Network reachability is never authorization; LAN/Tailscale peers still need a valid, capability-scoped Aiden device credential. +3. The phone cannot gain more workspace/tool authority than the saved Aiden permission and explicit approvals allow. +4. No remote DTO, event, log, widget, App Intent, or error leaks credentials, absolute paths, private runtime state, or raw consequential-operation details. +5. Browser/file handles cannot escape approved canonical roots, survive policy/filesystem identity changes, cross devices, or be replayed after use/expiry. +6. Network loss/retry cannot duplicate prompts, approvals, workspace creation, Git mutations, or scheduled runs. +7. Revocation and shutdown terminate future authority predictably without corrupting persisted chats/workspaces/tasks. +8. Desktop IPC ownership remains renderer-scoped; network operations use separate explicit owners rather than forged `WebContents` identity. + +## 2. Assets + +- Aiden instance identity and LAN private key. +- Pairing secrets and issued device credentials. +- Provider/MCP credentials and provider configuration. +- Chats, messages, reasoning, attachments, and event journals. +- Workspace registry, permissions, filesystem contents, and approved-root policy. +- Git repositories, branches, commits, remotes, and managed-worktree ownership metadata. +- Scheduled task definitions, scripts, process environment, run output, and notification settings. +- Desktop process availability and user intent/approval state. +- iOS Keychain, App Group cache, offline chat cache, Live Activity, and local notification content. + +## 3. Trust boundaries + +```text +iOS UI / app process + -> Keychain credential + pinned HTTPS + -> LAN or Tailscale network + -> Aiden Remote transport parser/limits/auth + -> capability + revision/idempotency/owner gates + -> shared Aiden application services + -> stores, filesystem, Git, schedule service, Pi runtime + +App Intent process -> App Group cached IDs only -> foreground app routing +Live Activity widget -> bounded last-known state only (no network) +Electron renderer -> preload allowlist -> IPC owner (separate from remote owner) +``` + +Untrusted inputs begin at discovery packets, URLs/QR data, every HTTP header/body/query, SSE resume position, opaque handle, attachment, user-visible name/text, App Group cache, and all filesystem state that can change between calls. + +## 4. Adversaries + +- Unpaired device or malicious browser on the LAN/tailnet. +- Paired but stolen/compromised phone. +- Paired user attempting to exceed capability or workspace permission. +- Network attacker performing interception, DNS redirection, replay, downgrade, or connection disruption. +- Local unprivileged process probing the listener or logs. +- Malicious workspace containing symlink races, replacement directories, enormous trees/files/diffs, hostile Git metadata, or secret-bearing output. +- Compromised/hostile provider, MCP server, tool result, scheduled script, or prompt content attempting exfiltration through remote projections. +- Accidental user retry, concurrent Electron/mobile edits, app suspension, desktop restart, or stale offline state. + +## 5. Threats, mitigations, and required evidence + +| Threat | Mitigations | Required tests/evidence | +| --- | --- | --- | +| Listener enabled without consent | Master switch off by default; no bind/discovery until enabled; packaged lifecycle check. | Disabled port probe and packaged smoke. | +| Pairing secret guessing/replay | 256-bit random QR secret, short TTL, single-use atomic exchange, rate limit, pairing window required, digest-only storage, redacted logs. Manual entry uses a uniformly random 100-bit Crockford code that is never transmitted and locally decrypts the canonical pinned trust envelope with HKDF-SHA256 plus AES-256-GCM; QR and manual entry share the same consumed secret. Low-entropy numeric fallback remains prohibited without PAKE/SAS or explicit fingerprint confirmation. | Expiry, replacement, duplicate, QR/manual concurrency, wrong-code authenticated-decryption, endpoint binding, rate-limit, and log/wire-secrecy tests. | +| MITM/wrong Aiden server | HTTPS; QR carries endpoint + P-256 SPKI SHA-256 pin; hostname/time/signature + pin validation; explicit key rotation/re-pair. | Same-key renewal accepted, changed key/wrong host/expired cert rejected on macOS and physical iOS device. | +| Plain-HTTP downgrade | Production config rejects HTTP; development exception compile/config gated and clearly labeled. | Release/config contract tests. | +| Tailscale mistaken for auth | Bearer credential on every route/SSE; capability checks after auth. | Tailnet request without credential fails. | +| Tailscale config takeover | Inspect current config; Funnel-enabled listener fails connect; exact owned Serve route; matching `off`; never Funnel/reset/global replacement; conflict stops. | Fixture/parser tests plus live loopback route proof showing before/after config equality. | +| Credential theft from server storage | Store credential digest only using memory-hard/slow verifier; safe-storage-protected instance key material; restrictive file permissions. | Store format and migration tests. | +| Credential leakage from phone | Keychain only; no UserDefaults/App Group/URL/log/widget/intent; per-install isolation. | Swift storage and no-network/no-Keychain intent-process tests. | +| Stolen paired device | Desktop device list/revoke; capability scopes; last-seen audit; revoke closes streams and rejects commands. | Revocation during stream/approval/operation tests. | +| Browser cross-origin attack | No permissive CORS, cookies, or ambient auth; Authorization bearer required; reject browser origins as policy; content types/limits. | Origin/CORS/preflight and auth tests. | +| Request smuggling/parser abuse | One vetted HTTP stack; header/body/count/time limits; reject duplicate JSON keys, ambiguous transfer framing, invalid UTF-8, deep JSON. | Malformed/body/header/timeout tests. | +| Secret/path leakage | DTO allowlists; stable safe errors; log redaction; bounded sanitized Git/schedule/provider/tool projection. | Forbidden-field fixture/route/log tests. | +| Directory traversal or handle-store exhaustion | No free-form path; hashed device-bound location handles; canonical `realpath`; approved-root boundary + device/inode + policy revision checks; expired/consumed pruning and hard fail-closed capacity. | `..`, encoding, symlink, mount/root replacement, cross-device, expired/replayed handle, pruning, and capacity tests. | +| Browse-to-register TOCTOU | Separate selection nonce; synchronous atomic revalidate-and-consume with workspace creation; async callbacks prohibited and promise escapes consume/fail closed; fail on identity/policy/duplicate change. | Concurrent consume, async-callback, and filesystem replacement tests. | +| Broad-root data exposure | Root addition desktop-only; nested-root dedupe; home warning/confirmation; filesystem root disabled by default; hidden/system policy. | Settings policy tests and remote inability to add roots. | +| File handle escape/staleness | File handles separate from browser handles; bind instance/device/workspace/root/file/snapshot/expiry; re-resolve; expected-version atomic writes. | Cross-workspace/device, root/file replacement, stale version, symlink tests. | +| File/diff resource exhaustion | 4,000/depth-20 index, read/write/diff byte/time limits, streaming/backpressure where needed. | Boundary and cancellation tests. | +| Git destructive misuse | Explicit `git:write`, foreground confirmation, repository-root-only commit/push, operation snapshots/stale errors, canonical repo serialization, managed metadata server-owned. No generic Git/shell. | Nested workspace, confirmation, concurrency, rollback, metadata projection tests. | +| Managed-worktree deletion escape | Accept persisted workspace ID only; re-resolve ownership token/filesystem identity; cancel/settle operations; schedule restoration; rollback. | Replacement and partial-failure tests. | +| Duplicate prompt/model call | Atomic append/admit/start; bounded durable idempotency digest with fulfilled/rejected/in-flight states; no TTL/capacity eviction of in-flight work; stable turn/stream owner independent of socket; client never retries on SSE loss. | Disconnect/retry/concurrent start, rejected outcome, in-flight expiry, and restart tests. | +| Cross-device stream/approval | Owner bound to authenticated device/stream; approval deadline/idempotent decision; other devices denied. | Ownership and duplicate/conflicting decision tests. | +| Event replay/gap confusion | Per-stream monotonic sequence; bounded journal; Last-Event-ID/after; gap -> snapshot; immutable terminal event. | Replay, duplicate, gap, expiry, restart tests. | +| Restart silently retries provider | Persist terminal metadata; restart marks interrupted once; never retries call. | Crash/restart fixture/integration test. | +| Permission elevation/bypass | `workspace:manage`; explicit foreground confirmation + audit for stronger permission; server saved permission composes tool set; remote cannot request hidden modes. | Elevation, approval, none/ask/full tool-contract tests. | +| Scheduled task duplicate/run cancellation | Revision/CAS edits; idempotent durable `runId`; schedule service owns execution across TCP loss; bounded redacted output. | Duplicate retry, disconnect, concurrent edit, DST, output redaction tests. | +| Scheduled script exfiltration | Only existing server-inventoried script IDs; no raw paths; pairing warns about scheduled authority; no App Intent schedule mutations. | Script-ID/path rejection and DTO tests. | +| Attachment bomb/content abuse | MIME/size/count/dimensions/text limits; short-lived references; cleanup; no server paths. | Oversize, decompression dimension, expiry, aggregate tests. | +| Lock Screen/App Intent leak | Live Activity under 4 KB, safe status only by default, no path/args/errors/credentials; intents cache ID-only and foreground. | Payload size/allowlist and stale/revoked entity tests. | +| Voice privacy leak | On-device recognition required; remove server/cloud/audio upload and voice-note path; permission at first use. | Code-path absence and permission fallback tests. | +| Denial of service | Per-device/global rate limits; active stream/device caps; bounded journals/uploads/browse; timeouts; revoke/disable. | Limit and recovery tests. | + +## 6. Operation ownership and revocation policy + +- Chat turns survive socket loss. Explicit cancel, device revocation, workspace mutation/deletion, or server shutdown follows the generation owner's settlement policy. +- File writes and Git operations use stable remote operation IDs and the existing workspace operation/mutation gates. Socket loss alone does not cancel after server acceptance. +- Scheduled `run now` survives socket loss and is observed by `runId`; remove/pause/global disable/revocation/shutdown use the schedule service's documented policy. +- Pairing/device revocation immediately prevents new requests, closes SSE subscribers, resolves pending approvals as denied/expired according to service policy, and prevents reconnect. It must not blindly kill unrelated desktop-owned work. + +## 7. Privacy and logging + +Remote-access logs are metadata-minimal. Permitted fields: request ID, route template, status, duration, stable error code, and truncated instance/device ID. Forbidden fields: Authorization, pairing/idempotency secrets, opaque handles, QR contents, URLs with query data, request/response bodies, paths, prompts/messages/reasoning, attachments, tool details, provider/MCP failures, Git/schedule output, Keychain/App Group data. + +Offline caches are scoped by Aiden instance ID and use platform data protection. Revocation makes cached data read-only until the user explicitly removes the installation/cache. Lock Screen response excerpts are off by default. + +## 8. Residual risks and non-goals + +- A fully compromised paired phone can exercise granted capabilities until revoked. Pairing disclosure, least capability, foreground confirmations, and desktop revocation reduce but cannot eliminate this. +- A malicious scheduled script or agent tool already authorized on the Mac may access data available to the Aiden process. Mobile does not create new shell/script path authority, but triggering an existing task remains consequential. +- Tailscale/Bonjour availability and certificates depend on their respective system services. Aiden must fail closed and explain prerequisites. +- No external push relay means Live Activities cannot receive fresh terminal updates while the app is terminated. +- No generic terminal, Computer Use, remote provider credentials, file create/rename/delete, or arbitrary Git command is in scope. + +## 9. Review checklist + +Before each phase gate: + +- Compare new routes/DTOs to this model and the protocol allowlists. +- Identify every new bearer secret, opaque handle, owner, mutation precondition, and terminal state. +- Add negative tests before accepting a new consequential action. +- Verify logs and user-visible errors with adversarial inputs. +- Run two independent fresh-memory security/architecture reviews and clear all P0/P1 findings. diff --git a/docs/testing/aiden-on-the-go/phase-0.md b/docs/testing/aiden-on-the-go/phase-0.md new file mode 100644 index 00000000..9203cecf --- /dev/null +++ b/docs/testing/aiden-on-the-go/phase-0.md @@ -0,0 +1,130 @@ +# Aiden On The Go — Phase 0 evidence + +Date: 2026-08-18 +Status: Complete — contract, transport, signing, threat model, parser parity, durability, and physical-device gates pass. + +## Contract and threat model + +- Normative prose: `docs/aiden-remote-api-v1.md` +- Machine-readable schema: `protocol/aiden-remote/v1/openapi.json` +- Shared fixture: `protocol/aiden-remote/v1/fixtures/contract.json` +- Threat model: `docs/security/aiden-remote-threat-model.md` +- iOS implementation contract: `ios/PROJECT_SPEC.md` + +Automated desktop proof: + +```text +npm run type-check +PASS + +npm run test:aiden-remote +PASS — 43 protocol/security-semantics tests and 4 LAN transport/restart tests +``` + +The LAN spike generates a local P-256 CA plus server-only leaves (`CA:FALSE`, digital-signature key usage, server-auth EKU) and proves normal hostname/chain validation, stable installation-key restart, same-key certificate renewal, changed-key rejection and explicit re-pair recovery, wrong-pin rejection, wrong-host rejection, and expired-certificate rejection. Private keys remain in a temporary directory and are removed by the spike. + +## Swift contract and transport proof + +Current generic-hardware compile command: + +```text +xcodebuild build-for-testing -project ios/AidenOnTheGo.xcodeproj \ + -scheme AidenOnTheGo \ + -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO +``` + +Result: PASS. The deterministic Swift contract suite and its environment-gated trust/Keychain cases are now run on explicitly selected physical iOS devices; hosted CI performs only the signing-disabled generic-hardware compile. The TypeScript and Swift tests decode the same checked-in fixture. The privacy tests assert that the built host declares a non-empty `NSLocalNetworkUsageDescription` and the canonical `_aiden-agent._tcp` Bonjour service while shipping no insecure HTTP or arbitrary-load ATS exception. Swift and TypeScript enforce the same known payload/error bounds using Unicode-scalar counts, exact Health allowlisting, strict full-string RFC 3339 timestamps, safe-integer stream sequences, non-null schema members, canonical encoded paths and ASCII host authorities, required unknown-event payload, forbidden wire fields, duplicate-key and unpaired-surrogate rejection, finite JSON numbers, aligned raw/parsed extensible-envelope limits, and a 1 MiB JSON/SSE body ceiling. + +Physical-device setup is non-secret and reproducible: + +1. Run `scripts/aiden-remote-physical-device-spike-server.mjs --host `. +2. Build for testing with automatic signing and the approved Aiden identifiers. +3. Copy the generated `.xctestrun` beside its `Build/Products` payload (or preserve/rewrite its relative `__TESTROOT__` paths), then add the ephemeral canonical pairing-bootstrap JSON as a test-process environment variable, modeling the QR transfer. +4. Run only `AidenRemotePhase0Tests/testPhysicalDevicePinnedURLSessionWhenConfigured` on the connected iPhone. + +The test makes a real request through `AidenPinnedServerSessionDelegate`, verifies the health response, then proves that the same endpoint is rejected with a wrong SPKI pin. No private key, endpoint, LAN address, or fingerprint is committed. + +Physical result: PASS on `Smbt16ProMax` (iPhone 16 Pro Max, iOS 27.0) — one test executed with zero failures in 0.652 seconds. The first hardware attempt usefully exposed missing Local Network/Bonjour declarations, which were added and locked by `testLocalNetworkPrivacyAndBonjourContractIsDeclared`. A second attempt exposed an incorrect hard-coded Mac interface; the successful run derived the active interface from the default route and advertised that LAN address. The final run admitted the correctly pinned health request and rejected the wrong pin through the real device URLSession trust stack. + +Signed Keychain result: PASS on the same device — the approved `sbtbiswas.AidenOnTheGo.pairing` service resolved from the signed application plist, an ephemeral probe completed write/read/delete, and the same account remained absent from a distinct service. Cleanup ran in-test and no credential or probe value was logged or committed. + +Expanded physical lifecycle result: PASS on `Sambit’s iPhone` (iPhone 13 Pro, iOS 27.0). A fresh signed payload first repeated the correct-pin health request and wrong-pin rejection, then completed this six-case matrix with one focused XCTest and zero failures per case: + +| Case | Separate server process | Expected result | Result | +| --- | --- | --- | --- | +| Original pairing | Yes | Current pin accepted; deliberately wrong pin rejected | PASS | +| Process restart | Yes | Persisted installation key remains accepted; wrong pin rejected | PASS | +| Same-key renewal | Yes | Renewed certificate remains accepted; wrong pin rejected | PASS | +| Key rotation / repair | Yes | New pin accepted only after explicit repair; original pin rejected | PASS | +| Wrong hostname | Yes | Normal hostname validation fails closed | PASS | +| Expired certificate | Yes | Normal validity validation fails closed | PASS | + +The matrix used one private installation-identity directory across genuinely separate Node processes. Every process issued a new ephemeral pairing secret and port, while only the intended key/certificate lifecycle property persisted. After the final duplicate-key/ATS remediation, a new signed payload repeated the signed Keychain proof and the complete six-case matrix with the same passing results. That exact built app reported the approved team/bundle/Keychain identity and no `NSAppTransportSecurity` exception dictionary. Injected test-run files, pairing bootstraps, private keys, certificates, build products, and logs were kept under private temporary paths and removed after the summarized result was recorded. + +## Tailscale Serve proof + +Initial `tailscale serve status --json`: `{}`. + +An ephemeral HTTP health service was bound to loopback. A single path-scoped, non-Funnel HTTPS handler was added with the equivalent of: + +```text +tailscale serve --yes --bg --https=443 \ + --set-path=/aiden-phase0-spike http://127.0.0.1:42731 +``` + +The stable tailnet HTTPS URL returned the expected JSON health payload. The handler was removed with the matching path-specific command: + +```text +tailscale serve --yes --https=443 --set-path=/aiden-phase0-spike off +``` + +Final `tailscale serve status --json`: `{}`. Funnel was never invoked and `serve reset` was never invoked. A second live proof first installed an unrelated exact-path handler, added the Aiden handler, removed only the Aiden handler, and compared the remaining status: the unrelated handler and its target were byte-for-byte unchanged. The unrelated proof handler was then removed with its own exact-path `off`, restoring `{}`. The temporary TLS certificate/key requested by the local Tailscale CLI for the proof were deleted after validation. A checked-in pure planner test additionally covers unowned matching-target conflicts, persisted Aiden ownership, idempotent reconnect/disconnect, preservation of unrelated handlers, and preservation of Funnel state. + +## Automatic-signing preflight + +The connected-device `build-for-testing` command used: + +```text +DEVELOPMENT_TEAM=5WP229CBB8 +APP_BUNDLE_IDENTIFIER=sbtbiswas.AidenOnTheGo +APP_GROUP_IDENTIFIER=group.sbtbiswas.AidenOnTheGo +CODE_SIGN_STYLE=Automatic +-allowProvisioningUpdates +-allowProvisioningDeviceRegistration +``` + +Result: PASS. Xcode provisioned the main app, test bundle, existing share target, and Live Activity widget. Inspection of the signed main app, test bundle, and Live Activity widget reports: + +```text +Identifier=sbtbiswas.AidenOnTheGo +TeamIdentifier=5WP229CBB8 +application-identifier=5WP229CBB8.sbtbiswas.AidenOnTheGo +com.apple.security.application-groups=[group.sbtbiswas.AidenOnTheGo] + +Identifier=sbtbiswas.AidenOnTheGoTests +TeamIdentifier=5WP229CBB8 + +Identifier=sbtbiswas.AidenOnTheGo.LiveActivityWidget +TeamIdentifier=5WP229CBB8 +``` + +This resolves the Phase 0 team mismatch at the built-product/provisioning boundary. Permanent target renaming and identity cleanup remain Phase 5 work. + +After the final trust-delegate remediation, a fresh generic iOS `build-for-testing` also passed. Its current `.xctestrun`, signed application, and signed test bundle were generated together; the application still reports `Identifier=sbtbiswas.AidenOnTheGo`, `TeamIdentifier=5WP229CBB8`, and App Group `group.sbtbiswas.AidenOnTheGo`, while the test bundle reports `Identifier=sbtbiswas.AidenOnTheGoTests` and the same team. The build was regenerated after adding the Local Network/Bonjour declarations and was the payload used for the passing physical run. + +## Review outcome + +The post-hardware review rounds reported no P0 and identified concrete P1 contract/lifecycle gaps. Remediation binds file handles to workspace identity, keeps selection nonces consumed after mutation errors, persists and authoritatively finalizes stable in-flight operation references, validates expected SSE stream identity, constrains Tailscale to a server-owned loopback HTTP target, rejects endpoint userinfo, aligns bounded Swift/TypeScript decoding, configures the approved Keychain service, and persists transport identity across real process restarts. Later passes closed parser parity, Release ATS, Health/timestamp/Unicode, restart, and Tailscale-capability edges. Phase 12 real-tailnet acceptance further requires that the target restore the exact canonical Aiden API base stripped by `--set-path`. The final hardening rejects duplicate/escaped-equivalent keys, non-finite values, unpaired surrogates, null schema members, unsafe stream integers, encoded endpoint variants, numeric-final-label DNS aliases, and noncanonical/ambiguous Tailscale listener authorities. OpenAPI, TypeScript, and Swift now share the same endpoint vectors and JSON-safe sequence maximum. + +Persisted idempotency envelopes and state-specific entries are exact allowlists; durable-operation arrays reject every non-dense own key. Replay values have depth, node, key, array, string, per-result, and aggregate-snapshot limits. The live operation registry is capped at 10,000 owners with owner-checked capacity release. If an action has run but its result or rejection cannot be persisted within the exact schema and byte budget, its stable operation reference remains indefinitely `in_flight` for authoritative reconciliation instead of expiring into a duplicate retry. + +At the user's direction, the final post-remediation gate was completed locally without further subagents. It included targeted adversarial restore/settlement tests, a 20,000-authority OpenAPI/runtime parity fuzz with zero mismatches, lint, schema parsing, diff checks, type-check, all 43 focused protocol/security tests, all 4 transport tests, and the complete repository lifecycle. The current signed source also ran directly on `Sambit’s iPhone` (iPhone 13 Pro): all 21 deterministic Phase 0 tests passed and the two environment-injected live transport/Keychain cases skipped as designed. Earlier signed evidence remains valid for Keychain isolation and all six certificate-lifecycle cases; the iPhone 16 Pro Max was untouched. + +## Full applicable test gate + +```text +npm run test +PASS +``` + +The repository's complete desktop test lifecycle passed after the final reviewer remediations, including the new pretest contract/transport suite, all JavaScript/TypeScript suites, native worktree-remover tests, and 41 Rust computer-use-broker tests plus formatting/clippy. The focused Aiden Remote suite, desktop type-check, and focused Swift suite were also rerun after the final contract, redirect, idempotency, selection-transaction, and Tailscale changes and passed. diff --git a/docs/testing/aiden-on-the-go/phase-1.md b/docs/testing/aiden-on-the-go/phase-1.md new file mode 100644 index 00000000..18ec2449 --- /dev/null +++ b/docs/testing/aiden-on-the-go/phase-1.md @@ -0,0 +1,42 @@ +# Aiden On The Go — Phase 1 evidence + +Date: 2026-08-18 +Status: Complete — shared chat/workspace application services preserve the Electron ownership and mutation contracts. + +## Implementation boundary + +- `main/services/chat-application-service.ts` owns chat list/get/wait/create/rename/empty-move/delete semantics and keeps inactive-renderer reconciliation, atomic create guards, Assistant identity rejection, workspace admission, and privacy-first durable deletion ordering. +- `main/services/workspace-application-service.ts` owns workspace registry list/get/create/folder-create/scratch-create/update/remove semantics and keeps canonical folder validation, renderer path-mutation rejection, mutation/operation drains, generation/schedule settlement, permission restoration, and managed-worktree deletion refusal. +- The corresponding `*-main.ts` files bind Electron-backed singletons; the core services import singleton shapes only as types and remain directly unit-testable. +- `ChatGenerationOwner` is a shared narrow interface. Remote owners retain only digests of bounded device/stream identities and survive network subscriber loss until explicit invalidation. Existing renderer owners still come from `rendererDocumentOwner`. +- `main/handlers/chats.ts` and `main/handlers/workspaces.ts` delegate matching CRUD operations. Renderer-only Assistant creation, dialogs, append/copy/export, Git/files, and approval-facing operations remain in IPC-specific handlers. + +## Review outcome + +The requested between-phase review was completed locally because the user directed that no further subagents be used. The review compared the extracted services with the removed handler implementations and checked authority lifetime, cancellation/drain order, schedule restoration, deletion durability, Assistant isolation, dialog ownership, and IPC validation. + +Two test-only issues were found: source-contract assertions still expected mutation logic inline in `chats.ts`. They were updated to prove both sides of the new boundary: the IPC handler must acquire a renderer owner and delegate, while the application service must retain the mutation and operation gates. No production issue remained after review. + +## Tests + +```text +npm run type-check +PASS + +npm run test:aiden-service-boundary +PASS — 9 tests + +npx tsx --test main/handlers/chat-create-params.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/services/chat-deletion-gate.test.ts main/services/chat-workspace-mutation-gate.test.ts main/services/workspace-mutation-gate.test.ts main/services/workspace-operation-registry.test.ts main/services/workspace-record-removal.test.ts main/services/workspace-schedule-restoration.test.ts +PASS — 43 tests + +npm run test +PASS — complete pretest, TypeScript/JavaScript, Telegram, native worktree-remover, and Rust computer-use lifecycle. One initial parallel Git-suite run reported a single transient failure; the isolated 97-test Git suite and the complete retained-log rerun both passed. + +npm run build +PASS + +npx playwright test tests/e2e/chat-shell-interactions.spec.ts --config=playwright.config.ts +PASS — 1 isolated Electron smoke test +``` + +Targeted ESLint and `git diff --check` also pass for the Phase 1 files. diff --git a/docs/testing/aiden-on-the-go/phase-10.md b/docs/testing/aiden-on-the-go/phase-10.md new file mode 100644 index 00000000..52980772 --- /dev/null +++ b/docs/testing/aiden-on-the-go/phase-10.md @@ -0,0 +1,25 @@ +# Aiden On The Go — Phase 10 evidence + +Date: 2026-08-19 +Status: Complete on the available desktop and physical-iPhone gates. + +The Electron main process and authenticated Aiden Remote API now use one shared scheduled-task application service. Mobile supports list, search/filter, detail, create, edit, remove, pause, resume, run now, preview, run history, and revision-checked global settings. Task definitions bind only to validated Aiden workspaces, configured providers/models, enabled MCP IDs, and current server-inventoried scripts. + +Remote DTOs omit provider fingerprints, resolved MCP bindings, chat IDs, credentials, raw script paths, and execution internals. Script selections are short-lived opaque claims bound to the paired device and selected workspace. The MCP inventory contains display names and IDs only. Stored run output is bounded and redacts local paths and token-like values before projection. + +Create/edit uses a final foreground review. Lifecycle edits carry revisions into per-task serialization, settings have a serialized revision gate, and concurrent desktop/mobile edits admit only one revision. Run-now is device-scoped and idempotent: one server-minted `runId` is carried into the scheduler's stored run, duplicate retries replay it, and scheduler-owned execution continues after the caller disconnects. + +The Swift app uses native `List`, `Form`, `Picker`, `Toggle`, `TextEditor`, sheets, alerts, and confirmation dialogs. Definitions, settings, and bounded run history have an installation-scoped, file-protected offline cache; offline mutations remain disabled. + +Verification: + +- TypeScript type-check and `git diff --check` pass. +- Scheduler tests pass 84/84. +- Shared application-service boundary tests pass 18/18. +- Aiden Remote tests pass 112/112 plus 4/4 LAN transport proofs. +- The complete workspace/subagent regression command passes 650/650 plus all native helper tests. +- The complete signed physical iPhone 13 Pro XCTest suite passes 50 tests with four expected environment-gated transport/Keychain skips. Its three Phase 10 tests cover canonical routes and preconditions, strict DTO rejection, stable run idempotency, safe inventory, and installation-scoped bounded offline cache. +- The signed app builds, installs, and launches successfully on the iPhone 13 Pro. +- No simulator was used and the iPhone 16 Pro Max was untouched. + +Physical-iPad and real-Tailscale acceptance were not claimed by this phase. Phase 12 later closes real-Tailscale pairing and authenticated workspace transport on the physical iPhone; physical-iPad acceptance remains open. diff --git a/docs/testing/aiden-on-the-go/phase-11.md b/docs/testing/aiden-on-the-go/phase-11.md new file mode 100644 index 00000000..c7644258 --- /dev/null +++ b/docs/testing/aiden-on-the-go/phase-11.md @@ -0,0 +1,27 @@ +# Aiden On The Go — Phase 11 evidence + +Date: 2026-08-19 +Status: Complete on the available signed-build and physical-iPhone gates. + +Phase 11 adds Aiden-native App Intents, deep-link routing, Live Activities, on-device composer dictation, and local assistant read-aloud without widening the authenticated remote-control boundary. + +App Intents use a bounded App Group cache containing only sanitized installation/workspace display names and stable IDs. Per-installation composite entity IDs prevent collisions. The intent process does not network or read Keychain; it returns current-SDK `OpenURLIntent` results for new chat, voice chat, or an exact cached workspace. Deep links reject credentials, ports, fragments, paths, duplicate or unknown query keys, missing values, and unsafe identifiers. The app switches only to a known paired installation, authenticates through the pinned client, and resolves the exact destination before navigating. Stale and revoked destinations fail closed. + +The signed Aiden Live Activity extension renders bounded app-provided state and never receives credentials or performs network requests. The app owns start/update/terminal transitions, marks activities stale while disconnected or backgrounded, reconciles persisted stream IDs only through an authenticated pinned client after relaunch, and ends activities on installation removal or revocation. Response excerpts are controlled by a local privacy toggle and default off. + +Composer voice starts only after an explicit user action, requests Speech and Microphone permission at that point, requires an on-device-capable recognizer with `requiresOnDeviceRecognition`, and fills the draft without sending. It stops before send and on disappearance. The shipping source list has no server STT, audio upload, voice-note, or hold-to-record path. Assistant read-aloud uses the local system speech synthesizer with bounded text. + +Verification: + +- `git diff --check` passes. +- The complete signed physical iPhone 13 Pro XCTest suite passed 56 tests with zero failures and four expected environment-gated transport/Keychain skips. +- After the final Live Activity reducer cleanup, the focused `AidenNativeIntegrationTests` suite passed 6/6 on the same physical phone in 0.008 seconds. It covers cache safety/bounds, sanitization, multi-installation isolation, strict stable-ID deep links, private bounded Live Activity state, on-device voice policy, privacy keys, and absence of a production ATS exception. +- A clean signed physical-device build succeeded. Xcode embedded and validated `AidenLiveActivityWidget.appex`, generated `Metadata.appintents`, and trained the three Aiden shortcut phrases. +- Bundle inspection confirms the Aiden display/bundle identity, microphone and speech descriptions, `NSSupportsLiveActivities`, the App Group entitlement, generated App Intent assets, embedded widget, and no `NSAppTransportSecurity` exception. +- The shipping Swift source list contains no deprecated `openAppWhenRun`, plain-HTTP literal, server API client, audio-upload, voice-note, or hold-to-record marker. +- The clean app installed and launched successfully on the physical iPhone 13 Pro (`iPhone14,2`). +- No simulator was used and the iPhone 16 Pro Max was untouched. + +Manual/environment acceptance still open: invoking the shortcuts through Siri/Shortcuts, exercising the live Speech/Microphone permission UI and actual dictation, directly observing a real server-streamed Live Activity in system UI, and physical-iPad behavior. Those scenarios are not claimed here and remain part of Phase 12 plus the existing Phase 6/8 acceptance gates. Phase 12 later closes real-Tailscale pairing, authenticated workspace transport, and the independently automated ActivityKit process-boundary reconciliation proof on the physical iPhone. + +Physical ActivityKit follow-up 2026-08-19: a signed iPhone 13 Pro test now performs a real local ActivityKit request, three rapid production-manager updates, stale rendering, immediate terminal cleanup, and absence from the active activity inventory. It exposed and fixed a race where `activity.content` could lag an awaited update, causing the next event to derive from stale rendered state. The manager now owns canonical per-Activity state on the MainActor and hydrates that state only when adopting a persisted activity. A second regression releases the original manager, creates a fresh manager in the same test host, adopts the system-persisted activity, makes the exact bearer-authenticated and protocol-versioned stream-status request through `AidenRemoteClient`, reconciles the activity, and removes it. A third opt-in physical proof builds once, creates a uniquely identified activity in one test-host process, confirms that host has exited, launches a distinct host against the already-installed destination artifacts so the app is not reinstalled, then authenticates, reconciles, and ends the same system-persisted activity. Its guarded runner validates that the CoreDevice UUID and Xcode UDID identify the same physical iOS device and has a best-effort cleanup phase. The focused native suite passes 11/11 and the complete physical target passes 70 tests: 65 pass, five configured live-network/Keychain proofs skip, and zero fail. This closes local request/update/end, fresh-manager adoption, and actual process-boundary reconciliation plumbing; direct system-UI observation of a real server-streamed activity remains manual. diff --git a/docs/testing/aiden-on-the-go/phase-12.md b/docs/testing/aiden-on-the-go/phase-12.md new file mode 100644 index 00000000..8789abca --- /dev/null +++ b/docs/testing/aiden-on-the-go/phase-12.md @@ -0,0 +1,162 @@ +# Aiden On The Go — Phase 12 evidence + +Date: 2026-08-19 +Status: In progress. All available automated, packaging, archive, and physical-iPhone gates pass; external and unavailable hardware gates remain open. + +## Desktop release gates + +- `npm run type-check` passed. +- `npm run lint` passed. +- The canonical `npm test` lifecycle passed, including its pretest matrix, Electron main tests, native Swift/Rust/helper tests, and registered suites. It was repeated successfully after registering and hardening the physical ActivityKit process-proof runner. +- `npm run build` passed. Vite emitted only its existing chunk-size advisory. +- `npm run type-check:e2e` passed. +- CI no longer points at the removed `ios/HermesMobile.xcodeproj` or requests an iOS Simulator. Its iOS gate now runs `build-for-testing` against `ios/AidenOnTheGo.xcodeproj`, the `AidenOnTheGo` scheme, and generic iOS hardware with signing disabled. The exact command passed locally without addressing or launching a simulator. +- `npm run test:aiden-remote` passed 119/119 production Remote Access tests plus 7/7 deterministic transport tests. The production-service integration proves that a fresh local pairing window issues a distinct credential after revocation, keeps the old credential at typed `403 credential_revoked`, and never serializes either raw credential. New Tailscale regressions cover first-listener creation from `{}`, exact HTTPS certificate-domain eligibility, canonical prefix restoration, conflict/Funnel preservation, cleanup-only migration of an exact persisted origin-only route, and a system-trusted loopback proof payload. +- The strengthened Remote Access lifecycle Playwright spec passed 1/1 and now proves the health endpoint is unreachable before Remote Access is enabled. +- `npm run package` and `npm run package:verify` passed for the hardened signed development package at `release/development/mac-arm64/Aiden Agent.app`. Packaging emitted the existing Foundation Models helper deprecation warning but completed successfully. +- A packaged-app listener-off smoke used isolated Aiden config and Electron user-data directories. The exact test process remained alive for five seconds, port `49220` never listened, deep code-sign verification passed, and only that test process was terminated. The already-running installed Aiden Agent was not modified or stopped. + +The TypeScript remote-protocol suite and the signed Swift `AidenRemotePhase0Tests` both consume the checked-in shared contract fixture. Their complete registered suites pass on their respective release gates. + +## Physical iPhone and iOS release gates + +- The complete signed iPhone 13 Pro suite now executes 70 tests: 65 pass, five live-environment tests are expected skips, and zero fail. This includes the final bounded-attachment, malformed-reference, server-restart, credential-revocation, installation-scoped re-pair replacement, unpaired App Intent handoff, real ActivityKit lifecycle, fresh-manager authenticated reconciliation, and opt-in actual process-boundary ActivityKit coverage. +- A separate environment-enabled test passed 1/1 on the physical iPhone 13 Pro over LAN HTTPS. It paired to the Mac's private-CA endpoint, uploaded and explicitly discarded attachment references, consumed a Markdown reference exactly once, rejected replay with `409 handle_invalid`, preserved path-free metadata in authoritative history, rejected a duplicate approval with `409 approval_expired`, and completed the reconnect/approval/cancel stream lifecycle. +- A second environment-enabled test passed 1/1 in 105.983 seconds on the same physical iPhone. After authenticated access, the Mac server process was stopped and replaced on the identical LAN address and port with the same private CA, leaf certificate, SPKI pin, instance ID, and digest-only persisted device authorization. A fresh pinned client reconnected while pairing remained closed; after durable revocation, another fresh client rejected the old credential with typed `403 credential_revoked`. An explicit local re-pair window then accepted an independent one-use secret, issued a distinct device credential, rejected secret replay with `401 pairing_closed`, restored authenticated access, and kept the old credential invalid. After one more same-identity process restart, the new credential still worked and the old credential still returned typed `403 credential_revoked`. Persisted state contains only the active credential SHA-256 digest and a bounded set of prior revoked SHA-256 digests; no raw credential is written. The deterministic transport test separately proves atomic digest replacement, owner-only state, marker cleanup, and the same restart durability. The ordinary 65-test regression suite passed afterward. +- A real Tailscale proof passed 1/1 in 1.611 seconds on the physical iPhone 13 Pro. Starting from empty Serve status, Aiden created only `/api/aiden/v1`, targeting the exact loopback API base so Tailscale's stripped mount prefix was restored. The phone used system certificate validation plus the live Tailscale leaf SPKI pin, reported `connectionMode=tailscale`, paired, authenticated, browsed an approved root, completed folderless/scratch/selected-folder workspace CRUD, rejected selection replay, and reconciled authoritative state. Route-specific disconnect returned Serve status to `{}`; Funnel and unrelated routes were untouched. The temporary backend, secret-bearing xctestrun, DerivedData, and logs were moved to Trash after the route and server stopped. +- The final focused `AidenNativeIntegrationTests` suite passed 11/11 on that same physical iPhone. It reads the installed test host's privacy manifest and third-party notice, verifies the copied Hermex MIT license, checks all required camera/local-network/microphone/speech/Live Activity declarations, confirms there is no production ATS exception, pins the canonical public Privacy Policy and Support HTTPS destinations, locks the neutral unpaired deep-link error presentation, performs an actual ActivityKit request/update/stale/end lifecycle with immediate cleanup, has a fresh manager adopt and reconcile the persisted activity through the authenticated production client, and keeps the opt-in process proof inert when its paired environment keys are absent. +- A real cold custom-URL launch used `aiden-otg://new-chat`, the same bounded handoff emitted by the New Chat App Intent. On the unpaired physical iPhone it launched the installed app, failed closed without creating or sending a chat, and displayed the actionable “Connect to Aiden Agent before opening this link” message. Physical screenshot review found and corrected the misleading pairing-specific alert title; the repeated cold launch displayed the neutral Aiden product title. Both temporary captures were moved to Trash. +- The first real ActivityKit lifecycle run exposed that `await Activity.update` does not guarantee the public `activity.content` snapshot is already current. Rapid tool, token, and stale events could therefore derive from an older rendered snapshot and overwrite newer semantic state. The production manager now keeps a MainActor-isolated canonical state per Activity ID, hydrates persisted activities during reconciliation, ignores ended/dismissed activities, and clears terminal cache entries. One physical regression sends tool → token → stale without waiting for rendered state, verifies the eventual state is responding/stale with response excerpts still off, then ends and removes the activity immediately. Another releases the original manager, instantiates a fresh manager in the same test host, adopts the system-persisted activity, verifies the exact authenticated `GET /api/aiden/v1/streams/:id` reconciliation request, and confirms the rendered status and immediate cleanup. The registered `ios:activitykit-process-proof` command goes further: it validates a matching physical CoreDevice/Xcode destination, builds once, starts the activity in one host, confirms that host is absent, switches the temporary `.xctestrun` to `UseDestinationArtifacts` so Xcode cannot reinstall the app, and passes authenticated adoption and cleanup in a distinct host process. Four registered Node assertions lock its physical-only destination and no-reinstall contract. The complete 70-test signed suite passed after this proof. +- A fresh test-free signed Debug app was built for the physical iPhone 13 Pro. It contains no XCTest plug-in, embeds and validates the Live Activity widget, generates/trains App Intent metadata, and includes the exact third-party notices used by the shipping target. +- That exact app passed deep code-sign verification, installed, and launched as `sbtbiswas.AidenOnTheGo` on the iPhone 13 Pro (`iPhone14,2`). +- The final bounded-attachment follow-up repeated that gate from a separate clean DerivedData directory: the test-free app contained no XCTest bundle, passed strict deep code-sign verification, installed, and launched on the same iPhone 13 Pro. +- A fresh generic-hardware Release archive succeeded at `/tmp/AidenOnTheGo-phase12-final-20260819.xcarchive`. Xcode performed its store-validation build step, embedded the widget, and generated App Intent metadata. +- The bounded-attachment follow-up produced a fresh generic-hardware Release archive at `/tmp/AidenOnTheGo-attachments-20260819.xcarchive`. Store validation and App Intent generation passed, the archive contains no XCTest bundle, and its app passes strict deep code-sign verification. +- Archive inspection confirms bundle `sbtbiswas.AidenOnTheGo`, marketing version `1.5`, build `1`, team `5WP229CBB8`, App Group `group.sbtbiswas.AidenOnTheGo`, no test bundle, privacy tracking disabled, no declared collected-data types, and complete third-party notices. The archived Hermex attribution is the upstream license bundled under `ThirdPartyNotices`; `ios/LICENSE` is Aiden On The Go's own MIT license. +- No simulator was used for Phase 12, and the iPhone 16 Pro Max was untouched. + +Both archives are signed with `Apple Development: Sambit Biswas (7EK65FX44E)` and have `get-task-allow=true`. They are therefore verified development archives, not App Store/TestFlight export candidates. Distribution signing and export remain open. + +## Direct product audit + +- Shipping Release Swift sources use only `/api/aiden/v1`; the imported Hermes product sources were removed from the repository rather than retained outside the target. +- The current locked Xcode package graph contains KeychainAccess 4.2.2 plus Hermex's MarkdownUI 2.4.1 rendering foundation and its NetworkImage 6.0.1 and swift-cmark 0.8.0 transitive dependencies. `Package.resolved`, the Xcode project, the bundled notice directory, and a registered source/package regression test agree on that exact graph. A clean generic-hardware Release compile and the focused physical-iPhone integration suite passed after the cleanup; no simulator was addressed or launched. +- Shipping localization has no Hermes, Hermex, Kanban, or Cloudflare product copy. +- The archive contains Aiden app icons for iPhone and iPad, the privacy manifest, ActivityKit/App Group entitlements, App Intent assets, Local Network/Bonjour/Speech/Microphone/Camera descriptions, and the licensed third-party notice folder. +- The imported Hermex implementation foundation is attributed in the bundled notice and its exact MIT license ships with the app. +- The shipping Aiden app menu now exposes the canonical public Privacy Policy and Support destinations through native `Link` actions. The previously configured GitHub privacy URL returned `404` and lived only in dead imported settings code; the active target now includes the shared link config, and the physical-iPhone test locks the corrected destinations. +- TestFlight instructions and the metadata draft now resolve developer/team name, feedback email, marketing/support/privacy URLs, and copyright from the Contact Sheet identity reference plus Aiden's live website/repository. Contact Sheet's product domain was not copied. The current public policy still needs mobile-specific copy before submission. +- Internal-only and external-capable App Store Connect export option plists plus manual upload workflows were adapted from Hermex with Aiden's project, bundle, and pinned team. Both workflows are main-only, confirmation-gated, protected by separate GitHub environments, and use pinned actions. The external path checks for a closed App Store version train before archiving and uploads only; tester assignment and Beta App Review submission remain manual. The build selector passes 20 tests/42 assertions, and three deterministic workflow/export-policy tests are registered in `npm test`. No workflow was dispatched and owner-only signing, app-record, credential, and approval gates remain intact. +- Current Apple category/privacy/age-rating definitions resolve the release draft to Developer Tools / Productivity, no developer-collected data, and a conservative 13+ override matching Aiden's published under-13 policy. The draft includes exact questionnaire reasoning and current physical-device screenshot dimensions. Four registered metadata/ASC safety tests lock public links, field limits, category, privacy manifest alignment, absence of telemetry SDKs, telemetry-off strict ASC commands, and credential ignores. +- A read-only Rork `asc 3.4.0` audit found the main Aiden bundle ID and App Groups capability under team `5WP229CBB8`, but no accessible App Store app record, widget bundle identifier, or Aiden distribution profile. Aiden's live website links to an active public **Aiden - Quick AI** TestFlight beta whose page describes the existing macOS product and says it is available on iOS; its numeric App ID and bundle ID are not public. This proves the zero-result query is a credential-scope result and makes record reconciliation with the correct Aiden profile mandatory before creation. The accessible team has one iOS Distribution certificate, while the Mac still lacks its Apple Distribution private-key identity. The default ASC profile is Parsely-named and the Apple web session is unauthenticated, so no external mutation or placeholder automation was created. +- A post-ActivityKit read-only refresh confirms that external state is unchanged: `asc auth status` still exposes only the default `Parsely ASC` Keychain profile, the exact `sbtbiswas.AidenOnTheGo` app query still returns zero visible App Store Connect records, filtered Developer Portal output contains the main universal identifier but not `sbtbiswas.AidenOnTheGo.LiveActivityWidget`, filtered iOS App Store profiles contain no Aiden entry, and `security find-identity` still reports no Apple Distribution identity. Telemetry was disabled, strict authentication was used for API queries, unrelated account records were filtered before output, and no mutation occurred. + +## Internal TestFlight evidence — 2026-08-19 + +- The owner subsequently created the distinct Aiden On The Go record (App ID `6803233275`), provisioned the widget identifier and App Store signing, and authorized the existing strict ASC profile for this release. +- The first `0.1.0` upload used build `1` and was rejected before processing with `ITMS-90717`; inspection confirmed that the compiled App Store icon source contained alpha. +- Build `2` copies the exact RayChat `AppIcon.icon` package and its opaque 1024×1024 RGB artwork. Registered release tests lock the package, artwork digest, dimensions, PNG color type, resource-phase integration, and matching asset-catalog fallback. The generic-iOS archive's compiled 120×120 iPhone and 152×152 iPad icons both report `hasAlpha: no`. +- The exported IPA is internal-only, has the exact app/widget identifiers and App Group, is signed by Apple Distribution team `5WP229CBB8`, has `get-task-allow=false` on both bundles, and passes strict deep code-sign verification. +- App Store Connect build `721aeb9d-2b33-4729-8d10-5bc1783abbef` processed as `VALID`, is assigned to the `Internal Testers` group, and reports `internalBuildState=IN_BETA_TESTING` / `externalBuildState=NOT_APPLICABLE`. The account holder is assigned to that group. No simulator was used. +- Build `3` fixes an iPhone navigation regression in which `NavigationSplitView` highlighted a workspace row without transitioning to its chats. Compact layouts now use an explicit value-driven `NavigationStack`; regular layouts retain split navigation and wrap the detail in its own stack. Creation, folder registration, deep-link handoff, deletion, and size-class transitions share reconciled selection/path behavior. +- The focused navigation/appearance suite passed 5/5 and the adjacent chat suite passed 9/9 on the physical iPhone 13 Pro. A test-free `0.1.0 (3)` Debug app was then built, installed, verified by `devicectl`, and launched on that same device. No simulator or iPhone 16 Pro Max was used. +- The build `3` internal-only IPA reports `TFInternalTestingOnly=true`, the exact app/widget/App Group identities, Apple Distribution team `5WP229CBB8`, and `get-task-allow=false`; strict deep signing verification passes and both compiled iPhone/iPad icons report no alpha. App Store Connect build `e5f0ae7e-35aa-451e-be87-bc039885b2de` processed as `VALID` and reports `internalBuildState=IN_BETA_TESTING` / `externalBuildState=NOT_APPLICABLE` for `Internal Testers`. +- Build `4` contains the Aiden-native onboarding/home/settings/composer refresh, privacy-safe Mac usage projection, accessible Berry repair, and complete dead imported-source cleanup. The full signed iPhone 13 Pro suite executed 73 tests: 68 passed, five environment-gated live proofs skipped, and zero failed. A separate clean `0.1.0 (4)` app contained no XCTest bundle, installed, and launched on that phone. Its locally exported internal-only IPA is Apple Distribution-signed for team `5WP229CBB8`, has exact app/widget/App Group identities and `get-task-allow=false`, contains no XCTest bundle, and passes strict deep signature verification. The telemetry-off strict `Parsely ASC` upload created exact build `ee5b7c23-14d6-44e7-a4ba-6a5003018758`; Apple reports `VALID`, `internalBuildState=IN_BETA_TESTING`, and `externalBuildState=NOT_APPLICABLE` after assignment to `Internal Testers`. No simulator or iPhone 16 Pro Max was used. +- A post-upload cold-launch check showed build `4` could request workspaces/server while still connecting, then fail to retry the home aggregate after the coordinator became connected. Build `5` keys that load to the connection-state transition; an installed clean build on the iPhone 13 Pro then produced successful Mac-side `chats`, `scheduledTasks`, and `usage` requests, including `/usage` status 200. The complete 73-test physical suite remained green, the shipping-shell regression locks the connected-state trigger, and the internal-only Apple Distribution IPA repeated all identity, entitlement, no-test-bundle, and signature gates. Exact App Store Connect build `6173d5e2-0e58-4d0a-92fa-fc804fc82c37` is `VALID`, `IN_BETA_TESTING` for `Internal Testers`, and external state is `NOT_APPLICABLE`. +- A fresh build `6` audit against the retained Hermex reference corrected the remaining locally actionable shell mismatches: New Agent creates a managed scratch workspace, cold navigation handoffs wait for a connected coordinator without self-cancelling, handoff chats expose a native Close action, and the Live Activity extension embeds and tints the Aiden sidebar logo for starting/thinking rather than using brain/sparkle glyphs. The registered shipping test locks those contracts. The full physical iPhone 13 Pro suite executed 73 tests with 68 passes, five expected live-environment skips, and zero failures; release policy passes 20 Ruby tests/42 assertions plus 23 Node tests; Remote Access passes 120 production plus seven transport tests. The local internal-only IPA reports `0.1.0 (6)`, exact app/widget/App Group identities, `TFInternalTestingOnly=true`, Apple Distribution team `5WP229CBB8`, `get-task-allow=false`, no XCTest content, the widget Aiden logo resource, and a valid strict deep signature. App Store Connect build `aa994233-1bf3-4482-86f5-b8b0356eee25` is `VALID`, assigned to `Internal Testers`, `IN_BETA_TESTING`, and externally `NOT_APPLICABLE`. No simulator or iPhone 16 Pro Max was used. +- The owner clarified that the `Parsely ASC` profile may be used for Aiden On The Go within the resources it can access. Future commands must still select it explicitly, remain scoped to exact Aiden identifiers, and must not treat the profile's zero visible app records as permission to duplicate the existing public Aiden beta. +- A registered `ios:asc-monitor` wrapper now gives future Codex automations a tested read-only entry point. It requires an explicit named Aiden profile and exact App/build resource IDs, disables ASC telemetry, enforces strict auth, scopes build processing to `builds info --build-id`, and emits privacy-safe TestFlight change summaries without tester identity, feedback text, screenshots, or crash content. Six focused monitor tests join the seven metadata/workflow policy tests; no monitor was dispatched without an authoritative target. +- Build `8` contains the Mac-owned model visibility contract, bounded custom provider PNG/SVG normalization and iOS sync, corrected thinking-model menus, waveform-only dictation status, and the floating home action. Two fresh GPT-5.6 Terra xhigh reviews covered the iOS interaction and cross-platform remote/default contracts. Type-check, lint, focused config/model/remote/release suites, and the physical iPhone 13 Pro suite pass; the device suite executed 90 tests with 85 passes, five expected environment skips, and zero failures. The internal-only export resolved Apple Distribution team `5WP229CBB8`, App Store profiles with `get-task-allow=false`, exact app/widget/App Group identities, and uploaded with `testFlightInternalTestingOnly=true`. Exact build `b0d3c1c0-ab61-469f-9e4f-9dd250e1ff1a` is `VALID`, assigned to `Internal Testers`, `IN_BETA_TESTING`, and externally `NOT_APPLICABLE`. No simulator, iPhone 16 Pro Max, external tester group, Beta App Review, or App Review path was used. +- Build `9` adds first-class Concentrate through Pi's native Responses API path, a theme-aware Concentrate provider identity on Mac and iOS, the “Your Activity” heading without the Aiden ghost, a full-width exact Total Tokens card, and bounded reconciliation of Apple Foundation Models-generated chat titles into iOS navigation/home/workspace state. Type-check, lint, build, Remote Access 126/126 plus seven LAN-transport tests, release policy, and the physical iPhone 13 Pro suite pass; the device suite executed 91 tests with 86 passes, five expected environment skips, and zero failures. The internal-only export preserved the exact app/widget/App Group identities, Apple Distribution signing, `get-task-allow=false`, no XCTest content, and `testFlightInternalTestingOnly=true`. Exact build `7ed5d771-0ebe-4e38-b9e9-fab9c564794f` is `VALID`, assigned to `Internal Testers`, `IN_BETA_TESTING`, and externally `NOT_APPLICABLE`. No simulator, iPhone 16 Pro Max, external tester group, Beta App Review, or App Review path was used. +- Canonical ASC `en-US` localization files now exist for the shipping `1.5` draft. The stable CLI's offline validator scans both files with zero errors and zero warnings; registered tests keep their name, subtitle, URLs, keywords, field limits, description, and intentionally omitted optional keys aligned with the reviewed metadata document. A separate mobile privacy/support patch is ready for owner/legal review and covers direct LAN/Tailscale pairing, Keychain/cache behavior, attachments/provider forwarding, Local Network/Camera/Photos/Files/on-device Speech permissions, App Intents, Live Activities, external media requests, and a visible support email. The website lives outside this repository, so publication remains an explicit external gate. +- `xcrun devicectl` captured the shipping app's pairing screen from the physical iPhone 13 Pro at its native `1170 × 2532` resolution. An opaque JPEG conversion passed local `asc screenshots validate` for the matching display type. This proves the physical screenshot pipeline but is not retained as a final store asset because it is not the final distribution candidate and the required physical-iPad set remains unavailable. + +## Markdown and attachment correction — 2026-08-20 + +Aiden already pins the same MarkdownUI 2.4.1 foundation used by Hermex, so no package change was needed. The renderer no longer applies its own root width/fixed-size pair; the assistant row supplies full transcript width, and a physical-device pixel regression proves the first painted glyph stays within its bounds. The `+` menu now follows Hermex's UIKit-menu/state-driven external presentation for Photos and Files instead of nesting `PhotosPicker` in `Menu`. Turn construction captures uploaded attachment IDs before hopeful composer clearing; a focused regression and registered source guard prevent those IDs from being silently dropped. + +Shipping source checks pass 6/6. The focused chat suite passes 16/16, and the complete physical iPhone 13 Pro suite reports 80 total, 75 passed, five expected live-environment skips, and zero failed. No simulator, archive, ASC mutation, or TestFlight upload was used. + +## Home glass navigation correction — 2026-08-20 + +The provided CustomTB sample was used as an interaction reference for the prominent New Agent action: the button now owns an anchored popover that stays a popover on compact iPhone layouts and exposes the existing-workspace, reusable-workspace, and managed-scratch choices with concise explanations. The former confirmation dialog was removed, and presentation handoff yields after popover dismissal before opening a picker, alert, or starting scratch creation. Hermex's session-list chrome was used as the glass reference: the search/settings capsule now selects native interactive Liquid Glass on iOS 26+, ultra-thin material on older systems, and an opaque palette-raised fallback when Reduce Transparency is enabled. + +The registered shipping-source suite passes 6/6 and locks both native glass and compact-popover adaptation. The focused physical-iPhone chat suite passes 17/17. The iOS release suite passes 20 Ruby tests/42 assertions plus 23 Node tests, and the complete signed physical iPhone 13 Pro suite reports 81 total, 76 passed, five expected live-environment skips, and zero failed. No simulator, archive, App Store Connect mutation, or TestFlight upload was used. + +## Compact approval correction — 2026-08-20 + +The iOS live approval card now follows the Aiden Agent Mac approval hierarchy: a 32-point warning badge contains the 15-point `shield` SF Symbol; the title uses small-semibold emphasis; helper and monospaced summary copy use caption sizing; and compact `Deny` / `Allow once` controls align to the trailing edge. Native iOS 26 interactive Liquid Glass renders both actions, with the allow action using the active Aiden accent; older systems and Reduce Transparency keep material or opaque themed fallbacks. Each visible capsule is 34 points tall with enough outer padding to retain a 44-point touch target. + +The registered shell checks pass 6/6 and reject the previous hand icon, destructive-role button, and oversized hierarchy. The iOS release suite passes 20 Ruby tests/42 assertions plus 23 Node tests. The complete signed physical iPhone 13 Pro suite remains green at 81 total: 76 passed, five expected live-environment skips, and zero failed. No simulator, archive, App Store Connect mutation, or TestFlight upload was used. + +## Device-only workspace archive — 2026-08-20 + +Hermex's session interaction was re-read before implementation. Aiden workspace rows now use the same safe interaction shape: a context menu plus leading/trailing swipe actions with `allowsFullSwipe: false`. Rename and safe unregister continue through Aiden's revision-checked hopeful coordinator paths; unregister confirmation explicitly says the registry entry disappears from Aiden Agent and paired clients while the folder/files remain untouched on the Mac. Managed scratch records do not expose generic unregister because the desktop contract intentionally requires managed-worktree recovery authority. + +Archive is a phone/tablet-only projection keyed by paired installation. The first confirmed use displays the device-only scope; subsequent archive/unarchive actions are immediate. Archived workspaces have a searchable directory and are removed from home chats, New Agent's existing-workspace picker, adaptive selection/path state, cold/warm deep-link resolution, and the App Intent workspace cache. The archive store prunes only after a connected authoritative workspace refresh so temporary offline/empty states cannot erase local preferences. + +Fresh-memory iOS, persistence, and server reviews were completed after implementation. Their concrete findings were fixed: authoritative snapshots—not optimistic row changes—own pruning; empty registries prune correctly; loads and mutations are bound to the active installation generation; archived rows require unarchive before chat navigation; persisted archives filter App Intents before the shell mounts; the sole workspace cannot expose ineffective removal; and conflicts/ambiguous completions reconcile from the Mac without falsely marking a healthy server offline. Failed quick renames retain their draft. + +The signed iPhoneOS build-for-testing succeeds. Eleven focused tests pass on the physical iPhone 13 Pro, including first-use acknowledgement, relaunch persistence, installation isolation, unarchive, rejected-removal rollback, authoritative-empty pruning, reversed installation-load completion, and canonical CRUD reconciliation. The focused server suite passes 16/16, including a real-file assertion proving unregister leaves Mac content intact. No simulator or iPhone 16 Pro Max was addressed, and no archive, App Store Connect mutation, or TestFlight upload occurred. + +## Usage dashboard and home continuity — 2026-08-20 + +The iOS usage DTO now retains the canonical daily and per-model aggregates returned by the privacy-safe Mac endpoint. The dashboard derives active days, current/longest activity streaks, a fixed 30-day heatmap, completion/local-use ratios, token categories, and top models from that data while retaining Aiden palette and accessibility semantics. Missing server capabilities are not represented with placeholder statistics. + +The home list's former transparent tail row was replaced with a palette-backed bottom safe-area inset, so the New Agent control keeps appropriate scroll clearance without producing a white band after the last chat. The registered shipping-source checks pass 6/6. A signed build-for-testing succeeds for the physical iPhone 13 Pro, and the focused usage test passes on that device. No simulator, iPhone 16 Pro Max, archive, ASC mutation, or TestFlight upload was used. + +## Provider identity and reply copying — 2026-08-20 + +The iOS asset catalog contains the complete 40-logo provider inventory from Aiden Agent, and the shared resolver retains the desktop alias, model-specific, local-provider, multicolor, and fallback rules. Usage, chat model selection, and scheduled-task provider/model selection now show the same provider identity without adding network-backed artwork. + +Completed and in-flight assistant replies expose a long-press Copy action and accessibility action. In keeping with Hermex, the copied value is the original Markdown source. A requested GPT-5.6 Terra xhigh review found and closed missing scheduled-task icons, missing streaming Copy, and inaccurate desktop-only notice language. The signed iPhoneOS build-for-testing succeeds, the registered shipping/asset suite passes 7/7, and 23 focused chat, scheduled-task, and usage tests pass on the connected physical iPhone 13 Pro. No simulator, iPhone 16 Pro Max, archive, App Store Connect mutation, or TestFlight upload was used. + +## Internal TestFlight build 10 — 2026-08-21 + +The accepted multi-instance hardening source was archived as version `0.1.0 (10)` using generic iOS hardware, exported with the checked-in internal-only policy, and uploaded through the explicitly selected telemetry-disabled App Store Connect profile. Exact build `426041ab-8638-4b6a-9d10-59ab2ee5b79b` processed as `VALID`, is assigned to `Internal Testers` (`3f90ffa7-29bb-429e-80a4-88422eb85b6d`), reports `IN_BETA_TESTING`, and remains `NOT_APPLICABLE` externally. No simulator, external group, Beta App Review, App Review, metadata, pricing, screenshot, or availability mutation was used. + +The current Mac dev app was restarted from this worktree with the existing `Aiden Agent Dev` data root. It retained the same stable instance, existing device credentials, connection mode, and LAN port `49220`, so paired Aiden On The Go clients can reconnect without a new QR. + +## Plan completion audit + +The current source, registered test commands, signed artifacts, and phase evidence were re-audited against every phase acceptance statement in `docs/plans/aiden-on-the-go-plan.md`: + +| Phases | Implementation result | Remaining acceptance | +| --- | --- | --- | +| 0–4 | Complete and covered by the protocol, service-boundary, real-HTTP, lifecycle, package, and signed transport evidence in the corresponding phase files. | None. | +| 5 | Shipping target contains the Aiden identity and launches on the physical iPhone. | Physical-iPad launch. | +| 6 | Pairing, trust, installation switching/revocation/re-pair replacement, approved-root browsing, selected-folder registration, and workspace CRUD are implemented; LAN and real Tailscale are proven on the physical iPhone. | Repeat LAN and Tailscale acceptance on a physical iPad. | +| 7 | Complete, including bounded attachments, replay-safe turns, authoritative reconciliation, duplicate-approval rejection, and physical-iPhone LAN streaming. | None specific to Phase 7. | +| 8 | Aiden appearance parity, adaptive navigation, accessibility contracts, and physical-iPhone regression coverage pass. | Physical-iPad visual/accessibility/keyboard/rotation/split-view/Stage Manager matrix. | +| 9–10 | Files/Git and Scheduled Tasks are implemented through shared desktop services and native clients with strict DTO, mutation, cache, and idempotency tests. | Their physical-iPad cross-network coverage follows the Phase 6/8 gates. | +| 11 | App Intents, the signed Live Activity widget, on-device dictation, local read-aloud, real ActivityKit lifecycle, fresh-manager adoption, and authenticated reconciliation across distinct physical-device app-host processes are implemented and tested. | Manual Siri/Shortcuts, live permission/dictation, and direct system-UI observation of a real server-streamed Live Activity. | +| 12 | All available automated, package, archive, release-policy, physical-iPhone LAN/Tailscale, attachment, duplicate-approval, same-endpoint process-restart, durable credential-revocation, and credential-rotation/re-pair gates pass. | The external and manual gates listed below. | + +This audit found no additional locally actionable missing product surface. The remaining items require unavailable hardware, physical network transitions, direct system-UI observation, a second physical client, or release-owner credentials and metadata. + +## Gates still open + +- Physical-iPad LAN/Tailscale pairing, workspace acceptance, visual, accessibility, keyboard, rotation, split-view, and Stage Manager matrix. +- Physical sleep/wake, address change, and multi-device concurrency acceptance. +- Manual Siri/Shortcuts invocation, live microphone/speech permission UI and dictation, and direct system-UI observation of a real server-streamed Live Activity. +- Mobile-specific privacy-policy publication, visible support contact, owner publication of the drafted age/privacy answers, final physical-device screenshots, review phone/environment, remaining metadata approval, external TestFlight/Beta App Review, and public-release decisions. + +Phase 12 and the overall plan must remain active until those gates are completed or deliberately re-scoped by the owner. + +## First-class iOS pairing choices — 2026-08-22 + +The iOS pairing landing page now mirrors Aiden Agent's complete Add Device choice set: recommended QR for either Mac-selected endpoint, Nearby Mac + Setup Code for local Wi-Fi, Private Address + Setup Code for Tailscale, and an advanced full-payload paste fallback. The native contract test verifies that every method remains represented with the correct Local Network/Tailscale identity; the registered source gate also requires the canonical `.local:49220/api/aiden/v1` and `.tailnet.ts.net/api/aiden/v1` examples and rejects the prior segmented method picker. + +Validation passed with `npm run test:aiden-remote` (216 production Remote Access tests with one explicit skip, plus seven transport proofs), `npm run test:ios-release` (20 Ruby tests/42 assertions and 24 Node tests), `npm run test`, `npm run type-check`, `npm run lint`, `npm run build`, and the generic unsigned iPhoneOS test build. The complete Apple Development-signed XCTest target also passed on the connected physical iPhone 13 Pro, including `testPairingMethodsMirrorEveryMacConnectionChoice`; only explicit live-environment tests without an injected pairing payload skipped. No simulator or iPhone 16 Pro Max was used. + +## Internal TestFlight build 11 — 2026-08-22 + +Version `0.1.0 (11)` was archived for generic iOS hardware and exported with the checked-in internal-only policy. Xcode reported `Upload succeeded`; exact App Store Connect build `7c372144-6f64-46cb-880f-2ad92043198d` processed as `VALID`, was explicitly assigned to `Internal Testers` (`3f90ffa7-29bb-429e-80a4-88422eb85b6d`), reports `IN_BETA_TESTING`, and remains `NOT_APPLICABLE` externally. No simulator, external group, Beta App Review, App Review, metadata, pricing, screenshot, or availability mutation was used. + +## Internal TestFlight build 13 — 2026-08-22 + +Version `0.1.0 (13)` contains the complete bidirectional image/outcome pipeline, sender-anchored image deck, adaptive onboarding, and the follow-up fitted-image corner-mask correction. The iOS release-policy suite passes, and the complete signed XCTest target on the physical iPhone 13 Pro executed 128 tests with 123 passes, five explicit environment-only skips, and zero failures. Xcode reported `Upload succeeded`; exact App Store Connect build `c5988f33-3184-4e04-8205-9c4505cfc894` processed as `VALID`, was explicitly assigned to `Internal Testers` (`3f90ffa7-29bb-429e-80a4-88422eb85b6d`), reports `IN_BETA_TESTING`, and remains `NOT_APPLICABLE` externally. Superseded build `12` (`588fef66-7ecc-4b6d-b04a-3539da52fad0`) processed as `VALID` but was not assigned to testers. No simulator, external group, Beta App Review, App Review, metadata, pricing, screenshot, or availability mutation was used. + +## Internal TestFlight build 15 — 2026-08-22 + +Version `0.1.0 (15)` contains bounded typed activity timelines on iOS, Mac-aligned tool verbs and summaries, progressive activity disclosure, and Reduce Motion-aware shimmer/fade transitions. It retains build `14`'s reliable approval recovery and attended Mac-to-mobile image sharing. Type-check, lint, 45 focused Remote Access tests, the iOS release-policy suite (20 Ruby tests/42 assertions and 25 Node tests), a signed generic-iOS archive, and 72 focused tests on the physical iPhone 13 Pro passed with two expected environment-only skips and zero failures. Xcode reported `Upload succeeded`; exact App Store Connect build `0185ac70-617f-47d2-b959-d17f13e9a9d2` processed as `VALID`, was explicitly assigned to `Internal Testers` (`3f90ffa7-29bb-429e-80a4-88422eb85b6d`), reports `IN_BETA_TESTING`, and remains `NOT_APPLICABLE` externally. No simulator, external group, Beta App Review, App Review, metadata, pricing, screenshot, or availability mutation was used. diff --git a/docs/testing/aiden-on-the-go/phase-2.md b/docs/testing/aiden-on-the-go/phase-2.md new file mode 100644 index 00000000..fda6b916 --- /dev/null +++ b/docs/testing/aiden-on-the-go/phase-2.md @@ -0,0 +1,52 @@ +# Aiden On The Go — Phase 2 evidence + +Date: 2026-08-19 +Status: Complete — Remote Access is an explicit, off-by-default desktop capability with authenticated LAN/Tailscale transports, pairing, revocation, approved roots, Settings, onboarding, and documentation. + +## Implementation boundary + +- `AidenRemoteService` owns the app-lifetime listeners. Disabled startup creates no TLS identity, listener, Bonjour advertisement, or Tailscale route. LAN uses dual-stack HTTPS and `_aiden-agent._tcp`; Tailscale fronts a loopback-only HTTP listener at the exact `/api/aiden/v1` route. +- Installation-local P-256 CA/server identities are persisted owner-only. LAN pairing carries the private CA certificate plus the leaf SPKI pin; Tailscale pairing verifies and pins the actual system-trusted peer identity. +- Pairing opens only after a local desktop action, expires after five minutes, consumes a 256-bit secret once, stores only credential digests, and supports durable per-device revocation and capability checks. +- The HTTP router uses exact paths, bounded duplicate-key-safe JSON, strict protocol and bearer headers, no CORS/browser origin, safe error envelopes, request IDs, connection/time limits, and redacted logging. Health is the only unauthenticated endpoint. +- Tailscale Connect/Disconnect previews and owns only Aiden's exact non-Funnel Serve route. It detects conflicts, verifies the installed route, never runs `serve reset`, and preserves unrelated routes. +- Settings exposes enable/mode, endpoints, route preview, Connect/Disconnect, QR pairing, approved roots, paired devices, and revocation using existing Aiden primitives and semantic tokens. The onboarding tour includes a dedicated transparent 1024×1024 Aiden On The Go illustration. + +## Review outcome + +The between-phase review was performed locally, per the user's direction to use no subagents. It checked disabled-start side effects, TLS trust semantics, IPv4/IPv6 reachability, one-time pairing, route ownership/cleanup, authentication boundaries, QR secrecy, renderer IPC validation, window-close lifetime, quit cleanup, onboarding accessibility, and documentation. + +Review fixes included dual-stack LAN binding for Bonjour/IPv6, a versioned IPC-only QR trust envelope for the private LAN CA, targeted ESLint globals for transport spike scripts, and updates to source-contract tests after the Phase 1 application-service extraction. No production failure remains at the Phase 2 gate. + +## Tests + +```text +npm run test:aiden-remote +PASS — 76 TypeScript tests plus 4 transport spike tests + +npm run test:onboarding +PASS — 18 tests, including tile/asset/alpha contracts + +npm run type-check +PASS + +npm run type-check:e2e +PASS + +npm run lint +PASS + +npm run build +PASS + +npx playwright test tests/e2e/remote-access-lifecycle.spec.ts --config=playwright.config.ts +PASS — off by default, enable, authenticated HTTPS health, service survives main-window close + +npx playwright test tests/e2e/settings-model-picker.spec.ts --config=playwright.config.ts +PASS — Remote Access destination renders Off and unchecked by default + +npm test +PASS — complete TypeScript/JavaScript, native helper, Git safety, and Rust lifecycle gate +``` + +`git diff --check` also passes for the completed Phase 2 state. diff --git a/docs/testing/aiden-on-the-go/phase-3.md b/docs/testing/aiden-on-the-go/phase-3.md new file mode 100644 index 00000000..f7405994 --- /dev/null +++ b/docs/testing/aiden-on-the-go/phase-3.md @@ -0,0 +1,47 @@ +# Aiden On The Go — Phase 3 evidence + +Date: 2026-08-19 +Status: Complete — The authenticated remote API supports safe workspace-registry CRUD and approved-root folder selection without exposing or accepting Mac paths. + +## Implementation boundary + +- Workspace list/get/create/update/remove routes use allowlisted path-free DTOs, strict request schemas, scoped durable idempotency, exact revision preconditions, the shared workspace mutation registry, default-workspace recovery, and immediate Electron invalidation. +- Folderless and managed-scratch creation are supported. Folder-backed creation accepts only a short-lived one-use selection token issued by the server-controlled browser; it never accepts an absolute or relative client path. +- Approved-root navigation is directory-only, nonrecursive, deterministic, paginated, depth-bounded, and excludes hidden/system entries and symlinks. Location handles, cursors, and selections are opaque and bound to the instance, device, root policy, canonical directory identity, and expiry. +- Registration revalidates root approval, policy revision, real path, directory device/inode identity, and duplicate state inside the serialized workspace commit. Removing a workspace never deletes its folder, while generic deletion refuses managed worktrees. + +## Review outcome + +The between-phase review was performed locally, following the user's direction not to use subagents. It checked DTO secrecy, parser aliases, cursor isolation, replay, expiry, symlink and root replacement, root-policy removal, selected-folder TOCTOU, stale revisions, deletion side effects, idempotency restart behavior, operation snapshot secrecy, desktop reconciliation, and disabled-start side effects. + +Review fixes moved revision checks and directory revalidation inside authoritative mutation leases, prevented stale removal requests from closing a workspace terminal, sanitized control characters from browser labels, and made the idempotency store persist the in-flight admission before mutation and terminal response afterward. No production failure remains at the Phase 3 gate. + +## Tests + +```text +npm run test:aiden-remote +PASS — 88 TypeScript tests plus 4 transport spike tests + +npm run test:aiden-service-boundary +PASS — 12 tests + +npx playwright test tests/e2e/remote-access-lifecycle.spec.ts --config=playwright.config.ts +PASS — 1 test + +npm run lint +PASS + +npm run type-check +PASS + +npm run type-check:e2e +PASS + +npm run build +PASS + +npm test +PASS — complete TypeScript/JavaScript, native helper, Git safety, and Rust lifecycle gate +``` + +The real HTTP integration test browses an approved root, exchanges the opaque directory handle for a selection, creates a workspace, patches it with its revision, removes it, verifies default-workspace preservation, and asserts that no raw path appears in the response. `git diff --check` also passes for the completed Phase 3 state. diff --git a/docs/testing/aiden-on-the-go/phase-4.md b/docs/testing/aiden-on-the-go/phase-4.md new file mode 100644 index 00000000..93c0ef0f --- /dev/null +++ b/docs/testing/aiden-on-the-go/phase-4.md @@ -0,0 +1,48 @@ +# Aiden On The Go — Phase 4 evidence + +Date: 2026-08-19 +Status: Complete — The authenticated remote API supports chat CRUD, atomic remote turns, resumable server-owned generation streams, cancellation, approvals, and a privacy-safe provider/model catalog. + +## Implementation boundary + +- Chat list/get/create/rename/delete and empty-chat move use path-free DTOs, exact parsers, scoped durable idempotency, and revision assertions inside the shared serialized chat mutation boundary. Desktop and remote clients receive immediate chat invalidation after mutations. +- Remote turn creation commits one user message through the existing turn-admission lease, then transfers generation to a stable device-and-stream-bound owner. A setup failure after the append returns the accepted message plus a terminal error stream; an indeterminate append remains durably in flight rather than becoming retryable. +- The bounded SSE journal records typed status, text, reasoning, tool, timeline, approval, terminal, error, cancellation, and reconciliation events with monotonic sequence IDs. Reconnect replays from `Last-Event-ID`; future cursors fail closed and pruned gaps receive an authoritative snapshot. +- Disconnecting delivery does not cancel main-owned work. Explicit cancellation, approval expiry, device revocation, restart interruption, journal retention, and persistence settlement all preserve per-device ownership and terminal reconciliation. +- Provider/model projection includes only configured chat-capable choices and contains no credentials, endpoint URLs, authentication methods, or embedding-only entries. Remote turns cannot enable Computer Use and retain the workspace's existing permission/tool contract. + +## Review outcome + +The between-phase review was performed locally, following the user's direction not to use subagents. It covered cross-device stream and approval isolation, duplicate sends, stale revisions, post-append setup failures, unknown append outcomes, reconnect gaps, cancellation and approval replay, approval expiry, device revocation, restart recovery, model secrecy, desktop cache refresh, disabled-start side effects, and deletion ordering. + +Review fixes added automatic expiry denial for unattended approvals, coalesced stream-journal persistence, graceful quit settlement, a pre-side-effect delete revision check plus an authoritative final check, and compatibility preservation for removing an indexed chat whose payload is corrupt. No production failure remains at the Phase 4 gate. + +## Tests + +```text +npm run test:aiden-remote +PASS — 102 TypeScript tests plus 4 transport spike tests + +npm run test:aiden-service-boundary +PASS — 13 tests + +npx playwright test tests/e2e/remote-access-lifecycle.spec.ts --config=playwright.config.ts --fail-on-flaky-tests +PASS — 1 test + +npm run type-check +PASS + +npm run type-check:e2e +PASS + +npm run lint -- --no-fix +PASS + +npm run build +PASS + +npm test +PASS — complete TypeScript/JavaScript, native helper, Git safety, and Rust lifecycle gate +``` + +The real HTTP integration test starts one mocked remote turn, reconnects and replays its SSE journal without duplicating the prompt, allows and denies device-owned approvals, cancels a turn, and verifies the final append count. A signed Debug build with the approved Aiden identifiers was installed and launched on the physical iPhone 13 Pro; the iPhone 16 Pro Max and all simulators were untouched. `git diff --check` also passes for the completed Phase 4 state. diff --git a/docs/testing/aiden-on-the-go/phase-5.md b/docs/testing/aiden-on-the-go/phase-5.md new file mode 100644 index 00000000..100dd9fa --- /dev/null +++ b/docs/testing/aiden-on-the-go/phase-5.md @@ -0,0 +1,10 @@ +# Aiden On The Go — Phase 5 evidence + +Date: 2026-08-19 +Status: Implementation complete; physical-iPad launch acceptance remains open. + +The shipping target now compiles only the Aiden application shell, remote transport, pairing, workspace, chat, cache, Keychain, and shared Aiden contract sources. Retained Hermex files remain available in repository history/imported source for later deliberate adaptation, but are not members of the Aiden target. Built-product identity assertions reject Hermes, Hermex, Kanban, Cloudflare, and legacy bundle/service identifiers. + +A registered source-membership regression test now locks the exact app, test, and Live Activity widget source allowlists. It also scans only shipping Swift sources for imported product identity and legacy `/api/*` routes, and locks the shipping route family to `/api/aiden/v1`. + +Automatic signing uses team `5WP229CBB8`, bundle identifier `sbtbiswas.AidenOnTheGo`, the Aiden Keychain service, and the approved URL/app-group namespace. The signed app builds, installs, launches, and runs tests on the physical iPhone 13 Pro. No simulator was used and the iPhone 16 Pro Max was untouched. A physical iPad was not connected, so the plan's physical-iPad launch criterion is recorded as open rather than inferred from compilation. diff --git a/docs/testing/aiden-on-the-go/phase-6.md b/docs/testing/aiden-on-the-go/phase-6.md new file mode 100644 index 00000000..1b6e3448 --- /dev/null +++ b/docs/testing/aiden-on-the-go/phase-6.md @@ -0,0 +1,20 @@ +# Aiden On The Go — Phase 6 evidence + +Date: 2026-08-19 +Status: LAN and Tailscale implementation verified on a physical iPhone; physical-iPad acceptance remains open. + +The Swift client supports canonical QR pairing, private-CA or system trust plus the exact P-256 leaf SPKI pin, per-installation Keychain credentials, multiple Aiden installations, switching/removal/revocation, workspace registry CRUD, managed scratch creation, and opaque approved-root folder exploration/selection. Legacy installation metadata without an explicit pairing trust policy fails closed and requires secure re-pairing. + +On the physical iPhone 13 Pro, a real Wi-Fi LAN harness proved QR exchange, authenticated server discovery, folderless create/update/delete, scratch create/delete, approved roots/children, selected-folder registration, one-use selection replay rejection, and authoritative final reconciliation. The strict certificate proof separately covers wrong pin, renewal, rotation, host mismatch, and expiry. No simulator was used and the iPhone 16 Pro Max was untouched. + +The same physical iPhone then passed 1/1 in 1.611 seconds through a real Aiden-owned Tailscale Serve route. It used the tailnet's system-trusted certificate plus the exact live SPKI pin, reported `connectionMode=tailscale`, paired, authenticated, browsed the approved root, completed folderless/scratch/selected-folder workspace CRUD, rejected selection replay, and reconciled to an empty authoritative workspace list. The proof began from an empty Serve configuration and exposed two defects that are now covered by regression tests: first-connect must derive HTTPS eligibility from the exact Tailscale certificate domain rather than requiring a pre-existing 443 listener, and the loopback proxy target must restore `/api/aiden/v1` because `--set-path` strips the public mount prefix. Cleanup removed only Aiden's exact path and returned `tailscale serve status --json` to `{}`. + +The remaining Phase 6 acceptance work is explicit: repeat LAN and Tailscale pairing, browsing, and workspace CRUD from a physical iPad. No simulator was used and the iPhone 16 Pro Max was untouched. + +Manual setup-code follow-up 2026-08-21: + +- The Mac now offers a uniformly random 100-bit Crockford setup code beside the QR. The code never crosses the network; iOS derives the envelope key locally with HKDF-SHA256, authenticates/decrypts AES-256-GCM, binds the selected exact endpoint, then reuses the existing certificate-pinned one-use exchange. +- Pairing issuance is fenced inside the durable server mutation. iOS authenticates the staged Mac before promotion, uses versioned per-device Keychain scopes, retains the previous working installation on every pre-promotion failure, and stream-bounds the unauthenticated bootstrap response. +- Three fresh-memory reviews were completed and all accepted server-race, Keychain atomicity, cancellation, canonical-input, Tailscale-address, expiry/regeneration, accessibility, contract-revision, and cross-runtime-vector findings were repaired. +- The final registered Remote Access gate passes 216 tests plus seven deterministic transport proofs. Type-check, lint, release-policy, and diff checks pass. The complete connected physical iPhone 13 Pro target passes 101 tests with five expected environment-gated live proofs skipped and zero failures. No simulator was used. +- Hands-on setup-code entry through the live LAN and Tailscale UI is still an explicit manual acceptance item, as is physical-iPad acceptance; neither is claimed as observed here. diff --git a/docs/testing/aiden-on-the-go/phase-7.md b/docs/testing/aiden-on-the-go/phase-7.md new file mode 100644 index 00000000..2b2257e9 --- /dev/null +++ b/docs/testing/aiden-on-the-go/phase-7.md @@ -0,0 +1,23 @@ +# Aiden On The Go — Phase 7 evidence + +Date: 2026-08-19 +Status: Complete. + +The Swift workspace shell now provides chat list/detail/create/rename/delete, native Aiden conversation presentation, provider/model/thinking selection, atomic text and attachment turns, reasoning, tool/timeline activity, inline approvals, stop, strict resumable SSE, terminal authoritative reconciliation, and an installation/chat-scoped protected cache. + +Attachment support is end to end rather than UI-only. The authenticated API accepts bounded JSON image or UTF-8 text uploads and returns short-lived, one-use references bound to the exact device and chat. A turn accepts at most ten unique references and atomically translates them into Aiden's existing canonical attachment model. References expire after ten minutes, are removed on discard, consumption, or device revocation, and never expose bytes or paths in remote message projections. Image admission is limited to PNG/JPEG, 8 MiB, 16,384 pixels per dimension, and 40 megapixels; text is MIME-allowlisted and limited to 400,000 UTF-8 bytes and 100,000 Unicode scalars. Store count and retained-byte ceilings bound aggregate memory. + +The native composer uses the retained system `Menu`, `PhotosPicker`, and file importer. Images are bounded before decode, dimension-checked, downscaled, and transcoded to JPEG. Text import reads only a bounded prefix, validates UTF-8, and visibly marks truncation. The client rejects malformed attachment references, allows attachment-only turns, and reuses the exact turn idempotency key after an ambiguous transport failure so a retry cannot duplicate the prompt or consume the attachment twice. + +Streaming stores the accepted `streamId`, `turnId`, and last applied sequence before consumption. Reconnect opens the existing stream after the durable cursor and never calls the turn endpoint again. Duplicate events are ignored, gaps trigger authoritative chat reconciliation, terminal events reload server history, and cached streams are restored through the status endpoint after app/view recreation. Raw SSE bytes preserve blank frame boundaries and enforce UTF-8 and frame limits before strict event decoding. + +Verification on the physical iPhone 13 Pro: + +- Signed build-for-testing succeeded. +- 64 XCTest cases executed on the iPhone 13 Pro: 59 passed, five environment-gated live proofs were expected skips, and zero failed. +- Native tests cover image transcode/dimension/byte limits, bounded text-file reads, metadata-only DTOs, fail-closed reference validation, canonical upload/discard routes, attachment-only turn projection, and exact-request idempotency-key reuse. +- The extended opted-in Phase 7 LAN test passed in 1.039 seconds. From the physical iPhone it paired through the canonical private-CA payload at the Mac's `192.168.1.228` LAN address, created/renamed a chat, uploaded a Markdown attachment, discarded a second reference through `DELETE`, consumed the first reference in a turn, verified metadata in the accepted and authoritative user message, and verified a second use failed with typed `409 handle_invalid`. It then received sequences 1–2, reconnected after sequence 2 for reasoning/tool/timeline/approval events 3–7, allowed the approval, verified a duplicate decision failed with typed `409 approval_expired`, reconciled terminal events 8–10 and the authoritative assistant message, cancelled a second turn, then removed the chat and workspace. +- Desktop service and real-HTTP tests cover valid image/text and attachment-only turns, one-use and device/chat binding, duplicate and count rejection, expiry/revocation/discard, capacity, dimension/name validation, retry replay, and path-free legacy metadata sanitization. +- Deterministic chat tests also cover canonical mutation preconditions, model/turn/cancel/approval contracts, duplicate-key and SSE identity rejection, frame bounds, cache isolation between installations, and stream-cursor restoration. + +The live LAN attachment acceptance gap is closed for the iPhone path: the signed native client performed upload, discard, one-use consumption, replay rejection, metadata reconciliation, streaming, approval, cancellation, and cleanup over HTTPS from the physical iPhone 13 Pro to the Mac. Tailscale was unavailable during this Phase 7 run; Phase 12 later closes real-Tailscale pairing and authenticated workspace transport on the same phone. No simulator was used and the iPhone 16 Pro Max was untouched. diff --git a/docs/testing/aiden-on-the-go/phase-8.md b/docs/testing/aiden-on-the-go/phase-8.md new file mode 100644 index 00000000..05bb2f49 --- /dev/null +++ b/docs/testing/aiden-on-the-go/phase-8.md @@ -0,0 +1,18 @@ +# Aiden On The Go — Phase 8 evidence + +Date: 2026-08-19 +Status: Implementation complete; physical-iPad acceptance remains open. + +The shipping Swift target now uses semantic Aiden palette tokens backed by a versioned fixture shared with Electron. It provides System, Light, and Dark modes; independent Aiden, Slate, Berry, and Moss selections for light and dark; System, Rounded, and Humanist UI fonts; SF Mono, Menlo, and Monaco code fonts; bounded UI/code sizing; per-scheme contrast and sidebar translucency; system/forced reduced-motion behavior; and a device-local Git diff-marker preference. No desktop appearance API exists or is mutated. + +Appearance is available both before pairing and from the workspace shell. Native controls retain their semantic disabled and secondary states, custom scrolling honors the resolved motion preference, code inputs use the selected scalable code font, and the composer switches its selector layout at accessibility Dynamic Type sizes. The send/stop controls have 44-point targets. `NavigationSplitView` retains compact/regular workspace selection, uses a bounded sidebar appropriate to iPad resizing, and reconciles workspace CRUD without losing a still-valid detail selection. + +Verification: + +- The Electron appearance suite passes 13/13 tests, including exact shared-fixture parity and contrast checks for every built-in palette in light and dark. +- A signed physical-iPhone build-for-testing succeeded. +- The focused physical iPhone 13 Pro appearance suite passes 3/3 tests: exact palette parity, complete device-local preference persistence/normalization, and adaptive workspace-selection reconciliation. +- The complete physical iPhone 13 Pro suite executes 43 tests with zero failures; four explicit environment-gated transport/Keychain proofs are expected skips in the ordinary run. +- No simulator was used and the iPhone 16 Pro Max was untouched. + +The target declares both iPhone and iPad families and the adaptive implementation compiles in the signed device build. A physical iPad is not connected, so VoiceOver, hardware-keyboard, rotation, Stage Manager sizing, offline-state, and all-preset light/dark visual checks on actual iPad hardware have not been claimed. Those are the remaining Phase 8 acceptance items. diff --git a/docs/testing/aiden-on-the-go/phase-9.md b/docs/testing/aiden-on-the-go/phase-9.md new file mode 100644 index 00000000..1fb446c4 --- /dev/null +++ b/docs/testing/aiden-on-the-go/phase-9.md @@ -0,0 +1,20 @@ +# Aiden On The Go — Phase 9 evidence + +Date: 2026-08-19 +Status: Complete on the available physical-iPhone and desktop gates. + +The desktop now exposes workspace Files and Git through shared application services used by both Electron IPC and authenticated Aiden Remote routes. File identities and Git snapshots are opaque, device/workspace bound, bounded, and path-free on the wire. File writes use version preconditions. Git commit, push, checkout, branch creation, managed-worktree creation, and managed-worktree deletion require explicit foreground confirmation and durable idempotency; managed worktrees retain Aiden's mutation, rollback, ownership, terminal, generation, and scheduled-task safety gates. + +The Swift app provides native Files and Git destinations from Workspace Settings. It supports bounded file search/read/edit, offline cache reads, stale-write reconciliation, Git review/diff/compare/branches/commit/push/worktrees, destructive confirmation, and retry of ambiguous Git outcomes with the original idempotency key. It does not accept or reveal Mac paths or Git administration metadata. + +Verification: + +- TypeScript type-check passes. +- Shared application-service boundary tests pass 15/15. +- Aiden Remote tests pass 107/107 plus 4/4 LAN transport proofs. +- The complete subagent and workspace-mutation regression suite passes after its source-contract assertions were updated to follow the shared worktree application service. +- The complete signed physical iPhone 13 Pro XCTest suite passes, including opaque Files/Git DTO validation, cache isolation, canonical routes and mutation preconditions, and exact idempotency-key reuse after an ambiguous disconnect. Four explicit environment-gated transport/Keychain tests remain expected skips in the ordinary run. +- The installed app launches successfully on the iPhone 13 Pro. +- No simulator was used and the iPhone 16 Pro Max was untouched. + +The physical-iPad and real-Tailscale gates belong to Phases 6 and 8 and were not claimed by this phase. Phase 12 later closes real-Tailscale pairing and authenticated workspace transport on the physical iPhone; physical-iPad acceptance remains open. diff --git a/eslint.config.js b/eslint.config.js index 9747d86d..4c9dec03 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,6 +8,16 @@ export default [ ignores: ["build/**", "release/**", "node_modules/**", ".memory/**", ".papercuts/**"], }, js.configs.recommended, + { + files: [ + "scripts/aiden-remote-*.mjs", + "scripts/ios-asc-monitor*.mjs", + "scripts/ios-live-activity-process-proof*.mjs", + ], + languageOptions: { + globals: { ...globals.node }, + }, + }, { files: ["**/*.{ts,tsx}"], languageOptions: { diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 00000000..7dc78a24 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,60 @@ +# Xcode +DerivedData/ +build/ +*.xcworkspace/xcuserdata/ +*.xcodeproj/xcuserdata/ +*.xcuserstate +*.xcscmblueprint + +# Swift Package Manager +.build/ +.swiftpm/ + +# macOS +.DS_Store + +# Local secrets +.env +.env.local +.env.*.local + +# Per-machine signing overrides (see CONTRIBUTING.md) +Config/Local.xcconfig + +# Local reference material +UPDATES.md +.obsidian/ + +# Local agent tooling (per-machine caches, skills, session state) +node_modules/ +.codex-tmp/ +.agents/ +.sc/ +.claude/ +.codex/ +.cursor/ + +# App packaging +*.ipa +*.dSYM.zip +*.dSYM + +# Fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output/ + +# Pi tooling artifacts (review outputs, fix plans, etc.) +.pi/ + +# Editor swap/temp files +*.swp +*.swo +*~ + +# Retired per-session progress log (removed in #224; history lives in git log / merged PRs) +/PROGRESS.md + +# Local session-handoff state (read at session start, overwritten at wrap-up; not tracked) +/CURRENT.md diff --git a/ios/AGENTS.md b/ios/AGENTS.md new file mode 100644 index 00000000..9a1cbd8f --- /dev/null +++ b/ios/AGENTS.md @@ -0,0 +1,53 @@ +# AGENTS.md — Aiden On The Go working agreement + +Aiden On The Go is the native SwiftUI iPhone/iPad companion for the Aiden Agent Electron app in this repository. The desktop app is the execution and persistence authority. The imported Hermex project is an implementation foundation, not a product/API compatibility requirement. + +## Sources of truth + +Read these before changing mobile behavior or contracts: + +1. `../AGENTS.md` for repository-wide requirements. +2. `../docs/plans/aiden-on-the-go-plan.md` for scope, delivery order, and phase gates. +3. `PROJECT_SPEC.md` for the approved mobile product contract. +4. `../docs/aiden-remote-api-v1.md` and `../protocol/aiden-remote/v1/openapi.json` for protocol behavior and exact wire shapes. +5. `../protocol/aiden-remote/v1/fixtures/contract.json` for cross-platform contract fixtures. + +If these disagree, stop at the narrower safety boundary and resolve the documents together. Do not consult or preserve Hermes WebUI endpoints as a fallback. + +## Delivery rules + +- Implement phases 0–12 in order. Do not expose later production endpoints to make an earlier phase easier. +- Add or update focused XCTest and cross-platform contract coverage with every behavioral slice. +- Before advancing a phase, satisfy its evidence gate in `../docs/testing/aiden-on-the-go/`, run the applicable full suites, and clear all P0/P1 findings in a direct source-and-test review. The owner currently prohibits subagent/reviewer delegation; do not use it unless that direction is explicitly reversed. +- Preserve unrelated user changes and imported MIT attribution. +- New third-party dependencies require explicit approval. Prefer Apple frameworks and the dependencies already locked by the imported project. +- Use terminal-based `xcodebuild`/`xcrun` verification against an explicitly selected physical device. The owner currently prohibits simulator use. A `CODE_SIGNING_ALLOWED=NO` build is compile/test-only and is not valid manual Keychain or entitlements evidence. + +## Protocol and security rules + +- Never invent endpoints or JSON shapes in Swift. Change the normative Aiden contract, shared fixture, TypeScript tests, and Swift tests together. +- Decode additive response fields tolerantly, but fail closed when required identity, authentication, capability, ownership, revision, sequence, expiry, or mutation-precondition fields are missing or invalid. +- Do not send or persist Aiden credentials in URLs, App Group data, App Intents, logs, Live Activities, fixtures, or source control. Device credentials belong in Keychain. +- LAN production traffic uses hostname/certificate validation plus the QR-pinned P-256 SPKI SHA-256 fingerprint. A matching pin alone never bypasses normal trust evaluation. +- The client never sends a free-form Mac path. Workspace-browser selections and workspace-file identities use separate opaque, server-issued capabilities. +- TCP/SSE loss never retries a turn creation or abandons server-owned work. Reconcile by stable IDs and sequence. +- Remote controls cannot mint Assistant/unattended authority, enable Computer Use, add a terminal, or widen a workspace's stored permission/tool contract. + +## Product boundaries + +- Retain native Hermex UI/behavior only where `PROJECT_SPEC.md` maps it to Aiden 1:1. +- Remove Kanban and every Hermes-only panel, profile/personality concept, server voice upload path, and Cloudflare-specific onboarding during the designated cleanup phases. +- Workspace permission belongs in Workspace Settings opened from the conversation-toolbar ellipsis, never in the composer. +- App Intents are App-Group-cache-only navigation. Live Activities are bounded last-known state without cloud push. Voice is on-device dictation/read-aloud only. +- Use iPhone navigation stacks and an adaptive iPad `NavigationSplitView`; do not introduce a custom UI system for branding. + +## Approved Apple identity + +- Automatic signing team: `5WP229CBB8` +- Main bundle: `sbtbiswas.AidenOnTheGo` +- Live Activity widget: `sbtbiswas.AidenOnTheGo.LiveActivityWidget` +- App Group: `group.sbtbiswas.AidenOnTheGo` +- Keychain service: `sbtbiswas.AidenOnTheGo.pairing` +- URL scheme: `aiden-otg` + +The shipping project, target, and scheme are `AidenOnTheGo`. Old Hermes project, target, scheme, bundle, service, and asset identifiers are not approved and must not be restored. diff --git a/ios/APP_STORE_METADATA.md b/ios/APP_STORE_METADATA.md new file mode 100644 index 00000000..b79d8f24 --- /dev/null +++ b/ios/APP_STORE_METADATA.md @@ -0,0 +1,116 @@ +# Aiden On The Go — App Store metadata draft + +Status: Release draft. Public identity, category, age-rating answers, privacy-label answers, and screenshot specifications are resolved from the shipping app and current Apple definitions. App Store Connect publication and release-specific owner decisions remain open. + +The executable canonical ASC localization draft lives in `app-store/metadata/`. The first internal TestFlight train is `0.1.0`; build `1` was rejected during upload for an alpha-bearing App Store icon, builds `2`–`5` progressively corrected the icon, compact navigation, native shell, and cold home loading, and build `6` is the current internal candidate with the final scratch/navigation/activity-branding repairs. The version directory and Xcode targets match that decision. Offline validation is safe, but no metadata may be applied until the public mobile privacy/support copy and owner decisions below are complete. + +## Resolved public identity + +- Developer/team name: `Sambit Biswas` +- Apple team: `5WP229CBB8` +- Bundle ID: `sbtbiswas.AidenOnTheGo` +- App Store SKU: `aiden-on-the-go-ios` +- Marketing URL: `https://chatwithaiden.com/` +- Support URL: `https://chatwithaiden.com/` +- Privacy-policy URL: `https://chatwithaiden.com/privacy` +- Feedback/review email: `hey@sambitbiswas.com` +- Copyright: `2026 Sambit Biswas` + +The team/developer identity and feedback email come from the owner's shipped Contact Sheet project and installed Apple profile. Aiden URLs come from the live Aiden website; Contact Sheet's product-specific domain is not reused. The Contact Sheet listing uses its product site for both marketing and support, so Aiden follows the same pattern. Before submission, make the resolved feedback address or another working support contact visible on the Aiden site. The published policy is a valid public URL, but its copy currently describes the macOS app only. `app-store/MOBILE_PRIVACY_SUPPORT_COPY.md` contains ready-to-review replacement/addition copy covering Aiden On The Go's device-local cache, pairing credential, Local Network/Tailscale transport, attachments/providers, permissions, dictation, App Intents, external media, and Live Activity behavior. It is not considered published until the live site is updated and rechecked. + +## Product copy + +- Name: `Aiden On The Go` +- Subtitle: `Your Aiden Agent, anywhere` +- Primary category: Developer Tools +- Secondary category: Productivity +- Keywords draft: `AI assistant,agent,developer,Git,workspace,chat,automation,remote,Tailscale,Swift` + +Developer Tools is the closest current Apple category: Apple describes it as apps for app development, management, coding, workflow management, and code editing. Productivity is the complementary secondary category. Category selection remains an App Store Connect property for this iOS/iPadOS app; do not add the macOS-only Xcode category key just to duplicate it. + +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. +> +> 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 and read-aloud stay 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. + +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. + +## Age-rating questionnaire draft + +Use the current questionnaire for OS version 26 and later: + +| Questionnaire item | Draft answer | Shipping-app basis | +| --- | --- | --- | +| Parental controls | No | Aiden has no parental-control surface. | +| Age assurance | No | Aiden does not request or infer age. | +| Unrestricted web access | No | The app has no embedded browser or free webpage navigation. Opening a link in the system browser is not in-app unrestricted browsing. | +| User-generated content | No | Chats and files stay private to the paired installation; the app does not broadly distribute user-created content. | +| Social media / disabled under 13 | No / No | There is no feed, discovery, sharing, liking, reposting, or public amplification. | +| Messaging and chat | No | Apple's capability is user-to-user communication. Aiden is private user-to-agent interaction and has no direct/group user messaging or public posting. | +| Advertising | No | No ads or paid promotional placements ship. | +| Mature themes, medical/wellness, sexuality/nudity, violence, and chance-based activities | None for every frequency item; Gambling and Loot Boxes: No | None is authored or promoted as app content. Connected AI output is open-ended and remains subject to provider behavior and user prompts. | +| Made for Kids | Not applicable | The product and public policy are not directed to children. | +| Override | 13+ | The public privacy policy says Aiden is not directed to children under 13. A conservative 13+ override aligns the listing with that product policy even if the questionnaire calculates a lower rating. | +| Age-suitability URL | Leave blank | No dedicated age-suitability page currently exists. | + +Before publishing, compare these answers with the exact final questionnaire wording and distribution candidate. If the release adds an embedded browser, user-to-user communication, public sharing, a hosted social surface, or intentionally supplied mature content, update the answers rather than relying on this draft. + +Current Apple references: + +- `https://developer.apple.com/help/app-store-connect/reference/app-information/age-ratings-values-and-definitions/` +- `https://developer.apple.com/help/app-store-connect/manage-app-information/set-an-app-age-rating/` + +## App Privacy questionnaire draft + +- Privacy Policy URL: `https://chatwithaiden.com/privacy` +- User Privacy Choices URL: leave blank; Aiden has no developer-held account or cloud data for a user to manage. +- Tracking: No. +- “Do you or your third-party partners collect data from this app?”: **No, we do not collect data from this app.** + +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. +- QR camera frames are processed for pairing and are not uploaded to Aiden's developer. +- Dictation and read-aloud use Apple system frameworks locally; Aiden does not operate a speech collection endpoint. +- 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. +- 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. +- `PrivacyInfo.xcprivacy` declares no tracking, no collected data types, and only the `UserDefaults` required-reason API (`CA92.1`). + +Apple defines collection for the privacy label as off-device transmission that lets the developer or a third-party partner access the data for longer than needed to service the request in real time. Reconfirm the **No** answer before publishing: it stops being accurate if the release adds developer-accessible telemetry, crash reports, accounts, hosted relay/proxy storage, or another partner that retains app data. The owner must also confirm that the final public policy accurately covers mobile behavior and the configured AI-provider path. + +Current Apple references: + +- `https://developer.apple.com/app-store/app-privacy-details/` +- `https://developer.apple.com/help/app-store-connect/manage-app-information/manage-app-privacy/` + +## Screenshot delivery specification + +The final distribution candidate needs one to ten opaque `.png`, `.jpg`, or `.jpeg` screenshots for each required family. Use real-device captures and do not use a simulator for this project. + +- iPhone: capture the connected iPhone 13 Pro at `1170 × 2532` portrait or `2532 × 1170` landscape. App Store Connect accepts that 6.1-inch size, but a final 6.9-inch capture is preferred when the owner's iPhone 16 Pro Max is available because Apple can scale the highest-resolution set down. +- iPad: a physical iPad capture is mandatory because the app supports iPad. Current 13-inch accepted portrait sizes are `2064 × 2752` or `2048 × 2732`; landscape reverses those dimensions. +- Do not include alpha/transparency. Capture at least pairing/installation selection, workspace/chat navigation, an active transcript with tool status, files/Git, and Scheduled Tasks while excluding real secrets, private paths, personal chats, and live pairing credentials. + +Current Apple reference: `https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/`. + +## Owner decisions still required + +- Internal TestFlight starts at marketing version `0.1.0`; build `1` was rejected before processing and build `6` is the current internal candidate. Advance the build number for later uploads in the same train and reserve `1.0.0` for the first public release decision. +- Enter Developer Tools / Productivity in App Store Connect, or deliberately override this documented recommendation. +- Verify and publish the drafted 13+ age-rating answers in the exact current questionnaire. +- Verify and publish the drafted “No data collected” App Privacy answer against the final distributed build and operational services. +- 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. +- Availability, price, territories, and release mode. + +Do not replace unresolved values with placeholders in App Store Connect. diff --git a/ios/ASC_CLI.md b/ios/ASC_CLI.md new file mode 100644 index 00000000..45501ca4 --- /dev/null +++ b/ios/ASC_CLI.md @@ -0,0 +1,175 @@ +# App Store Connect CLI runbook + +`asc` is the owner operations and read-only monitoring client for Aiden On The Go. The Hermex-derived GitHub upload workflows remain self-contained and do not install a mutable third-party CLI during an archive. Use `asc` after upload for App Store Connect inspection, deterministic metadata plans, validation, TestFlight operations, and Codex monitoring automations. + +## Local tool and safety policy + +- Audited local binary: Rork `asc` `3.4.0`, installed from the Homebrew stable formula on 2026-08-19. +- Use the stable command contract shown by `asc --help`; do not script experimental screenshot capture/framing commands. +- `asc` command telemetry is enabled by default on this Mac. Aiden operations must set `ASC_TELEMETRY_DISABLED=1` per invocation. The user's global CLI preference was not changed. +- Use `--strict-auth` for API commands so a profile cannot silently mix with partial environment credentials. +- The owner authorizes the existing `Parsely ASC` Keychain profile for Aiden operations within the resources it can actually access. Name it explicitly with `--profile "Parsely ASC"`; never rely on ambient/default credential selection or treat its zero-app result as authority to duplicate the existing public Aiden beta. +- Prefer JSON output for automation and exact App Store Connect IDs after resolving them read-only. +- Credentials belong in macOS Keychain or protected CI secrets. Never create or commit `.asc/config.json`, cached web sessions, `.p8` files, JWTs, passwords, or two-factor codes. +- Run read-only discovery first. App creation, bundle registration, capability edits, profile/certificate creation, metadata application, screenshot upload, tester changes, review submission, pricing, and availability changes require explicit owner authorization and an exact reviewed target. + +## Read-only audit — 2026-08-19 + +The installed CLI has one default System Keychain profile named `Parsely ASC`. The owner authorizes using it for Aiden resources within its visible scope. A cached Apple web session is not authenticated. + +With telemetry disabled and strict authentication: + +- `asc apps list --bundle-id sbtbiswas.AidenOnTheGo` returns zero accessible App Store Connect app records. +- Aiden's live website links to the active public TestFlight invitation `https://testflight.apple.com/join/s3T4T8y3`. On 2026-08-19, Apple's invitation page identified that beta as **Aiden - Quick AI**, available on iOS, with test instructions for the existing macOS product. The page does not expose the app's bundle ID or App Store Connect numeric ID. +- The Developer Portal contains the universal bundle ID `sbtbiswas.AidenOnTheGo` under team `5WP229CBB8`, with App Groups enabled. +- The Developer Portal result does not contain `sbtbiswas.AidenOnTheGo.LiveActivityWidget`. +- No provisioning profile is linked to the Aiden app identifier through this API profile. The accessible team has one iOS Distribution certificate and three unrelated iOS App Store profiles; none is named for Aiden. +- The local keychain still has no Apple Distribution private-key identity, matching the earlier Xcode signing audit. + +These findings prove only what the active API key can access. The public Aiden beta is positive evidence that an Aiden App Store Connect record exists outside this profile's visible scope, although it does not prove whether that record owns `sbtbiswas.AidenOnTheGo`. Before mutation, switch to a dedicated Aiden/release profile and resolve that existing record's numeric ID, platforms, and bundle ID. Zero accessible app records must not be treated as permission to auto-create one. + +Reproduce the non-mutating checks without printing unrelated account data and always select the owner-authorized profile explicitly: + +```sh +ASC_TELEMETRY_DISABLED=1 asc --strict-auth --profile "Parsely ASC" apps list \ + --bundle-id sbtbiswas.AidenOnTheGo \ + --output json --pretty +ASC_TELEMETRY_DISABLED=1 asc --strict-auth --profile "Parsely ASC" bundle-ids list \ + --paginate --output json +ASC_TELEMETRY_DISABLED=1 asc --strict-auth --profile "Parsely ASC" certificates list \ + --certificate-type IOS_DISTRIBUTION,DISTRIBUTION \ + --paginate --output json +ASC_TELEMETRY_DISABLED=1 asc --strict-auth --profile "Parsely ASC" profiles list \ + --profile-type IOS_APP_STORE \ + --paginate --output json +``` + +Filter the bundle/profile responses locally before saving evidence so unrelated apps, identifiers, and signing metadata are not copied into this repository. + +## Resolved internal-TestFlight state — 2026-08-19 + +The earlier zero-record audit is retained above as historical evidence. The owner subsequently created and authorized the distinct **Aiden On The Go** record and provisioned distribution signing: + +- App Store Connect App ID: `6803233275`; bundle ID: `sbtbiswas.AidenOnTheGo`; marketing version: `0.1.0`. +- Live Activity widget bundle ID: `sbtbiswas.AidenOnTheGo.LiveActivityWidget`. +- Internal group: `Internal Testers` (`3f90ffa7-29bb-429e-80a4-88422eb85b6d`). +- Build `1` upload was rejected with `ITMS-90717` because its App Store icon had transparency. +- Build `2` uses the exact opaque RayChat `.icon` package, is Apple Distribution-signed with `get-task-allow=false`, processed as `VALID`, and is `IN_BETA_TESTING` for the internal group. +- Build `3` fixes compact iPhone workspace navigation and preserves the same internal-only distribution contract. +- Build `4` carries the Aiden-native shell refresh but was superseded after a cold-connect home-load edge was found locally. +- Build `5` retries home loading on the connected-state transition; build `6` adds the final scratch/navigation/Live Activity branding repairs. +- Build `7` adds Thinking Orbs, the existing/new/scratch New Agent choices, hopeful server-confirmed mutations, relative-time and landing cleanup, the theme-continuous Liquid Glass composer, explicit keyboard dismissal, and nested per-model thinking-level menus. It is the current `VALID` / `IN_BETA_TESTING` candidate and remains internal-only. +- Build `8` adds Mac-owned model visibility, sanitized custom provider artwork sync, corrected iOS thinking-model rows, waveform-only dictation status, and a true floating New Agent home action. It is `VALID`, `IN_BETA_TESTING` for `Internal Testers`, and externally `NOT_APPLICABLE`. +- Build `9` adds first-class Concentrate, the refined “Your Activity” and full-width Total Tokens view, and bounded synchronization of Apple Foundation Models-generated chat titles into iOS. It is `VALID`, `IN_BETA_TESTING` for `Internal Testers`, and externally `NOT_APPLICABLE`. +- Build `10` adds authenticated pairing completion plus accepted multi-device, multi-Mac, listener-allocation, Tailscale-ownership, revocation, and iOS cache-isolation hardening. It is `VALID`, `IN_BETA_TESTING` for `Internal Testers`, and externally `NOT_APPLICABLE`. +- Build `11` adds the refreshed Pi-hosted model catalog and first-class iOS pairing choices for QR, nearby local setup, private Tailscale setup, and full-payload fallback. It is `VALID`, `IN_BETA_TESTING` for `Internal Testers`, and externally `NOT_APPLICABLE`. +- Build `12` introduced bidirectional chat images, durable terminal outcomes, adaptive onboarding, and the sender-anchored card deck. It processed as `VALID` but was superseded before tester assignment by the corner-mask correction in build `13`. +- Build `13` preserves all four continuous image corners across aspect ratios. It is `VALID`, `IN_BETA_TESTING` for `Internal Testers`, and externally `NOT_APPLICABLE`. +- Build `14` adds reliable mobile approval recovery, bounded mobile approval summaries, durable terminal reconciliation, and first-class Mac-to-mobile image sharing. It is `VALID` and was superseded by build `15` after the cross-platform activity presentation was normalized. +- Build `15` adds Mac-aligned typed activity timelines, concise expandable activity history, and Reduce Motion-aware shimmer/fade treatment. It is `VALID`, `IN_BETA_TESTING` for `Internal Testers`, and externally `NOT_APPLICABLE`. +- Current exact build resource ID: `0185ac70-617f-47d2-b959-d17f13e9a9d2` (build `14`: `e0a7152d-3ab7-41a3-89c0-e037fa9d8244`; build `13`: `c5988f33-3184-4e04-8205-9c4505cfc894`; build `12`: `588fef66-7ecc-4b6d-b04a-3539da52fad0`; build `11`: `7c372144-6f64-46cb-880f-2ad92043198d`; build `10`: `426041ab-8638-4b6a-9d10-59ab2ee5b79b`; build `9`: `7ed5d771-0ebe-4e38-b9e9-fab9c564794f`; build `8`: `b0d3c1c0-ab61-469f-9e4f-9dd250e1ff1a`; build `7`: `717b6381-4dec-4cce-85d1-72b503c28590`; build `6`: `aa994233-1bf3-4482-86f5-b8b0356eee25`; build `5`: `6173d5e2-0e58-4d0a-92fa-fc804fc82c37`; build `4`: `ee5b7c23-14d6-44e7-a4ba-6a5003018758`; build `3`: `e5f0ae7e-35aa-451e-be87-bc039885b2de`; build `2`: `721aeb9d-2b33-4729-8d10-5bc1783abbef`). + +The account holder is assigned to the internal group. The terminal valid state does not need a processing automation; future uploads may use the checked-in read-only monitor with the exact new build ID. + +## Owner-authorized reconciliation and bootstrap + +The remaining bootstrap has two distinct authorities: + +1. An App Store Connect API key with access to team `5WP229CBB8` can register the missing widget identifier and manage public-API signing/capability resources. +2. The correct Aiden App Store Connect profile or authenticated Apple web session is required to inspect the existing **Aiden - Quick AI** record and decide whether Aiden On The Go belongs in that record or a distinct one. + +First resolve the existing record read-only. Do not join the public beta as an operational shortcut, and do not create a second record merely because the current Parsely-named API key returns zero apps. + +Only after that reconciliation, execute the missing-widget mutation if the owner authorizes it: + +```sh +ASC_TELEMETRY_DISABLED=1 asc --strict-auth --profile "Parsely ASC" bundle-ids create \ + --identifier sbtbiswas.AidenOnTheGo.LiveActivityWidget \ + --name "Aiden On The Go Live Activity Widget" \ + --platform IOS +``` + +If the owner confirms that Aiden On The Go requires a distinct record, initial app-record creation is a web-session workflow in `asc 3.4.0`; `asc apps create` was removed. It requires an authenticated Apple web session and may require an interactive password and two-factor approval. Do not run this shape until the owner also confirms the first marketing version and authorizes the irreversible record: + +```sh +ASC_TELEMETRY_DISABLED=1 asc web apps create \ + --name "Aiden On The Go" \ + --bundle-id sbtbiswas.AidenOnTheGo \ + --sku aiden-on-the-go-ios \ + --platform IOS \ + --primary-locale en-US \ + --version "" +``` + +After creation, record the numeric App ID and App Info ID in protected owner configuration, not source defaults that could target the wrong app. + +## Metadata and validation flow + +Use dry-run/read-only commands before every apply: + +```sh +ASC_TELEMETRY_DISABLED=1 asc metadata validate \ + --dir ios/app-store/metadata \ + --output table + +ASC_TELEMETRY_DISABLED=1 asc --strict-auth --profile "Parsely ASC" app-setup categories set \ + --app "$ASC_APP_ID" \ + --primary DEVELOPER_TOOLS \ + --secondary PRODUCTIVITY + +ASC_TELEMETRY_DISABLED=1 asc --strict-auth --profile "Parsely ASC" age-rating view \ + --app "$ASC_APP_ID" --output json --pretty + +ASC_TELEMETRY_DISABLED=1 asc --strict-auth --profile "Parsely ASC" metadata apply \ + --app "$ASC_APP_ID" \ + --version "$AIDEN_IOS_VERSION" \ + --platform IOS \ + --dir ios/app-store/metadata \ + --dry-run + +ASC_TELEMETRY_DISABLED=1 asc --strict-auth --profile "Parsely ASC" validate \ + --app "$ASC_APP_ID" \ + --version "$AIDEN_IOS_VERSION" \ + --platform IOS \ + --output markdown +``` + +The checked-in canonical localization files contain only fields resolved in `APP_STORE_METADATA.md`; optional `promotionalText`, `whatsNew`, `privacyChoicesUrl`, and `privacyPolicyText` keys are intentionally omitted rather than set to empty strings. The version path matches the owner-approved first internal TestFlight train, `0.1.0`; build `1` was rejected for an alpha-bearing App Store icon, build `2` corrected the icon, and build `3` is the current compact-navigation candidate. The category command mutates immediately, so run it only after the resolved IDs and category decision in `APP_STORE_METADATA.md` are approved. Age-rating edits use `--all-none --age-rating-override-v2 THIRTEEN_PLUS` plus the exact reviewed flags. App Privacy is an Apple web-session surface in this CLI: use `asc web privacy pull` and `plan` first; `apply` and `publish` remain separate owner-confirmed mutations. + +`asc screenshots capture` is not an approved iOS path for this project because its local iOS capture workflow targets a simulator. Capture on physical devices with `xcrun devicectl device capture screenshot`, remove alpha by exporting an opaque JPEG when necessary, then use `asc screenshots validate` before any upload. A provisional physical iPhone 13 Pro capture at `1170 × 2532` converted to opaque JPEG and passed `asc screenshots validate --device-type APP_IPHONE_58`; it is evidence of the pipeline, not a final release asset. + +## Codex automation policy + +Create automations only after `ASC_APP_ID` resolves to the reviewed Aiden record and at least one build exists. Every automation must be read-only, set `ASC_TELEMETRY_DISABLED=1`, use `--strict-auth`, include the exact App ID/platform, and report state changes rather than mutating App Store Connect. + +The registered `npm run ios:asc-monitor` wrapper enforces those rules. It requires an explicit named Keychain profile and therefore cannot silently fall back to ambient credentials. The owner-authorized `Parsely ASC` profile may be supplied once the exact Aiden App ID/build are known. App IDs must be numeric App Store Connect IDs; processing and TestFlight modes additionally require the exact build resource ID. Processing executes `builds info --build-id` instead of selecting the latest build. TestFlight output contains only counts, newest timestamps, and deterministic fingerprints—never tester identity, feedback text, screenshot URLs, or crash content. + +After the correct IDs and profile exist, use these command shapes in Codex automations: + +```sh +npm run ios:asc-monitor -- \ + --mode processing \ + --profile "" \ + --app-id "" \ + --build-id "" + +npm run ios:asc-monitor -- \ + --mode review \ + --profile "" \ + --app-id "" \ + --version "" + +npm run ios:asc-monitor -- \ + --mode testflight \ + --profile "" \ + --app-id "" \ + --build-id "" +``` + +Useful lifecycle cadences: + +- Every 15 minutes after an upload: processing mode until the exact build reaches `VALID`, `FAILED`, or `INVALID`. +- Hourly while a submission is active: review mode, notifying only on state changes or blockers. +- Daily during internal/external testing: TestFlight mode for the exact app/build, notifying when counts, newest timestamps, or fingerprints change; inspect sensitive content only interactively in App Store Connect. + +Do not create a placeholder automation for build `2`: it is already terminal `VALID` and `IN_BETA_TESTING`. For a future upload, create a processing monitor only after that upload has an exact build resource ID, then stop/archive it when the watched state is terminal. diff --git a/ios/AidenLiveActivityWidget/AgentRunLiveActivityWidget.swift b/ios/AidenLiveActivityWidget/AgentRunLiveActivityWidget.swift new file mode 100644 index 00000000..40351580 --- /dev/null +++ b/ios/AidenLiveActivityWidget/AgentRunLiveActivityWidget.swift @@ -0,0 +1,430 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +@main +struct AidenLiveActivityWidgetBundle: WidgetBundle { + var body: some Widget { + AgentRunLiveActivityWidget() + } +} + +struct AgentRunLiveActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: AgentRunActivityAttributes.self) { context in + AgentRunLockScreenView(context: context) + .activityBackgroundTint(AgentRunLiveActivityTheme.background) + .activitySystemActionForegroundColor(AgentRunLiveActivityTheme.primaryText) + .widgetURL(AidenDeepLink.chatURL( + instanceId: context.attributes.instanceID, + chatId: context.state.sessionID + )) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + AgentRunIslandBadge(status: context.state.status) + .padding(.leading, 18) + } + + DynamicIslandExpandedRegion(.trailing) { + AgentRunIslandStatusView(state: context.state) + .padding(.trailing, 18) + } + + DynamicIslandExpandedRegion(.bottom) { + AgentRunExpandedIslandBottomView(state: context.state) + } + } compactLeading: { + AgentRunIslandCompactMark(status: context.state.status) + } compactTrailing: { + Text(context.state.status.compactTitle) + .font(.caption2.weight(.semibold)) + .foregroundStyle(AgentRunStatusStyle.color(for: context.state.status, isStale: context.state.isStale)) + .minimumScaleFactor(0.72) + .lineLimit(1) + } minimal: { + AgentRunIslandCompactMark(status: context.state.status) + } + .widgetURL(AidenDeepLink.chatURL( + instanceId: context.attributes.instanceID, + chatId: context.state.sessionID + )) + .keylineTint(AgentRunStatusStyle.color(for: context.state.status, isStale: context.state.isStale)) + } + } +} + +private struct AgentRunExpandedIslandBottomView: View { + let state: AgentRunActivityAttributes.ContentState + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + AgentRunProgressRail(status: state.status) + + if !state.responseExcerpt.isEmpty { + Text(state.responseExcerpt) + .font(.caption2) + .foregroundStyle(AgentRunLiveActivityTheme.secondaryText) + .lineLimit(2) + .truncationMode(.tail) + } else { + Text(state.currentActivity) + .font(.caption2.weight(.semibold)) + .foregroundStyle(AgentRunLiveActivityTheme.secondaryText) + .lineLimit(1) + .truncationMode(.tail) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) + .padding(.horizontal, 12) + .padding(.bottom, 4) + } +} + +private struct AgentRunLockScreenView: View { + let context: ActivityViewContext + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + header + activityProgressRow(progressWidth: 112) + transcriptPanel + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + + private var activityText: String { + if context.state.isStale { + return "Latest status shown" + } + + if let errorSummary = context.state.errorSummary, !errorSummary.isEmpty { + return errorSummary + } + + return context.state.currentActivity + } + + private var header: some View { + HStack(alignment: .center, spacing: 10) { + AgentRunStatusDot(status: context.state.status, isStale: context.state.isStale, size: 34) + + VStack(alignment: .leading, spacing: 2) { + Text("Aiden") + .font(.caption2.weight(.bold)) + .foregroundStyle(AgentRunLiveActivityTheme.secondaryText) + .textCase(.uppercase) + + Text(context.state.sessionTitle) + .font(.headline.weight(.semibold)) + .foregroundStyle(AgentRunLiveActivityTheme.primaryText) + .lineLimit(1) + .truncationMode(.tail) + .minimumScaleFactor(0.82) + } + .layoutPriority(1) + + Spacer(minLength: 8) + + AgentRunTimerPill(state: context.state) + } + } + + private func activityProgressRow(progressWidth: CGFloat) -> some View { + HStack(alignment: .center, spacing: 8) { + Text(activityText) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(AgentRunLiveActivityTheme.primaryText) + .lineLimit(1) + .minimumScaleFactor(0.82) + .layoutPriority(1) + + Spacer(minLength: 8) + + AgentRunProgressRail(status: context.state.status) + .frame(width: progressWidth) + } + } + + private var transcriptPanel: some View { + VStack(alignment: .leading, spacing: 0) { + Text(excerptText) + .font(.caption) + .foregroundStyle(AgentRunLiveActivityTheme.secondaryText) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(AgentRunStatusStyle.color(for: context.state.status, isStale: context.state.isStale).opacity(0.14), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(AgentRunLiveActivityTheme.stroke, lineWidth: 1) + ) + } + + private var excerptText: String { + if !context.state.responseExcerpt.isEmpty { + return context.state.responseExcerpt + } + + if context.state.isFinal { + return "Response is ready to review." + } + + return "Waiting for the next agent update." + } +} + +private struct AgentRunIslandBadge: View { + let status: AgentRunActivityStatus + + var body: some View { + HStack(spacing: 6) { + AgentRunStatusDot(status: status, isStale: false) + Text("Aiden") + .font(.caption.weight(.semibold)) + .foregroundStyle(AgentRunLiveActivityTheme.primaryText) + .lineLimit(1) + } + } +} + +private struct AgentRunIslandStatusView: View { + let state: AgentRunActivityAttributes.ContentState + + var body: some View { + VStack(alignment: .trailing, spacing: 3) { + Text(state.status.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(AgentRunStatusStyle.color(for: state.status, isStale: state.isStale)) + .lineLimit(1) + + if state.isFinal { + Text("Ready") + .font(.caption2.weight(.medium)) + .foregroundStyle(AgentRunLiveActivityTheme.secondaryText) + } else if state.isStale { + Text("Latest") + .font(.caption2.weight(.medium)) + .foregroundStyle(AgentRunLiveActivityTheme.secondaryText) + } else { + HStack(spacing: 3) { + Circle() + .fill(AgentRunLiveActivityTheme.liveDot) + .frame(width: 4, height: 4) + AgentRunElapsedTimerText(startedAt: state.startedAt, alignment: .trailing) + } + .font(.caption2.weight(.medium)) + .foregroundStyle(AgentRunLiveActivityTheme.secondaryText) + } + } + } +} + +private struct AgentRunIslandCompactMark: View { + let status: AgentRunActivityStatus + + var body: some View { + ZStack { + Circle() + .fill(AgentRunStatusStyle.color(for: status, isStale: false).opacity(0.25)) + + AgentRunStatusMark(status: status, size: 10) + } + .frame(width: 22, height: 22) + } +} + +private struct AgentRunStatusDot: View { + let status: AgentRunActivityStatus + let isStale: Bool + var size: CGFloat = 22 + + var body: some View { + AgentRunStatusMark(status: status, size: size * 0.46) + .foregroundStyle(color) + .frame(width: size, height: size) + .background(color.opacity(0.18), in: Circle()) + .overlay(Circle().stroke(color.opacity(0.36), lineWidth: 1)) + } + + var color: Color { + AgentRunStatusStyle.color(for: status, isStale: isStale) + } + +} + +private struct AgentRunStatusMark: View { + let status: AgentRunActivityStatus + let size: CGFloat + + @ViewBuilder + var body: some View { + if status == .starting || status == .thinking { + Image("aiden-sidebar-logo") + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: size, height: size) + } else { + Image(systemName: AgentRunStatusStyle.symbolName(for: status)) + .font(.system(size: size, weight: .bold)) + } + } +} + +private struct AgentRunProgressRail: View { + let status: AgentRunActivityStatus + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule(style: .continuous) + .fill(AgentRunLiveActivityTheme.railBackground) + + Capsule(style: .continuous) + .fill(AgentRunStatusStyle.color(for: status, isStale: false)) + .frame(width: max(12, geometry.size.width * progressFraction)) + } + } + .frame(height: 6) + } + + private var progressFraction: CGFloat { + switch status { + case .starting: + 0.14 + case .thinking: + 0.3 + case .usingTool, .searchingFiles, .readingFiles, .runningCommand: + 0.52 + case .waitingForApproval: + 0.62 + case .responding: + 0.78 + case .complete: + 1 + case .failed, .cancelled: + 1 + } + } +} + +private struct AgentRunTimerPill: View { + let state: AgentRunActivityAttributes.ContentState + + var body: some View { + HStack(spacing: 4) { + Circle() + .fill(state.isFinal ? AgentRunStatusStyle.color(for: state.status, isStale: state.isStale) : AgentRunLiveActivityTheme.liveDot) + .frame(width: 5, height: 5) + + if state.isFinal { + Text("Done") + } else { + AgentRunElapsedTimerText(startedAt: state.startedAt, alignment: .center) + } + } + .font(.caption2.weight(.semibold)) + .foregroundStyle(AgentRunLiveActivityTheme.secondaryText) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .frame(width: 62, alignment: .center) + .background(AgentRunLiveActivityTheme.pillBackground, in: Capsule(style: .continuous)) + .overlay(Capsule(style: .continuous).stroke(AgentRunLiveActivityTheme.stroke, lineWidth: 1)) + } +} + +private struct AgentRunElapsedTimerText: View { + let startedAt: Date + var alignment: TextAlignment = .trailing + + // `Text(timerInterval:)` reserves layout width for the largest value its range + // could ever show, then draws the shorter live value leading-aligned inside that + // leftover slack — which left-shifted the digits in the Dynamic Island and the + // Lock Screen pill (#247). Bounding the range keeps an MM:SS-sized box, and an + // explicit `multilineTextAlignment` pins the digits to the edge each call site + // wants (trailing under the Dynamic Island status, centered in the pill). + private static let maxDisplayInterval: TimeInterval = 99 * 60 + 59 + + var body: some View { + Text( + timerInterval: startedAt...startedAt.addingTimeInterval(Self.maxDisplayInterval), + countsDown: false, + showsHours: false + ) + .monospacedDigit() + .multilineTextAlignment(alignment) + .lineLimit(1) + } +} + +private enum AgentRunLiveActivityTheme { + static let background = Color(red: 0.025, green: 0.028, blue: 0.038) + static let primaryText = Color.white + static let secondaryText = Color.white.opacity(0.68) + static let stroke = Color.white.opacity(0.13) + static let pillBackground = Color.white.opacity(0.08) + static let railBackground = Color.white.opacity(0.14) + static let liveDot = Color(red: 0.35, green: 0.95, blue: 0.7) +} + +private enum AgentRunStatusStyle { + static func color(for status: AgentRunActivityStatus, isStale: Bool) -> Color { + if isStale { + return Color.white.opacity(0.52) + } + + switch status { + case .starting, .thinking, .responding: + return Color(red: 1.0, green: 0.82, blue: 0.18) + case .usingTool: + return Color(red: 0.50, green: 0.72, blue: 1.0) + case .searchingFiles: + return Color(red: 0.22, green: 0.92, blue: 0.95) + case .readingFiles: + return Color(red: 0.58, green: 0.78, blue: 1.0) + case .runningCommand: + return Color(red: 0.76, green: 0.55, blue: 1.0) + case .waitingForApproval: + return Color(red: 1.0, green: 0.58, blue: 0.24) + case .complete: + return Color(red: 0.35, green: 0.95, blue: 0.55) + case .failed: + return Color(red: 1.0, green: 0.32, blue: 0.32) + case .cancelled: + return Color.white.opacity(0.56) + } + } + + static func symbolName(for status: AgentRunActivityStatus) -> String { + switch status { + case .starting, .thinking: + preconditionFailure("Starting and thinking use the Aiden logo resource") + case .usingTool: + "wrench.and.screwdriver" + case .searchingFiles: + "magnifyingglass" + case .readingFiles: + "doc.text" + case .runningCommand: + "terminal" + case .responding: + "text.bubble" + case .waitingForApproval: + "checkmark.shield" + case .complete: + "checkmark" + case .failed: + "exclamationmark" + case .cancelled: + "xmark" + } + } +} diff --git a/ios/AidenLiveActivityWidget/Resources/Info.plist b/ios/AidenLiveActivityWidget/Resources/Info.plist new file mode 100644 index 00000000..97223c51 --- /dev/null +++ b/ios/AidenLiveActivityWidget/Resources/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(APP_DISPLAY_NAME) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + AidenURLScheme + $(APP_URL_SCHEME) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/AidenOnTheGo.xcodeproj/project.pbxproj b/ios/AidenOnTheGo.xcodeproj/project.pbxproj new file mode 100644 index 00000000..5610be22 --- /dev/null +++ b/ios/AidenOnTheGo.xcodeproj/project.pbxproj @@ -0,0 +1,1042 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A0D000000000000000000001 /* AidenRemoteContract.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D000000000000000000011 /* AidenRemoteContract.swift */; }; + A0D000000000000000000002 /* AidenServerTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D000000000000000000012 /* AidenServerTrust.swift */; }; + A0D000000000000000000003 /* AidenRemotePhase0Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D000000000000000000013 /* AidenRemotePhase0Tests.swift */; }; + A0D600000000000000000001 /* AidenRemoteClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D600000000000000000011 /* AidenRemoteClient.swift */; }; + A0D600000000000000000002 /* AidenInstallation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D600000000000000000012 /* AidenInstallation.swift */; }; + A0D600000000000000000003 /* AidenRemoteClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D600000000000000000013 /* AidenRemoteClientTests.swift */; }; + A0D600000000000000000004 /* AidenRemoteCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D600000000000000000014 /* AidenRemoteCoordinator.swift */; }; + A0D600000000000000000005 /* AidenPairingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D600000000000000000015 /* AidenPairingView.swift */; }; + A0D600000000000000000006 /* AidenWorkspaceShellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D600000000000000000016 /* AidenWorkspaceShellView.swift */; }; + A0D700000000000000000001 /* AidenChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D700000000000000000011 /* AidenChat.swift */; }; + A0D700000000000000000002 /* AidenSSEParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D700000000000000000012 /* AidenSSEParser.swift */; }; + A0D700000000000000000003 /* AidenChatCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D700000000000000000013 /* AidenChatCache.swift */; }; + A0D700000000000000000004 /* AidenChatFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D700000000000000000014 /* AidenChatFeature.swift */; }; + A0D700000000000000000005 /* AidenChatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D700000000000000000015 /* AidenChatTests.swift */; }; + A0D800000000000000000001 /* AidenAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D800000000000000000011 /* AidenAppearance.swift */; }; + A0D800000000000000000002 /* AidenAppearanceV1.json in Resources */ = {isa = PBXBuildFile; fileRef = A0D800000000000000000012 /* AidenAppearanceV1.json */; }; + A0D900000000000000000001 /* AidenWorkspaceEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D900000000000000000011 /* AidenWorkspaceEnvironment.swift */; }; + A0D900000000000000000002 /* AidenWorkspaceEnvironmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D900000000000000000012 /* AidenWorkspaceEnvironmentView.swift */; }; + A0D900000000000000000003 /* AidenWorkspaceEnvironmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D900000000000000000013 /* AidenWorkspaceEnvironmentTests.swift */; }; + A0DA00000000000000000001 /* AidenScheduledTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0DA00000000000000000011 /* AidenScheduledTask.swift */; }; + A0DA00000000000000000002 /* AidenScheduledTasksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0DA00000000000000000012 /* AidenScheduledTasksView.swift */; }; + A0DA00000000000000000003 /* AidenScheduledTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0DA00000000000000000013 /* AidenScheduledTaskTests.swift */; }; + A0D000000000000000000004 /* AidenRemoteContractV1.json in Resources */ = {isa = PBXBuildFile; fileRef = A0D000000000000000000014 /* AidenRemoteContractV1.json */; }; + A0D000000000000000000005 /* AidenManualPairingVectorV1.json in Resources */ = {isa = PBXBuildFile; fileRef = A0D000000000000000000015 /* AidenManualPairingVectorV1.json */; }; + 1A2B3C4D5E6F700000000001 /* AidenOnTheGoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000011 /* AidenOnTheGoApp.swift */; }; + 1A2B3C4D5E6F700000000002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000012 /* ContentView.swift */; }; + 1A2B3C4D5E6F700000000338 /* AidenAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000337 /* AidenAppIntents.swift */; }; + 1A2B3C4D5E6F700000000003 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F700000000014 /* Assets.xcassets */; }; + A1DE00000000000000000001 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = A1DE00000000000000000002 /* AppIcon.icon */; }; + 10CA110C0DE000000000B001 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 10CA110C0DE000000000B002 /* Localizable.xcstrings */; }; + 10CA110C0DE000000000B003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 10CA110C0DE000000000B002 /* Localizable.xcstrings */; }; + 10CA110C0DE000000000B004 /* AppShortcuts.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 10CA110C0DE000000000B005 /* AppShortcuts.xcstrings */; }; + 1A2B3C4D5E6F700000000008 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 1A2B3C4D5E6F700000000098 /* KeychainAccess */; }; + BADA00000000000000000001 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = BADA00000000000000000003 /* MarkdownUI */; }; + 1A2B3C4D5E6F7000000000A0 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F7000000000B0 /* KeychainStore.swift */; }; + 1A2B3C4D5E6F7000000000F8 /* AppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F7000000000F9 /* AppConfig.swift */; }; + 1A2B3C4D5E6F70000000300 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D5E6F70000000301 /* PrivacyInfo.xcprivacy */; }; + 9E2A653D968A4ACDA19A8142 /* ThirdPartyNotices in Resources */ = {isa = PBXBuildFile; fileRef = 488791F556304AD2AC59BC5D /* ThirdPartyNotices */; }; + A04500000000000000000001 /* AgentRunActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04500000000000000000010 /* AgentRunActivityAttributes.swift */; }; + A04500000000000000000002 /* AidenDeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04500000000000000000011 /* AidenDeepLink.swift */; }; + A04500000000000000000004 /* AgentRunActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04500000000000000000010 /* AgentRunActivityAttributes.swift */; }; + A04500000000000000000005 /* AidenDeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04500000000000000000011 /* AidenDeepLink.swift */; }; + A04500000000000000000006 /* AgentRunLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A04500000000000000000013 /* AgentRunLiveActivityWidget.swift */; }; + A04500000000000000000007 /* AidenLiveActivityWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = A04500000000000000000017 /* AidenLiveActivityWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + A04500000000000000000008 /* aiden-sidebar-logo.png in Resources */ = {isa = PBXBuildFile; fileRef = A04500000000000000000018 /* aiden-sidebar-logo.png */; }; + A0DB00000000000000000001 /* AidenRemoteLiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0DB00000000000000000011 /* AidenRemoteLiveActivityManager.swift */; }; + A0DB00000000000000000002 /* AidenNativeIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0DB00000000000000000012 /* AidenNativeIntegrationTests.swift */; }; + A710739233F64F4FA4C2B700 /* ComposerVoiceInputController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A710739233F64F4FA4C2B710 /* ComposerVoiceInputController.swift */; }; + A0E000000000000000000001 /* Core.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E000000000000000000011 /* Core.swift */; }; + A0E000000000000000000002 /* Lattice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E000000000000000000012 /* Lattice.swift */; }; + A0E000000000000000000003 /* Morph.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E000000000000000000013 /* Morph.swift */; }; + A0E000000000000000000004 /* OrbSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E000000000000000000014 /* OrbSpec.swift */; }; + A0E000000000000000000005 /* Orbits.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E000000000000000000015 /* Orbits.swift */; }; + A0E000000000000000000006 /* Presets.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E000000000000000000016 /* Presets.swift */; }; + A0E000000000000000000007 /* Snapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E000000000000000000017 /* Snapshot.swift */; }; + A0E000000000000000000008 /* Strands.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E000000000000000000018 /* Strands.swift */; }; + A0E000000000000000000009 /* ThinkingOrb.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E000000000000000000019 /* ThinkingOrb.swift */; }; + A0E00000000000000000000A /* Web.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E00000000000000000001A /* Web.swift */; }; + A0F000000000000000000001 /* AidenProviderIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0F000000000000000000011 /* AidenProviderIcon.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 1A2B3C4D5E6F700000000036 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 1A2B3C4D5E6F700000000070 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 1A2B3C4D5E6F700000000040; + remoteInfo = AidenOnTheGo; + }; + A04500000000000000000030 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 1A2B3C4D5E6F700000000070 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A04500000000000000000040; + remoteInfo = AidenLiveActivityWidget; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + B5A100000000000000000023 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + A04500000000000000000007 /* AidenLiveActivityWidget.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + A0D000000000000000000011 /* AidenRemoteContract.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenRemoteContract.swift; sourceTree = ""; }; + A0D000000000000000000012 /* AidenServerTrust.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenServerTrust.swift; sourceTree = ""; }; + A0D000000000000000000013 /* AidenRemotePhase0Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenRemotePhase0Tests.swift; sourceTree = ""; }; + A0D000000000000000000014 /* AidenRemoteContractV1.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = AidenRemoteContractV1.json; path = ../protocol/aiden-remote/v1/fixtures/contract.json; sourceTree = SOURCE_ROOT; }; + A0D000000000000000000015 /* AidenManualPairingVectorV1.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = AidenManualPairingVectorV1.json; path = ../protocol/aiden-remote/v1/fixtures/manual-pairing-vector.json; sourceTree = SOURCE_ROOT; }; + A0D600000000000000000011 /* AidenRemoteClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenRemoteClient.swift; sourceTree = ""; }; + A0D600000000000000000012 /* AidenInstallation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenInstallation.swift; sourceTree = ""; }; + A0D600000000000000000013 /* AidenRemoteClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenRemoteClientTests.swift; sourceTree = ""; }; + A0D600000000000000000014 /* AidenRemoteCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenRemoteCoordinator.swift; sourceTree = ""; }; + A0D600000000000000000015 /* AidenPairingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenPairingView.swift; sourceTree = ""; }; + A0D600000000000000000016 /* AidenWorkspaceShellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenWorkspaceShellView.swift; sourceTree = ""; }; + A0D700000000000000000011 /* AidenChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenChat.swift; sourceTree = ""; }; + A0D700000000000000000012 /* AidenSSEParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenSSEParser.swift; sourceTree = ""; }; + A0D700000000000000000013 /* AidenChatCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenChatCache.swift; sourceTree = ""; }; + A0D700000000000000000014 /* AidenChatFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenChatFeature.swift; sourceTree = ""; }; + A0D700000000000000000015 /* AidenChatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenChatTests.swift; sourceTree = ""; }; + A0D800000000000000000011 /* AidenAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenAppearance.swift; sourceTree = ""; }; + A0D800000000000000000012 /* AidenAppearanceV1.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = AidenAppearanceV1.json; path = ../protocol/aiden-appearance-v1.json; sourceTree = SOURCE_ROOT; }; + A0D900000000000000000011 /* AidenWorkspaceEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenWorkspaceEnvironment.swift; sourceTree = ""; }; + A0D900000000000000000012 /* AidenWorkspaceEnvironmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenWorkspaceEnvironmentView.swift; sourceTree = ""; }; + A0D900000000000000000013 /* AidenWorkspaceEnvironmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenWorkspaceEnvironmentTests.swift; sourceTree = ""; }; + A0DA00000000000000000011 /* AidenScheduledTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenScheduledTask.swift; sourceTree = ""; }; + A0DA00000000000000000012 /* AidenScheduledTasksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenScheduledTasksView.swift; sourceTree = ""; }; + A0DA00000000000000000013 /* AidenScheduledTaskTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenScheduledTaskTests.swift; sourceTree = ""; }; + 351C0F1600000000000000A1 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Shared.xcconfig; path = Config/Shared.xcconfig; sourceTree = ""; }; + 1A2B3C4D5E6F700000000010 /* AidenOnTheGo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AidenOnTheGo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1A2B3C4D5E6F700000000011 /* AidenOnTheGoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenOnTheGoApp.swift; sourceTree = ""; }; + 1A2B3C4D5E6F700000000012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 1A2B3C4D5E6F700000000337 /* AidenAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenAppIntents.swift; sourceTree = ""; }; + 1A2B3C4D5E6F700000000013 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 1A2B3C4D5E6F700000000014 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A1DE00000000000000000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; + 10CA110C0DE000000000B002 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 10CA110C0DE000000000B005 /* AppShortcuts.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = AppShortcuts.xcstrings; sourceTree = ""; }; + 1A2B3C4D5E6F700000000015 /* AidenOnTheGoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AidenOnTheGoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 1A2B3C4D5E6F7000000000B0 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; + 1A2B3C4D5E6F7000000000F9 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfig.swift; sourceTree = ""; }; + 1A2B3C4D5E6F70000000301 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + A710739233F64F4FA4C2B710 /* ComposerVoiceInputController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerVoiceInputController.swift; sourceTree = ""; }; + B5A100000000000000000015 /* AidenOnTheGo.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AidenOnTheGo.entitlements; sourceTree = ""; }; + 488791F556304AD2AC59BC5D /* ThirdPartyNotices */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ThirdPartyNotices; sourceTree = ""; }; + A04500000000000000000010 /* AgentRunActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentRunActivityAttributes.swift; sourceTree = ""; }; + A04500000000000000000011 /* AidenDeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenDeepLink.swift; sourceTree = ""; }; + A04500000000000000000013 /* AgentRunLiveActivityWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentRunLiveActivityWidget.swift; sourceTree = ""; }; + A04500000000000000000014 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A04500000000000000000018 /* aiden-sidebar-logo.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "aiden-sidebar-logo.png"; path = AidenOnTheGo/Resources/Assets.xcassets/AidenSidebarLogo.imageset/aiden-sidebar-logo.png; sourceTree = SOURCE_ROOT; }; + A04500000000000000000017 /* AidenLiveActivityWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = AidenLiveActivityWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + A0DB00000000000000000011 /* AidenRemoteLiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenRemoteLiveActivityManager.swift; sourceTree = ""; }; + A0DB00000000000000000012 /* AidenNativeIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenNativeIntegrationTests.swift; sourceTree = ""; }; + A0E000000000000000000011 /* Core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Core.swift; sourceTree = ""; }; + A0E000000000000000000012 /* Lattice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lattice.swift; sourceTree = ""; }; + A0E000000000000000000013 /* Morph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Morph.swift; sourceTree = ""; }; + A0E000000000000000000014 /* OrbSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrbSpec.swift; sourceTree = ""; }; + A0E000000000000000000015 /* Orbits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Orbits.swift; sourceTree = ""; }; + A0E000000000000000000016 /* Presets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presets.swift; sourceTree = ""; }; + A0E000000000000000000017 /* Snapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Snapshot.swift; sourceTree = ""; }; + A0E000000000000000000018 /* Strands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strands.swift; sourceTree = ""; }; + A0E000000000000000000019 /* ThinkingOrb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThinkingOrb.swift; sourceTree = ""; }; + A0E00000000000000000001A /* Web.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Web.swift; sourceTree = ""; }; + A0F000000000000000000011 /* AidenProviderIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AidenProviderIcon.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1A2B3C4D5E6F700000000020 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A2B3C4D5E6F700000000008 /* KeychainAccess in Frameworks */, + BADA00000000000000000001 /* MarkdownUI in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 1A2B3C4D5E6F700000000021 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A04500000000000000000061 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1A2B3C4D5E6F700000000030 = { + isa = PBXGroup; + children = ( + 351C0F1600000000000000A1 /* Shared.xcconfig */, + 1A2B3C4D5E6F700000000031 /* AidenOnTheGo */, + 1A2B3C4D5E6F700000000034 /* AidenOnTheGoTests */, + A04500000000000000000021 /* AidenLiveActivityWidget */, + 1A2B3C4D5E6F700000000032 /* Products */, + ); + sourceTree = ""; + }; + 1A2B3C4D5E6F700000000031 /* AidenOnTheGo */ = { + isa = PBXGroup; + children = ( + 1A2B3C4D5E6F700000000011 /* AidenOnTheGoApp.swift */, + 1A2B3C4D5E6F700000000012 /* ContentView.swift */, + 1A2B3C4D5E6F7000000000FA /* Config */, + 1A2B3C4D5E6F700000000339 /* AppIntents */, + A04500000000000000000020 /* LiveActivities */, + 1A2B3C4D5E6F7000000000C0 /* Auth */, + 1A2B3C4D5E6F7000000000C1 /* Networking */, + 1A2B3C4D5E6F7000000000C2 /* Models */, + 1A2B3C4D5E6F7000000000F0 /* Persistence */, + 1A2B3C4D5E6F7000000000C3 /* Features */, + 1A2B3C4D5E6F700000000033 /* Resources */, + ); + path = AidenOnTheGo; + sourceTree = ""; + }; + 1A2B3C4D5E6F700000000032 /* Products */ = { + isa = PBXGroup; + children = ( + 1A2B3C4D5E6F700000000010 /* AidenOnTheGo.app */, + 1A2B3C4D5E6F700000000015 /* AidenOnTheGoTests.xctest */, + A04500000000000000000017 /* AidenLiveActivityWidget.appex */, + ); + name = Products; + sourceTree = ""; + }; + 1A2B3C4D5E6F700000000033 /* Resources */ = { + isa = PBXGroup; + children = ( + A1DE00000000000000000002 /* AppIcon.icon */, + 1A2B3C4D5E6F700000000014 /* Assets.xcassets */, + 10CA110C0DE000000000B002 /* Localizable.xcstrings */, + 10CA110C0DE000000000B005 /* AppShortcuts.xcstrings */, + 1A2B3C4D5E6F700000000013 /* Info.plist */, + B5A100000000000000000015 /* AidenOnTheGo.entitlements */, + 1A2B3C4D5E6F70000000301 /* PrivacyInfo.xcprivacy */, + 488791F556304AD2AC59BC5D /* ThirdPartyNotices */, + ); + path = Resources; + sourceTree = ""; + }; + 1A2B3C4D5E6F700000000034 /* AidenOnTheGoTests */ = { + isa = PBXGroup; + children = ( + A0DB00000000000000000012 /* AidenNativeIntegrationTests.swift */, + A0DA00000000000000000013 /* AidenScheduledTaskTests.swift */, + A0D900000000000000000013 /* AidenWorkspaceEnvironmentTests.swift */, + A0D000000000000000000013 /* AidenRemotePhase0Tests.swift */, + A0D600000000000000000013 /* AidenRemoteClientTests.swift */, + A0D700000000000000000015 /* AidenChatTests.swift */, + A0D800000000000000000012 /* AidenAppearanceV1.json */, + A0D000000000000000000014 /* AidenRemoteContractV1.json */, + A0D000000000000000000015 /* AidenManualPairingVectorV1.json */, + ); + path = AidenOnTheGoTests; + sourceTree = ""; + }; + 1A2B3C4D5E6F7000000000C0 /* Auth */ = { + isa = PBXGroup; + children = ( + 1A2B3C4D5E6F7000000000B0 /* KeychainStore.swift */, + ); + path = Auth; + sourceTree = ""; + }; + 1A2B3C4D5E6F7000000000C1 /* Networking */ = { + isa = PBXGroup; + children = ( + A0D000000000000000000011 /* AidenRemoteContract.swift */, + A0D000000000000000000012 /* AidenServerTrust.swift */, + A0D600000000000000000011 /* AidenRemoteClient.swift */, + A0D700000000000000000012 /* AidenSSEParser.swift */, + ); + path = Networking; + sourceTree = ""; + }; + 1A2B3C4D5E6F7000000000C2 /* Models */ = { + isa = PBXGroup; + children = ( + A0DA00000000000000000011 /* AidenScheduledTask.swift */, + A0D900000000000000000011 /* AidenWorkspaceEnvironment.swift */, + A0D600000000000000000012 /* AidenInstallation.swift */, + A0D700000000000000000011 /* AidenChat.swift */, + ); + path = Models; + sourceTree = ""; + }; + 1A2B3C4D5E6F7000000000C3 /* Features */ = { + isa = PBXGroup; + children = ( + A0D600000000000000000020 /* Remote */, + 1A2B3C4D5E6F7000000000C4 /* Onboarding */, + C09300000000000000000010 /* Shared */, + 1A2B3C4D5E6F7000000000C5 /* SessionList */, + 1A2B3C4D5E6F7000000000C6 /* Chat */, + 1A2B3C4D5E6F7000000000C7 /* Workspace */, + 1A2B3C4D5E6F7000000000F7 /* Settings */, + 3F91D2A54B9A4F66A2C01020 /* Tasks */, + ); + path = Features; + sourceTree = ""; + }; + A0D600000000000000000020 /* Remote */ = { + isa = PBXGroup; + children = ( + A0DA00000000000000000012 /* AidenScheduledTasksView.swift */, + A0D900000000000000000012 /* AidenWorkspaceEnvironmentView.swift */, + A0D600000000000000000014 /* AidenRemoteCoordinator.swift */, + A0D600000000000000000015 /* AidenPairingView.swift */, + A0D600000000000000000016 /* AidenWorkspaceShellView.swift */, + A0D700000000000000000014 /* AidenChatFeature.swift */, + ); + path = Remote; + sourceTree = ""; + }; + 1A2B3C4D5E6F7000000000C4 /* Onboarding */ = { + isa = PBXGroup; + children = ( + ); + path = Onboarding; + sourceTree = ""; + }; + C09300000000000000000010 /* Shared */ = { + isa = PBXGroup; + children = ( + A0F000000000000000000011 /* AidenProviderIcon.swift */, + A0E000000000000000000020 /* ThinkingOrbsKit */, + ); + path = Shared; + sourceTree = ""; + }; + A0E000000000000000000020 /* ThinkingOrbsKit */ = { + isa = PBXGroup; + children = ( + A0E000000000000000000011 /* Core.swift */, + A0E000000000000000000012 /* Lattice.swift */, + A0E000000000000000000013 /* Morph.swift */, + A0E000000000000000000014 /* OrbSpec.swift */, + A0E000000000000000000015 /* Orbits.swift */, + A0E000000000000000000016 /* Presets.swift */, + A0E000000000000000000017 /* Snapshot.swift */, + A0E000000000000000000018 /* Strands.swift */, + A0E000000000000000000019 /* ThinkingOrb.swift */, + A0E00000000000000000001A /* Web.swift */, + ); + path = ThinkingOrbsKit; + sourceTree = ""; + }; + 1A2B3C4D5E6F7000000000C5 /* SessionList */ = { + isa = PBXGroup; + children = ( + ); + path = SessionList; + sourceTree = ""; + }; + 1A2B3C4D5E6F7000000000C6 /* Chat */ = { + isa = PBXGroup; + children = ( + A710739233F64F4FA4C2B710 /* ComposerVoiceInputController.swift */, + ); + path = Chat; + sourceTree = ""; + }; + 1A2B3C4D5E6F7000000000C7 /* Workspace */ = { + isa = PBXGroup; + children = ( + ); + path = Workspace; + sourceTree = ""; + }; + 1A2B3C4D5E6F7000000000F0 /* Persistence */ = { + isa = PBXGroup; + children = ( + A0D700000000000000000013 /* AidenChatCache.swift */, + ); + path = Persistence; + sourceTree = ""; + }; + 1A2B3C4D5E6F7000000000F7 /* Settings */ = { + isa = PBXGroup; + children = ( + ); + path = Settings; + sourceTree = ""; + }; + 1A2B3C4D5E6F7000000000FA /* Config */ = { + isa = PBXGroup; + children = ( + 1A2B3C4D5E6F7000000000F9 /* AppConfig.swift */, + A0D800000000000000000011 /* AidenAppearance.swift */, + ); + path = Config; + sourceTree = ""; + }; + A04500000000000000000020 /* LiveActivities */ = { + isa = PBXGroup; + children = ( + A04500000000000000000010 /* AgentRunActivityAttributes.swift */, + A04500000000000000000011 /* AidenDeepLink.swift */, + A0DB00000000000000000011 /* AidenRemoteLiveActivityManager.swift */, + ); + path = LiveActivities; + sourceTree = ""; + }; + 1A2B3C4D5E6F700000000339 /* AppIntents */ = { + isa = PBXGroup; + children = ( + 1A2B3C4D5E6F700000000337 /* AidenAppIntents.swift */, + ); + path = AppIntents; + sourceTree = ""; + }; + A04500000000000000000021 /* AidenLiveActivityWidget */ = { + isa = PBXGroup; + children = ( + A04500000000000000000013 /* AgentRunLiveActivityWidget.swift */, + A04500000000000000000022 /* Resources */, + ); + path = AidenLiveActivityWidget; + sourceTree = ""; + }; + A04500000000000000000022 /* Resources */ = { + isa = PBXGroup; + children = ( + A04500000000000000000014 /* Info.plist */, + A04500000000000000000018 /* aiden-sidebar-logo.png */, + ); + path = Resources; + sourceTree = ""; + }; + 3F91D2A54B9A4F66A2C01020 /* Tasks */ = { + isa = PBXGroup; + children = ( + ); + path = Tasks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1A2B3C4D5E6F700000000040 /* AidenOnTheGo */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1A2B3C4D5E6F700000000041 /* Build configuration list for PBXNativeTarget "AidenOnTheGo" */; + buildPhases = ( + 1A2B3C4D5E6F700000000050 /* Sources */, + 1A2B3C4D5E6F700000000060 /* Resources */, + 1A2B3C4D5E6F700000000020 /* Frameworks */, + B5A100000000000000000023 /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + A04500000000000000000031 /* PBXTargetDependency */, + ); + name = AidenOnTheGo; + packageProductDependencies = ( + 1A2B3C4D5E6F700000000098 /* KeychainAccess */, + BADA00000000000000000003 /* MarkdownUI */, + ); + productName = AidenOnTheGo; + productReference = 1A2B3C4D5E6F700000000010 /* AidenOnTheGo.app */; + productType = "com.apple.product-type.application"; + }; + 1A2B3C4D5E6F700000000044 /* AidenOnTheGoTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1A2B3C4D5E6F700000000045 /* Build configuration list for PBXNativeTarget "AidenOnTheGoTests" */; + buildPhases = ( + 1A2B3C4D5E6F700000000052 /* Sources */, + A0D000000000000000000060 /* Resources */, + 1A2B3C4D5E6F700000000021 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 1A2B3C4D5E6F700000000037 /* PBXTargetDependency */, + ); + name = AidenOnTheGoTests; + packageProductDependencies = ( + ); + productName = AidenOnTheGoTests; + productReference = 1A2B3C4D5E6F700000000015 /* AidenOnTheGoTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + A04500000000000000000040 /* AidenLiveActivityWidget */ = { + isa = PBXNativeTarget; + buildConfigurationList = A04500000000000000000041 /* Build configuration list for PBXNativeTarget "AidenLiveActivityWidget" */; + buildPhases = ( + A04500000000000000000050 /* Sources */, + A04500000000000000000060 /* Resources */, + A04500000000000000000061 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = AidenLiveActivityWidget; + productName = AidenLiveActivityWidget; + productReference = A04500000000000000000017 /* AidenLiveActivityWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1A2B3C4D5E6F700000000070 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2640; + LastUpgradeCheck = 2640; + TargetAttributes = { + 1A2B3C4D5E6F700000000040 = { + CreatedOnToolsVersion = 26.4.1; + }; + 1A2B3C4D5E6F700000000044 = { + CreatedOnToolsVersion = 26.4.1; + TestTargetID = 1A2B3C4D5E6F700000000040; + }; + A04500000000000000000040 = { + CreatedOnToolsVersion = 26.4.1; + }; + }; + }; + buildConfigurationList = 1A2B3C4D5E6F700000000071 /* Build configuration list for PBXProject "AidenOnTheGo" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + de, + es, + fr, + it, + pl, + "pt-BR", + nl, + tr, + ru, + ja, + "zh-Hans", + ko, + ar, + he, + ur, + "zh-Hant", + "zh-HK", + ); + mainGroup = 1A2B3C4D5E6F700000000030; + packageReferences = ( + 1A2B3C4D5E6F700000000099 /* XCRemoteSwiftPackageReference "KeychainAccess" */, + BADA00000000000000000002 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, + ); + productRefGroup = 1A2B3C4D5E6F700000000032 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1A2B3C4D5E6F700000000040 /* AidenOnTheGo */, + 1A2B3C4D5E6F700000000044 /* AidenOnTheGoTests */, + A04500000000000000000040 /* AidenLiveActivityWidget */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A0D000000000000000000060 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A0D000000000000000000004 /* AidenRemoteContractV1.json in Resources */, + A0D000000000000000000005 /* AidenManualPairingVectorV1.json in Resources */, + A0D800000000000000000002 /* AidenAppearanceV1.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 1A2B3C4D5E6F700000000060 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A1DE00000000000000000001 /* AppIcon.icon in Resources */, + 1A2B3C4D5E6F700000000003 /* Assets.xcassets in Resources */, + 10CA110C0DE000000000B001 /* Localizable.xcstrings in Resources */, + 1A2B3C4D5E6F70000000300 /* PrivacyInfo.xcprivacy in Resources */, + 9E2A653D968A4ACDA19A8142 /* ThirdPartyNotices in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A04500000000000000000060 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 10CA110C0DE000000000B003 /* Localizable.xcstrings in Resources */, + A04500000000000000000008 /* aiden-sidebar-logo.png in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1A2B3C4D5E6F700000000050 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A0E000000000000000000001 /* Core.swift in Sources */, + A0E000000000000000000002 /* Lattice.swift in Sources */, + A0E000000000000000000003 /* Morph.swift in Sources */, + A0E000000000000000000004 /* OrbSpec.swift in Sources */, + A0E000000000000000000005 /* Orbits.swift in Sources */, + A0E000000000000000000006 /* Presets.swift in Sources */, + A0E000000000000000000007 /* Snapshot.swift in Sources */, + A0E000000000000000000008 /* Strands.swift in Sources */, + A0E000000000000000000009 /* ThinkingOrb.swift in Sources */, + A0E00000000000000000000A /* Web.swift in Sources */, + A0F000000000000000000001 /* AidenProviderIcon.swift in Sources */, + 1A2B3C4D5E6F700000000338 /* AidenAppIntents.swift in Sources */, + A04500000000000000000001 /* AgentRunActivityAttributes.swift in Sources */, + A04500000000000000000002 /* AidenDeepLink.swift in Sources */, + A0DB00000000000000000001 /* AidenRemoteLiveActivityManager.swift in Sources */, + A710739233F64F4FA4C2B700 /* ComposerVoiceInputController.swift in Sources */, + A0DA00000000000000000001 /* AidenScheduledTask.swift in Sources */, + A0DA00000000000000000002 /* AidenScheduledTasksView.swift in Sources */, + A0D900000000000000000001 /* AidenWorkspaceEnvironment.swift in Sources */, + A0D900000000000000000002 /* AidenWorkspaceEnvironmentView.swift in Sources */, + A0D000000000000000000001 /* AidenRemoteContract.swift in Sources */, + A0D000000000000000000002 /* AidenServerTrust.swift in Sources */, + A0D600000000000000000001 /* AidenRemoteClient.swift in Sources */, + A0D600000000000000000002 /* AidenInstallation.swift in Sources */, + A0D600000000000000000004 /* AidenRemoteCoordinator.swift in Sources */, + A0D600000000000000000005 /* AidenPairingView.swift in Sources */, + A0D600000000000000000006 /* AidenWorkspaceShellView.swift in Sources */, + A0D700000000000000000001 /* AidenChat.swift in Sources */, + A0D700000000000000000002 /* AidenSSEParser.swift in Sources */, + A0D700000000000000000003 /* AidenChatCache.swift in Sources */, + A0D700000000000000000004 /* AidenChatFeature.swift in Sources */, + A0D800000000000000000001 /* AidenAppearance.swift in Sources */, + 1A2B3C4D5E6F700000000001 /* AidenOnTheGoApp.swift in Sources */, + 1A2B3C4D5E6F700000000002 /* ContentView.swift in Sources */, + 1A2B3C4D5E6F7000000000F8 /* AppConfig.swift in Sources */, + 1A2B3C4D5E6F7000000000A0 /* KeychainStore.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 1A2B3C4D5E6F700000000052 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A0DB00000000000000000002 /* AidenNativeIntegrationTests.swift in Sources */, + A0DA00000000000000000003 /* AidenScheduledTaskTests.swift in Sources */, + A0D900000000000000000003 /* AidenWorkspaceEnvironmentTests.swift in Sources */, + A0D000000000000000000003 /* AidenRemotePhase0Tests.swift in Sources */, + A0D600000000000000000003 /* AidenRemoteClientTests.swift in Sources */, + A0D700000000000000000005 /* AidenChatTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A04500000000000000000050 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A04500000000000000000004 /* AgentRunActivityAttributes.swift in Sources */, + A04500000000000000000005 /* AidenDeepLink.swift in Sources */, + A04500000000000000000006 /* AgentRunLiveActivityWidget.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 1A2B3C4D5E6F700000000037 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 1A2B3C4D5E6F700000000040 /* AidenOnTheGo */; + targetProxy = 1A2B3C4D5E6F700000000036 /* PBXContainerItemProxy */; + }; + A04500000000000000000031 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A04500000000000000000040 /* AidenLiveActivityWidget */; + targetProxy = A04500000000000000000030 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 1A2B3C4D5E6F700000000080 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 351C0F1600000000000000A1 /* Shared.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APP_DISPLAY_NAME = "Aiden On The Go"; + APP_URL_SCHEME_SUFFIX = ""; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = targeted; + }; + name = Debug; + }; + 1A2B3C4D5E6F700000000081 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 351C0F1600000000000000A1 /* Shared.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + APP_DISPLAY_NAME = "Aiden On The Go"; + APP_URL_SCHEME_SUFFIX = ""; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = targeted; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 1A2B3C4D5E6F700000000082 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = AppIconMonochrome; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = AidenOnTheGo/Resources/AidenOnTheGo.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 16; + DEVELOPMENT_ASSET_PATHS = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = AidenOnTheGo/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 1A2B3C4D5E6F700000000083 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = AppIconMonochrome; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = AidenOnTheGo/Resources/AidenOnTheGo.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 16; + DEVELOPMENT_ASSET_PATHS = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = AidenOnTheGo/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 1A2B3C4D5E6F700000000084 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 16; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)Tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AidenOnTheGo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/AidenOnTheGo"; + }; + name = Debug; + }; + 1A2B3C4D5E6F700000000085 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 16; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)Tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AidenOnTheGo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/AidenOnTheGo"; + }; + name = Release; + }; + A04500000000000000000042 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 16; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = AidenLiveActivityWidget/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).LiveActivityWidget"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A04500000000000000000043 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 16; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = AidenLiveActivityWidget/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).LiveActivityWidget"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1A2B3C4D5E6F700000000041 /* Build configuration list for PBXNativeTarget "AidenOnTheGo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A2B3C4D5E6F700000000082 /* Debug */, + 1A2B3C4D5E6F700000000083 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1A2B3C4D5E6F700000000045 /* Build configuration list for PBXNativeTarget "AidenOnTheGoTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A2B3C4D5E6F700000000084 /* Debug */, + 1A2B3C4D5E6F700000000085 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1A2B3C4D5E6F700000000071 /* Build configuration list for PBXProject "AidenOnTheGo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A2B3C4D5E6F700000000080 /* Debug */, + 1A2B3C4D5E6F700000000081 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A04500000000000000000041 /* Build configuration list for PBXNativeTarget "AidenLiveActivityWidget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A04500000000000000000042 /* Debug */, + A04500000000000000000043 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 1A2B3C4D5E6F700000000099 /* XCRemoteSwiftPackageReference "KeychainAccess" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kishikawakatsumi/KeychainAccess.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.2.2; + }; + }; + BADA00000000000000000002 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.4.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 1A2B3C4D5E6F700000000098 /* KeychainAccess */ = { + isa = XCSwiftPackageProductDependency; + package = 1A2B3C4D5E6F700000000099 /* XCRemoteSwiftPackageReference "KeychainAccess" */; + productName = KeychainAccess; + }; + BADA00000000000000000003 /* MarkdownUI */ = { + isa = XCSwiftPackageProductDependency; + package = BADA00000000000000000002 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; + productName = MarkdownUI; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 1A2B3C4D5E6F700000000070 /* Project object */; +} diff --git a/ios/AidenOnTheGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/AidenOnTheGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..50dea948 --- /dev/null +++ b/ios/AidenOnTheGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "fb5fc1072fe083e92c2417e3131383d24de0f43da0c8fb892c71b4aaa6413e88", + "pins" : [ + { + "identity" : "keychainaccess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kishikawakatsumi/KeychainAccess.git", + "state" : { + "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7", + "version" : "4.2.2" + } + }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "924936d0427cb25a61169739a7660230bffa6ea6", + "version" : "0.8.0" + } + }, + { + "identity" : "swift-markdown-ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/swift-markdown-ui.git", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + } + ], + "version" : 3 +} diff --git a/ios/AidenOnTheGo.xcodeproj/xcshareddata/xcschemes/AidenOnTheGo.xcscheme b/ios/AidenOnTheGo.xcodeproj/xcshareddata/xcschemes/AidenOnTheGo.xcscheme new file mode 100644 index 00000000..fd930180 --- /dev/null +++ b/ios/AidenOnTheGo.xcodeproj/xcshareddata/xcschemes/AidenOnTheGo.xcscheme @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/AidenOnTheGo/AidenOnTheGoApp.swift b/ios/AidenOnTheGo/AidenOnTheGoApp.swift new file mode 100644 index 00000000..37872856 --- /dev/null +++ b/ios/AidenOnTheGo/AidenOnTheGoApp.swift @@ -0,0 +1,25 @@ +import SwiftUI + +@main +struct AidenOnTheGoApp: App { + @State private var remoteCoordinator: AidenRemoteCoordinator + @State private var appearance = AidenAppearanceStore() + @State private var haptics: AidenHapticCenter + + init() { + let haptics = AidenHapticCenter() + _haptics = State(initialValue: haptics) + _remoteCoordinator = State(initialValue: AidenRemoteCoordinator(haptics: haptics)) + } + + var body: some Scene { + WindowGroup { + AidenAppearanceRoot(appearance: appearance) { + ContentView(coordinator: remoteCoordinator) + } + .environment(appearance) + .environment(haptics) + .aidenHapticHost(haptics) + } + } +} diff --git a/ios/AidenOnTheGo/AppIntents/AidenAppIntents.swift b/ios/AidenOnTheGo/AppIntents/AidenAppIntents.swift new file mode 100644 index 00000000..b5cf3f6a --- /dev/null +++ b/ios/AidenOnTheGo/AppIntents/AidenAppIntents.swift @@ -0,0 +1,229 @@ +import AppIntents +import Foundation + +struct AidenIntentInstallationRecord: Codable, Equatable, Sendable { + let id: String + let name: String +} + +struct AidenIntentWorkspaceRecord: Codable, Equatable, Sendable { + let id: String + let instanceId: String + let name: String +} + +struct AidenIntentCatalogSnapshot: Codable, Equatable, Sendable { + let installations: [AidenIntentInstallationRecord] + let workspaces: [AidenIntentWorkspaceRecord] + let activeInstallationId: String? +} + +/// App-Group cache intentionally contains display names and stable IDs only. +/// The intent process never receives endpoints, credentials, pins, permissions, or paths. +struct AidenIntentCatalogStore: @unchecked Sendable { + static let shared = AidenIntentCatalogStore() + private static let key = "aiden.intent-catalog.v1" + private let defaults: UserDefaults? + + init(defaults: UserDefaults? = nil) { + if let defaults { + self.defaults = defaults + } else { + let identifier = Bundle.main.object(forInfoDictionaryKey: "AidenAppGroupIdentifier") as? String + ?? "group.sbtbiswas.AidenOnTheGo" + self.defaults = UserDefaults(suiteName: identifier) + } + } + + func load() -> AidenIntentCatalogSnapshot { + guard let data = defaults?.data(forKey: Self.key), data.count <= 1_048_576, + let value = try? JSONDecoder().decode(AidenIntentCatalogSnapshot.self, from: data) else { + return AidenIntentCatalogSnapshot(installations: [], workspaces: [], activeInstallationId: nil) + } + let installations = unique(value.installations.filter { + Self.safeID($0.id) && Self.safeName($0.name) + }, id: \.id) + let installationIDs = Set(installations.map(\.id)) + let workspaces = uniqueWorkspaces(value.workspaces.filter { + Self.safeID($0.id) && Self.safeID($0.instanceId) + && Self.safeName($0.name) && installationIDs.contains($0.instanceId) + }) + let active = value.activeInstallationId.flatMap { installationIDs.contains($0) ? $0 : nil } + return AidenIntentCatalogSnapshot( + installations: installations, + workspaces: workspaces, + activeInstallationId: active + ) + } + + func update( + installations: [AidenIntentInstallationRecord], + activeInstallationId: String?, + workspaces: [AidenIntentWorkspaceRecord], + for instanceId: String? + ) throws { + let existing = load() + let sanitizedInstallations = unique(installations.filter { + Self.safeID($0.id) && Self.safeName($0.name) + }, id: \.id) + let installationIDs = Set(sanitizedInstallations.map(\.id)) + var retained = existing.workspaces.filter { + installationIDs.contains($0.instanceId) && $0.instanceId != instanceId + } + retained.append(contentsOf: workspaces.filter { + Self.safeID($0.id) && Self.safeID($0.instanceId) + && Self.safeName($0.name) && installationIDs.contains($0.instanceId) + }) + let snapshot = AidenIntentCatalogSnapshot( + installations: sanitizedInstallations, + workspaces: uniqueWorkspaces(retained), + activeInstallationId: activeInstallationId.flatMap { installationIDs.contains($0) ? $0 : nil } + ) + let data = try JSONEncoder().encode(snapshot) + guard data.count <= 1_048_576 else { throw CocoaError(.fileWriteOutOfSpace) } + defaults?.set(data, forKey: Self.key) + } + + private func unique(_ values: [Value], id: KeyPath) -> [Value] { + var seen = Set() + return values.filter { seen.insert($0[keyPath: id]).inserted } + } + + private func uniqueWorkspaces(_ values: [AidenIntentWorkspaceRecord]) -> [AidenIntentWorkspaceRecord] { + var seen = Set() + return values.filter { seen.insert("\($0.instanceId)\u{1F}\($0.id)").inserted } + } + + private static func safeID(_ value: String) -> Bool { + !value.isEmpty && value.count <= 160 + && value.unicodeScalars.allSatisfy { scalar in + CharacterSet.alphanumerics.contains(scalar) || "._:-".unicodeScalars.contains(scalar) + } + } + + private static func safeName(_ value: String) -> Bool { + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && value.count <= 256 + } +} + +struct AidenInstallationIntentEntity: AppEntity, Equatable { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Aiden Installation") + static let defaultQuery = AidenInstallationIntentQuery() + + let id: String + let name: String + + var displayRepresentation: DisplayRepresentation { DisplayRepresentation(title: "\(name)") } +} + +struct AidenInstallationIntentQuery: EntityQuery { + func entities(for identifiers: [String]) async throws -> [AidenInstallationIntentEntity] { + let wanted = Set(identifiers) + return AidenIntentCatalogStore.shared.load().installations + .filter { wanted.contains($0.id) } + .map { AidenInstallationIntentEntity(id: $0.id, name: $0.name) } + } + + func suggestedEntities() async throws -> [AidenInstallationIntentEntity] { + AidenIntentCatalogStore.shared.load().installations + .map { AidenInstallationIntentEntity(id: $0.id, name: $0.name) } + } +} + +struct AidenWorkspaceIntentEntity: AppEntity, Equatable { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Aiden Workspace") + static let defaultQuery = AidenWorkspaceIntentQuery() + + let workspaceId: String + let instanceId: String + let name: String + + var id: String { "\(instanceId)|\(workspaceId)" } + + var displayRepresentation: DisplayRepresentation { DisplayRepresentation(title: "\(name)") } +} + +struct AidenWorkspaceIntentQuery: EntityQuery { + func entities(for identifiers: [String]) async throws -> [AidenWorkspaceIntentEntity] { + let wanted = Set(identifiers) + return AidenIntentCatalogStore.shared.load().workspaces + .map(Self.entity) + .filter { wanted.contains($0.id) } + } + + func suggestedEntities() async throws -> [AidenWorkspaceIntentEntity] { + let snapshot = AidenIntentCatalogStore.shared.load() + return snapshot.workspaces + .filter { snapshot.activeInstallationId == nil || $0.instanceId == snapshot.activeInstallationId } + .map(Self.entity) + } + + private static func entity(_ value: AidenIntentWorkspaceRecord) -> AidenWorkspaceIntentEntity { + AidenWorkspaceIntentEntity(workspaceId: value.id, instanceId: value.instanceId, name: value.name) + } +} + +struct NewChatIntent: AppIntent { + static let title: LocalizedStringResource = "New Chat" + static let description = IntentDescription("Open Aiden On The Go on a new chat.") + + func perform() async throws -> some IntentResult & OpensIntent { + guard let url = AidenDeepLink.newChatURL else { throw AidenIntentError.invalidDestination } + return .result(opensIntent: OpenURLIntent(url)) + } +} + +struct NewChatVoiceIntent: AppIntent { + static let title: LocalizedStringResource = "New Chat with Voice" + static let description = IntentDescription("Open a new Aiden chat and start on-device dictation.") + + func perform() async throws -> some IntentResult & OpensIntent { + guard let url = AidenDeepLink.newChatVoiceURL else { throw AidenIntentError.invalidDestination } + return .result(opensIntent: OpenURLIntent(url)) + } +} + +struct NewChatInWorkspaceIntent: AppIntent { + static let title: LocalizedStringResource = "New Chat in Workspace" + static let description = IntentDescription("Open a new chat in a cached Aiden workspace.") + + @Parameter(title: "Workspace") var workspace: AidenWorkspaceIntentEntity + + static var parameterSummary: some ParameterSummary { + Summary("New chat in \(\.$workspace)") + } + + func perform() async throws -> some IntentResult & OpensIntent { + guard let url = AidenDeepLink.newChatURL( + instanceId: workspace.instanceId, + workspaceId: workspace.workspaceId, + startsVoice: false + ) else { throw AidenIntentError.invalidDestination } + return .result(opensIntent: OpenURLIntent(url)) + } +} + +enum AidenIntentError: Error { case invalidDestination } + +struct AidenShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: NewChatIntent(), + phrases: ["New chat in \(.applicationName)", "Start a new \(.applicationName) chat"], + shortTitle: "New Chat", + systemImageName: "square.and.pencil" + ) + AppShortcut( + intent: NewChatVoiceIntent(), + phrases: ["New voice chat in \(.applicationName)", "Start voice in \(.applicationName)"], + shortTitle: "New Chat with Voice", + systemImageName: "mic.badge.plus" + ) + AppShortcut( + intent: NewChatInWorkspaceIntent(), + phrases: ["New \(.applicationName) chat in \(\.$workspace)"], + shortTitle: "Chat in Workspace", + systemImageName: "folder.badge.plus" + ) + } +} diff --git a/ios/AidenOnTheGo/Auth/KeychainStore.swift b/ios/AidenOnTheGo/Auth/KeychainStore.swift new file mode 100644 index 00000000..dd645be7 --- /dev/null +++ b/ios/AidenOnTheGo/Auth/KeychainStore.swift @@ -0,0 +1,67 @@ +import Foundation +import KeychainAccess + +protocol KeychainStoring { + func save(_ value: String, forKey key: KeychainStore.Key) throws + func load(_ key: KeychainStore.Key) throws -> String? + 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 + // installation's credential. + func save(_ value: String, forKey key: KeychainStore.Key, scope: String) throws + func load(_ key: KeychainStore.Key, scope: String) throws -> String? + func delete(_ key: KeychainStore.Key, scope: String) throws +} + +struct KeychainStore: KeychainStoring { + enum Key: String { + // Aiden Remote installation metadata and the active installation live + // in one Keychain snapshot. Per-device bearer credentials are stored in + // separately scoped entries so switching or removing one installation + // cannot expose or delete another installation's credential. + case remoteInstallations = "aiden_remote_installations" + case remoteCredential = "aiden_remote_credential" + } + + private let keychain: Keychain + + init(service: String? = nil) { + let service = service + ?? Bundle.main.object(forInfoDictionaryKey: "AidenKeychainService") as? String + ?? Bundle.main.bundleIdentifier + ?? "sbtbiswas.AidenOnTheGo.pairing" + self.keychain = Keychain(service: service) + .accessibility(.afterFirstUnlockThisDeviceOnly) + } + + func save(_ value: String, forKey key: Key) throws { + try keychain.set(value, key: key.rawValue) + } + + func load(_ key: Key) throws -> String? { + try keychain.get(key.rawValue) + } + + func delete(_ key: Key) throws { + try keychain.remove(key.rawValue) + } + + func save(_ value: String, forKey key: Key, scope: String) throws { + try keychain.set(value, key: Self.scopedKey(key, scope: scope)) + } + + func load(_ key: Key, scope: String) throws -> String? { + try keychain.get(Self.scopedKey(key, scope: scope)) + } + + func delete(_ key: Key, scope: String) throws { + try keychain.remove(Self.scopedKey(key, scope: scope)) + } + + /// Namespaces a logical key by an installation scope. `::` cannot appear in + /// the fixed lowercase key names, so the representation is unambiguous. + static func scopedKey(_ key: Key, scope: String) -> String { + "\(key.rawValue)::\(scope)" + } +} diff --git a/ios/AidenOnTheGo/Config/AidenAppearance.swift b/ios/AidenOnTheGo/Config/AidenAppearance.swift new file mode 100644 index 00000000..fbe2fe26 --- /dev/null +++ b/ios/AidenOnTheGo/Config/AidenAppearance.swift @@ -0,0 +1,653 @@ +import CoreHaptics +import Observation +import SwiftUI +import UIKit + +enum AidenHapticEvent: String, Equatable, Sendable { + case selection + case actionStarted + case actionStopped + case success + case warning + case error +} + +@MainActor +protocol AidenHapticEmitting: AnyObject { + func activate(scope: UUID) + func deactivate(scope: UUID) + func emit(_ event: AidenHapticEvent, scope: UUID?, dedupeKey: String?) +} + +extension AidenHapticEmitting { + func play(_ event: AidenHapticEvent, scope: UUID? = nil, dedupeKey: String? = nil) { + emit(event, scope: scope, dedupeKey: dedupeKey) + } +} + +func aidenIsCancellation(_ error: Error) -> Bool { + if error is CancellationError || Task.isCancelled { return true } + return (error as? URLError)?.code == .cancelled +} + +struct AidenHapticPulse: Equatable, Sendable { + let sequence: UInt64 + let event: AidenHapticEvent + let scope: UUID? +} + +/// One semantic haptic lane for Aiden-owned interactions. Native controls keep +/// their system feedback; callers emit only direct actions or authoritative +/// outcomes so reconnects, token streaming, and background refreshes stay quiet. +@MainActor +@Observable +final class AidenHapticCenter { + static let preferenceKey = "aiden.interactionHaptics.enabled" + + private let defaults: UserDefaults + private let isApplicationActive: @MainActor () -> Bool + private let isAudioCaptureActive: @MainActor () -> Bool + private var sequence: UInt64 = 0 + private var dedupeKeys: [String] = [] + private var dedupeKeySet: Set = [] + private var activeScopes: Set = [] + + private(set) var pulse = AidenHapticPulse(sequence: 0, event: .selection, scope: nil) + let supportsHaptics: Bool + var isEnabled: Bool { + didSet { defaults.set(isEnabled, forKey: Self.preferenceKey) } + } + + init( + defaults: UserDefaults = .standard, + isApplicationActive: @escaping @MainActor () -> Bool = { UIApplication.shared.applicationState == .active }, + isAudioCaptureActive: @escaping @MainActor () -> Bool = { AidenComposerAudioCaptureState.shared.isCapturing }, + supportsHaptics: Bool = CHHapticEngine.capabilitiesForHardware().supportsHaptics + ) { + self.defaults = defaults + self.isApplicationActive = isApplicationActive + self.isAudioCaptureActive = isAudioCaptureActive + self.supportsHaptics = supportsHaptics + isEnabled = defaults.object(forKey: Self.preferenceKey) as? Bool ?? true + } + + func activate(scope: UUID) { + activeScopes.insert(scope) + } + + func deactivate(scope: UUID) { + activeScopes.remove(scope) + } + + func emit(_ event: AidenHapticEvent, scope: UUID? = nil, dedupeKey: String? = nil) { + if let dedupeKey { + let semanticKey = "\(event.rawValue):\(dedupeKey)" + guard dedupeKeySet.insert(semanticKey).inserted else { return } + dedupeKeys.append(semanticKey) + if dedupeKeys.count > 128 { + dedupeKeySet.remove(dedupeKeys.removeFirst()) + } + } + guard supportsHaptics, isEnabled, isApplicationActive(), !isAudioCaptureActive() else { return } + if let scope, !activeScopes.contains(scope) { return } + sequence &+= 1 + pulse = AidenHapticPulse(sequence: sequence, event: event, scope: scope) + } + + func shouldDeliverNow(scope: UUID? = nil) -> Bool { + guard supportsHaptics, isEnabled, isApplicationActive(), !isAudioCaptureActive() else { return false } + return scope.map(activeScopes.contains) ?? true + } +} + +extension AidenHapticCenter: AidenHapticEmitting {} + +private struct AidenHapticHost: ViewModifier { + @Bindable var haptics: AidenHapticCenter + + func body(content: Content) -> some View { + content.sensoryFeedback(trigger: haptics.pulse) { _, pulse in + guard haptics.shouldDeliverNow(scope: pulse.scope) else { return nil } + let feedback: SensoryFeedback = switch pulse.event { + case .selection: + .selection + case .actionStarted: + .start + case .actionStopped: + .stop + case .success: + .success + case .warning: + .warning + case .error: + .error + } + return feedback + } + } +} + +extension View { + func aidenHapticHost(_ haptics: AidenHapticCenter) -> some View { + modifier(AidenHapticHost(haptics: haptics)) + } +} + +enum AidenAppearanceMode: String, CaseIterable, Identifiable, Codable, Sendable { + case system + case light + case dark + + var id: String { rawValue } + + var title: String { + switch self { + case .system: "System" + case .light: "Light" + case .dark: "Dark" + } + } + + var colorScheme: ColorScheme? { + switch self { + case .system: nil + case .light: .light + case .dark: .dark + } + } +} + +enum AidenThemePresetID: String, CaseIterable, Identifiable, Codable, Sendable { + case aiden + case slate + case berry + case moss + + var id: String { rawValue } + + var title: String { + switch self { + case .aiden: "Aiden" + case .slate: "Slate" + case .berry: "Berry" + case .moss: "Moss" + } + } +} + +enum AidenUIFontID: String, CaseIterable, Identifiable, Codable, Sendable { + case system + case rounded + case humanist + + var id: String { rawValue } + + var title: String { + switch self { + case .system: "System" + case .rounded: "Rounded" + case .humanist: "Humanist" + } + } + + func font(size: CGFloat) -> Font { + switch self { + case .system: .system(size: size) + case .rounded: .system(size: size, design: .rounded) + case .humanist: .custom("Avenir Next", size: size) + } + } +} + +enum AidenCodeFontID: String, CaseIterable, Identifiable, Codable, Sendable { + case sfMono = "sf-mono" + case menlo + case monaco + + var id: String { rawValue } + + var title: String { + switch self { + case .sfMono: "SF Mono" + case .menlo: "Menlo" + case .monaco: "Monaco" + } + } + + func font(size: CGFloat, relativeTo style: Font.TextStyle = .body) -> Font { + let name = switch self { + case .sfMono: "SFMono-Regular" + case .menlo: "Menlo-Regular" + case .monaco: "Monaco" + } + return .custom(name, size: size, relativeTo: style) + } +} + +enum AidenReduceMotionPreference: String, CaseIterable, Identifiable, Codable, Sendable { + case system + case on + case off + + var id: String { rawValue } + + var title: String { + switch self { + case .system: "Follow System" + case .on: "On" + case .off: "Off" + } + } +} + +enum AidenDiffMarkerPreference: String, CaseIterable, Identifiable, Codable, Sendable { + case color + case symbols + + var id: String { rawValue } + var title: String { self == .symbols ? "Symbols and color" : "Color only" } +} + +struct AidenPalette: Equatable, Sendable { + let canvasHex: String + let sidebarHex: String + let raisedHex: String + let foregroundHex: String + let secondaryHex: String + let accentHex: String + let successHex: String + let warningHex: String + let dangerHex: String + + var canvas: Color { Color(aidenHex: canvasHex) } + var sidebar: Color { Color(aidenHex: sidebarHex) } + var raised: Color { Color(aidenHex: raisedHex) } + var foreground: Color { Color(aidenHex: foregroundHex) } + var secondary: Color { Color(aidenHex: secondaryHex) } + var accent: Color { Color(aidenHex: accentHex) } + var success: Color { Color(aidenHex: successHex) } + var warning: Color { Color(aidenHex: warningHex) } + var danger: Color { Color(aidenHex: dangerHex) } + + func applyingContrast(_ contrast: Int, baseline: Int) -> AidenPalette { + guard contrast != baseline else { return self } + let delta = Double(contrast - baseline) + let secondaryTarget = delta > 0 ? foregroundHex : canvasHex + let fraction = min(abs(delta) / 100 * (delta > 0 ? 0.7 : 0.25), 0.7) + return AidenPalette( + canvasHex: canvasHex, + sidebarHex: sidebarHex, + raisedHex: raisedHex, + foregroundHex: foregroundHex, + secondaryHex: Color.mixHex(secondaryHex, secondaryTarget, fraction: fraction), + accentHex: accentHex, + successHex: successHex, + warningHex: warningHex, + dangerHex: dangerHex + ) + } +} + +enum AidenThemeCatalog { + static func palette(preset: AidenThemePresetID, scheme: ColorScheme) -> AidenPalette { + palettes[preset]![scheme == .dark ? 1 : 0] + } + + static let palettes: [AidenThemePresetID: [AidenPalette]] = [ + .aiden: [ + .init(canvasHex: "#F6F7F9", sidebarHex: "#EEF0F3", raisedHex: "#FFFFFF", foregroundHex: "#3D3F41", secondaryHex: "#6B7280", accentHex: "#006AD6", successHex: "#30D158", warningHex: "#FF9F0A", dangerHex: "#FF453A"), + .init(canvasHex: "#181B21", sidebarHex: "#20242C", raisedHex: "#292E37", foregroundHex: "#D1D4DA", secondaryHex: "#9AA3AE", accentHex: "#3E97F6", successHex: "#32D17A", warningHex: "#FFB020", dangerHex: "#FF5E57"), + ], + .slate: [ + .init(canvasHex: "#F2F5F9", sidebarHex: "#E6EBF2", raisedHex: "#FFFFFF", foregroundHex: "#3A434E", secondaryHex: "#637083", accentHex: "#087581", successHex: "#2DB67D", warningHex: "#E0A72E", dangerHex: "#E24D5B"), + .init(canvasHex: "#181E26", sidebarHex: "#202833", raisedHex: "#29323E", foregroundHex: "#D1D6DE", secondaryHex: "#94A3BB", accentHex: "#21A9BE", successHex: "#35C08A", warningHex: "#D4A72C", dangerHex: "#F87171"), + ], + .berry: [ + .init(canvasHex: "#FBF4F7", sidebarHex: "#F1E8EE", raisedHex: "#FFFFFF", foregroundHex: "#443F4A", secondaryHex: "#6E6470", accentHex: "#B42C70", successHex: "#22C7A8", warningHex: "#E3A23C", dangerHex: "#E24C5A"), + .init(canvasHex: "#1D1822", sidebarHex: "#251D2B", raisedHex: "#2E2435", foregroundHex: "#D5CFD6", secondaryHex: "#A39AA6", accentHex: "#E8629F", successHex: "#32D1B2", warningHex: "#D9A441", dangerHex: "#F0717A"), + ], + .moss: [ + .init(canvasHex: "#F3F6F4", sidebarHex: "#E7ECE8", raisedHex: "#FFFFFF", foregroundHex: "#3F4943", secondaryHex: "#65736B", accentHex: "#157862", successHex: "#3DBF7D", warningHex: "#D4A22A", dangerHex: "#E05353"), + .init(canvasHex: "#18201C", sidebarHex: "#202A25", raisedHex: "#29342E", foregroundHex: "#D1D6D3", secondaryHex: "#95A39B", accentHex: "#42B596", successHex: "#47D18C", warningHex: "#D9B43A", dangerHex: "#EB6B6B"), + ], + ] +} + +struct AidenSidebarLogo: View { + var size: CGFloat = 24 + var color: Color? = nil + + var body: some View { + Image("AidenSidebarLogo") + .renderingMode(.template) + .resizable() + .scaledToFit() + .foregroundStyle(color ?? .primary) + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} + +@MainActor +@Observable +final class AidenAppearanceStore { + private enum Key { + static let mode = "aiden.appearance.mode" + static let lightPreset = "aiden.appearance.lightPreset" + static let darkPreset = "aiden.appearance.darkPreset" + static let lightUIFont = "aiden.appearance.lightUIFont" + static let darkUIFont = "aiden.appearance.darkUIFont" + static let lightCodeFont = "aiden.appearance.lightCodeFont" + static let darkCodeFont = "aiden.appearance.darkCodeFont" + static let lightContrast = "aiden.appearance.lightContrast" + static let darkContrast = "aiden.appearance.darkContrast" + static let lightTranslucentSidebar = "aiden.appearance.lightTranslucentSidebar" + static let darkTranslucentSidebar = "aiden.appearance.darkTranslucentSidebar" + static let reduceMotion = "aiden.appearance.reduceMotion" + static let uiFontSize = "aiden.appearance.uiFontSize" + static let codeFontSize = "aiden.appearance.codeFontSize" + static let diffMarkers = "aiden.appearance.diffMarkers" + } + + private let defaults: UserDefaults + var mode: AidenAppearanceMode { didSet { defaults.set(mode.rawValue, forKey: Key.mode) } } + var lightPreset: AidenThemePresetID { didSet { defaults.set(lightPreset.rawValue, forKey: Key.lightPreset) } } + var darkPreset: AidenThemePresetID { didSet { defaults.set(darkPreset.rawValue, forKey: Key.darkPreset) } } + var lightUIFont: AidenUIFontID { didSet { defaults.set(lightUIFont.rawValue, forKey: Key.lightUIFont) } } + var darkUIFont: AidenUIFontID { didSet { defaults.set(darkUIFont.rawValue, forKey: Key.darkUIFont) } } + var lightCodeFont: AidenCodeFontID { didSet { defaults.set(lightCodeFont.rawValue, forKey: Key.lightCodeFont) } } + var darkCodeFont: AidenCodeFontID { didSet { defaults.set(darkCodeFont.rawValue, forKey: Key.darkCodeFont) } } + var lightContrast: Int { didSet { defaults.set(Self.clamp(lightContrast, to: 0...100), forKey: Key.lightContrast) } } + var darkContrast: Int { didSet { defaults.set(Self.clamp(darkContrast, to: 0...100), forKey: Key.darkContrast) } } + var lightTranslucentSidebar: Bool { didSet { defaults.set(lightTranslucentSidebar, forKey: Key.lightTranslucentSidebar) } } + var darkTranslucentSidebar: Bool { didSet { defaults.set(darkTranslucentSidebar, forKey: Key.darkTranslucentSidebar) } } + var reduceMotion: AidenReduceMotionPreference { didSet { defaults.set(reduceMotion.rawValue, forKey: Key.reduceMotion) } } + var uiFontSize: Int { didSet { defaults.set(Self.clamp(uiFontSize, to: 12...18), forKey: Key.uiFontSize) } } + var codeFontSize: Int { didSet { defaults.set(Self.clamp(codeFontSize, to: 10...18), forKey: Key.codeFontSize) } } + var diffMarkers: AidenDiffMarkerPreference { didSet { defaults.set(diffMarkers.rawValue, forKey: Key.diffMarkers) } } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + mode = AidenAppearanceMode(rawValue: defaults.string(forKey: Key.mode) ?? "") ?? .system + lightPreset = AidenThemePresetID(rawValue: defaults.string(forKey: Key.lightPreset) ?? "") ?? .aiden + darkPreset = AidenThemePresetID(rawValue: defaults.string(forKey: Key.darkPreset) ?? "") ?? .aiden + lightUIFont = AidenUIFontID(rawValue: defaults.string(forKey: Key.lightUIFont) ?? "") ?? .system + darkUIFont = AidenUIFontID(rawValue: defaults.string(forKey: Key.darkUIFont) ?? "") ?? .system + lightCodeFont = AidenCodeFontID(rawValue: defaults.string(forKey: Key.lightCodeFont) ?? "") ?? .sfMono + darkCodeFont = AidenCodeFontID(rawValue: defaults.string(forKey: Key.darkCodeFont) ?? "") ?? .sfMono + lightContrast = defaults.object(forKey: Key.lightContrast) == nil ? 45 : Self.clamp(defaults.integer(forKey: Key.lightContrast), to: 0...100) + darkContrast = defaults.object(forKey: Key.darkContrast) == nil ? 60 : Self.clamp(defaults.integer(forKey: Key.darkContrast), to: 0...100) + lightTranslucentSidebar = defaults.object(forKey: Key.lightTranslucentSidebar) == nil ? true : defaults.bool(forKey: Key.lightTranslucentSidebar) + darkTranslucentSidebar = defaults.object(forKey: Key.darkTranslucentSidebar) == nil ? true : defaults.bool(forKey: Key.darkTranslucentSidebar) + reduceMotion = AidenReduceMotionPreference(rawValue: defaults.string(forKey: Key.reduceMotion) ?? "") ?? .system + uiFontSize = defaults.object(forKey: Key.uiFontSize) == nil ? 14 : Self.clamp(defaults.integer(forKey: Key.uiFontSize), to: 12...18) + codeFontSize = defaults.object(forKey: Key.codeFontSize) == nil ? 12 : Self.clamp(defaults.integer(forKey: Key.codeFontSize), to: 10...18) + diffMarkers = AidenDiffMarkerPreference(rawValue: defaults.string(forKey: Key.diffMarkers) ?? "") ?? .symbols + } + + func palette(for scheme: ColorScheme, systemHighContrast: Bool = false) -> AidenPalette { + let isDark = scheme == .dark + let baseline = isDark ? 60 : 45 + let requested = (isDark ? darkContrast : lightContrast) + (systemHighContrast ? 20 : 0) + return AidenThemeCatalog + .palette(preset: isDark ? darkPreset : lightPreset, scheme: scheme) + .applyingContrast(Self.clamp(requested, to: 0...100), baseline: baseline) + } + + func uiFont(for scheme: ColorScheme) -> AidenUIFontID { scheme == .dark ? darkUIFont : lightUIFont } + func codeFont(for scheme: ColorScheme) -> AidenCodeFontID { scheme == .dark ? darkCodeFont : lightCodeFont } + func translucentSidebar(for scheme: ColorScheme) -> Bool { scheme == .dark ? darkTranslucentSidebar : lightTranslucentSidebar } + + func resolvedReduceMotion(system: Bool) -> Bool { + switch reduceMotion { + case .system: system + case .on: true + case .off: false + } + } + + private static func clamp(_ value: Int, to range: ClosedRange) -> Int { + min(max(value, range.lowerBound), range.upperBound) + } +} + +private struct AidenPaletteEnvironmentKey: EnvironmentKey { + static let defaultValue = AidenThemeCatalog.palette(preset: .aiden, scheme: .light) +} + +private struct AidenReduceMotionEnvironmentKey: EnvironmentKey { static let defaultValue = false } +private struct AidenDiffMarkerEnvironmentKey: EnvironmentKey { static let defaultValue = AidenDiffMarkerPreference.symbols } +private struct AidenSidebarTranslucencyEnvironmentKey: EnvironmentKey { static let defaultValue = true } +private struct AidenCodeTypographyEnvironmentKey: EnvironmentKey { + static let defaultValue = AidenCodeTypography(font: .sfMono, size: 12) +} + +struct AidenCodeTypography: Equatable, Sendable { + let font: AidenCodeFontID + let size: Int + + func swiftUIFont(relativeTo style: Font.TextStyle = .body) -> Font { + font.font(size: CGFloat(size), relativeTo: style) + } +} + +extension EnvironmentValues { + var aidenPalette: AidenPalette { + get { self[AidenPaletteEnvironmentKey.self] } + set { self[AidenPaletteEnvironmentKey.self] = newValue } + } + + var aidenReduceMotion: Bool { + get { self[AidenReduceMotionEnvironmentKey.self] } + set { self[AidenReduceMotionEnvironmentKey.self] = newValue } + } + + var aidenDiffMarkers: AidenDiffMarkerPreference { + get { self[AidenDiffMarkerEnvironmentKey.self] } + set { self[AidenDiffMarkerEnvironmentKey.self] = newValue } + } + + var aidenSidebarTranslucent: Bool { + get { self[AidenSidebarTranslucencyEnvironmentKey.self] } + set { self[AidenSidebarTranslucencyEnvironmentKey.self] = newValue } + } + + var aidenCodeTypography: AidenCodeTypography { + get { self[AidenCodeTypographyEnvironmentKey.self] } + set { self[AidenCodeTypographyEnvironmentKey.self] = newValue } + } +} + +struct AidenAppearanceRoot: View { + @Bindable var appearance: AidenAppearanceStore + @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var colorSchemeContrast + @Environment(\.accessibilityReduceMotion) private var systemReduceMotion + @ViewBuilder let content: () -> Content + + var body: some View { + let effectiveScheme: ColorScheme = switch appearance.mode { + case .system: colorScheme + case .light: .light + case .dark: .dark + } + let palette = appearance.palette( + for: effectiveScheme, + systemHighContrast: colorSchemeContrast == .increased + ) + content() + .environment(\.aidenPalette, palette) + .environment(\.aidenReduceMotion, appearance.resolvedReduceMotion(system: systemReduceMotion)) + .environment(\.aidenDiffMarkers, appearance.diffMarkers) + .environment(\.aidenSidebarTranslucent, appearance.translucentSidebar(for: effectiveScheme)) + .environment(\.aidenCodeTypography, AidenCodeTypography(font: appearance.codeFont(for: effectiveScheme), size: appearance.codeFontSize)) + .modifier(AidenUITypographyModifier(font: appearance.uiFont(for: effectiveScheme), size: appearance.uiFontSize)) + .preferredColorScheme(appearance.mode.colorScheme) + .tint(palette.accent) + .background(palette.canvas.ignoresSafeArea()) + } +} + +struct AidenAppearanceSettingsView: View { + @Environment(\.dismiss) private var dismiss + @Bindable var appearance: AidenAppearanceStore + @AppStorage(AidenRemoteLiveActivityManager.responseExcerptPreferenceKey) + private var showsLiveActivityResponseExcerpts = false + + var body: some View { + NavigationStack { + Form { + Section("Appearance") { + Picker("Mode", selection: $appearance.mode) { + ForEach(AidenAppearanceMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + } + + Section("Light Style") { + presetPicker(selection: $appearance.lightPreset, scheme: .light) + variantControls( + uiFont: $appearance.lightUIFont, + codeFont: $appearance.lightCodeFont, + contrast: $appearance.lightContrast, + translucentSidebar: $appearance.lightTranslucentSidebar + ) + } + + Section("Dark Style") { + presetPicker(selection: $appearance.darkPreset, scheme: .dark) + variantControls( + uiFont: $appearance.darkUIFont, + codeFont: $appearance.darkCodeFont, + contrast: $appearance.darkContrast, + translucentSidebar: $appearance.darkTranslucentSidebar + ) + } + + Section("Text and Motion") { + Stepper("UI size: \(appearance.uiFontSize)", value: $appearance.uiFontSize, in: 12...18) + Stepper("Code size: \(appearance.codeFontSize)", value: $appearance.codeFontSize, in: 10...18) + Picker("Reduce Motion", selection: $appearance.reduceMotion) { + ForEach(AidenReduceMotionPreference.allCases) { preference in + Text(preference.title).tag(preference) + } + } + Picker("Diff markers", selection: $appearance.diffMarkers) { + ForEach(AidenDiffMarkerPreference.allCases) { preference in + Text(preference.title).tag(preference) + } + } + } + + Section { + Toggle("Show response excerpts on Lock Screen", isOn: $showsLiveActivityResponseExcerpts) + } header: { + Text("Privacy") + } footer: { + Text("Off by default. Live Activities otherwise show only the chat title and bounded status.") + } + } + .navigationTitle("Appearance") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } + + @ViewBuilder + private func variantControls( + uiFont: Binding, + codeFont: Binding, + contrast: Binding, + translucentSidebar: Binding + ) -> some View { + Picker("UI font", selection: uiFont) { + ForEach(AidenUIFontID.allCases) { font in Text(font.title).tag(font) } + } + Picker("Code font", selection: codeFont) { + ForEach(AidenCodeFontID.allCases) { font in Text(font.title).tag(font) } + } + VStack(alignment: .leading) { + HStack { + Text("Contrast") + Spacer() + Text("\(contrast.wrappedValue)").foregroundStyle(.secondary) + } + Slider( + value: Binding( + get: { Double(contrast.wrappedValue) }, + set: { contrast.wrappedValue = Int($0.rounded()) } + ), + in: 0...100, + step: 1 + ) + .accessibilityLabel("Contrast") + .accessibilityValue("\(contrast.wrappedValue) percent") + } + Toggle("Translucent sidebar", isOn: translucentSidebar) + } + + private func presetPicker( + selection: Binding, + scheme: ColorScheme + ) -> some View { + Picker("Style", selection: selection) { + ForEach(AidenThemePresetID.allCases) { preset in + let palette = AidenThemeCatalog.palette(preset: preset, scheme: scheme) + Label { + Text(preset.title) + } icon: { + Image(systemName: "circle.fill").foregroundStyle(palette.accent) + } + .tag(preset) + } + } + .pickerStyle(.inline) + .labelsHidden() + } +} + +private struct AidenUITypographyModifier: ViewModifier { + let font: AidenUIFontID + @ScaledMetric(relativeTo: .body) private var scaledSize: CGFloat = 14 + + init(font: AidenUIFontID, size: Int) { + self.font = font + _scaledSize = ScaledMetric(wrappedValue: CGFloat(size), relativeTo: .body) + } + + func body(content: Content) -> some View { + content.font(font.font(size: scaledSize)) + } +} + +extension Color { + init(aidenHex: String) { + let hex = aidenHex.hasPrefix("#") ? String(aidenHex.dropFirst()) : aidenHex + let value = UInt64(hex, radix: 16) ?? 0 + self.init( + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255 + ) + } + + fileprivate static func mixHex(_ from: String, _ to: String, fraction: Double) -> String { + let start = rgb(from) + let end = rgb(to) + let amount = min(max(fraction, 0), 1) + let channels = zip(start, end).map { Int((Double($0.0) + (Double($0.1) - Double($0.0)) * amount).rounded()) } + return String(format: "#%02X%02X%02X", channels[0], channels[1], channels[2]) + } + + private static func rgb(_ hex: String) -> [Int] { + let clean = hex.hasPrefix("#") ? String(hex.dropFirst()) : hex + let value = Int(clean, radix: 16) ?? 0 + return [(value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF] + } +} diff --git a/ios/AidenOnTheGo/Config/AppConfig.swift b/ios/AidenOnTheGo/Config/AppConfig.swift new file mode 100644 index 00000000..9bdb5f1d --- /dev/null +++ b/ios/AidenOnTheGo/Config/AppConfig.swift @@ -0,0 +1,21 @@ +import Foundation + +enum AppConfig { + static var appGroupIdentifier: String { + Bundle.main.object(forInfoDictionaryKey: "AidenAppGroupIdentifier") as? String + ?? "group.sbtbiswas.AidenOnTheGo" + } + + static let privacyPolicyURL = URL(staticString: "https://chatwithaiden.com/privacy") + static let supportURL = URL(staticString: "https://chatwithaiden.com/") +} + +extension URL { + init(staticString string: StaticString) { + let value = string.withUTF8Buffer { String(decoding: $0, as: UTF8.self) } + guard let url = URL(string: value) else { + preconditionFailure("Invalid static URL literal: \(value)") + } + self = url + } +} diff --git a/ios/AidenOnTheGo/ContentView.swift b/ios/AidenOnTheGo/ContentView.swift new file mode 100644 index 00000000..42d51838 --- /dev/null +++ b/ios/AidenOnTheGo/ContentView.swift @@ -0,0 +1,89 @@ +import SwiftUI + +struct ContentView: View { + @Bindable var coordinator: AidenRemoteCoordinator + @Environment(\.scenePhase) private var scenePhase + @AppStorage("aiden.mobileOnboarding.v1.complete") private var hasCompletedMobileOnboarding = false + @State private var navigationRequest: AidenNavigationRequest? + + var body: some View { + Group { + switch coordinator.connectionState { + case .needsPairing: + AidenPairingView( + coordinator: coordinator, + showsIntroduction: !hasCompletedMobileOnboarding, + onIntroductionComplete: { + hasCompletedMobileOnboarding = true + } + ) + case .connecting, .connected, .offline: + AidenWorkspaceShellView( + coordinator: coordinator, + navigationRequest: $navigationRequest + ) + } + } + .task { await coordinator.start() } + .onOpenURL { url in + guard let request = AidenDeepLink.request(from: url) else { + coordinator.presentedError = String(localized: "That Aiden link is invalid or no longer supported.") + return + } + Task { await open(request) } + } + .onChange(of: scenePhase) { _, phase in + switch phase { + case .active: + guard coordinator.connectionState != .needsPairing else { return } + Task { + await coordinator.connectActiveInstallation() + if let context = try? coordinator.requestContext(), + let client = try? coordinator.remoteClient(for: context) { + await AidenRemoteLiveActivityManager.shared.reconcile( + instanceID: context.instanceId, + client: client, + isCurrent: { coordinator.isCurrent(context) } + ) + } + } + case .background: + Task { await AidenRemoteLiveActivityManager.shared.markAllStale() } + default: + break + } + } + } + + @MainActor + private func open(_ request: AidenNavigationRequest) async { + if let requestedInstance = request.instanceId { + guard coordinator.installationStore.installations.contains(where: { $0.id == requestedInstance }) else { + coordinator.presentedError = String(localized: "This Aiden installation is no longer paired. Pair it again to continue.") + return + } + if coordinator.activeInstanceId != requestedInstance { + await coordinator.switchInstallation(to: requestedInstance) + } + } else if coordinator.connectionState != .connected { + await coordinator.connectActiveInstallation() + } + + guard coordinator.connectionState == .connected else { + coordinator.presentedError = String(localized: "Connect to Aiden Agent before opening this link.") + return + } + navigationRequest = request + } +} + +#Preview { + let appearance = AidenAppearanceStore() + let haptics = AidenHapticCenter() + AidenAppearanceRoot(appearance: appearance) { + ContentView(coordinator: AidenRemoteCoordinator(haptics: haptics)) + } + .environment(appearance) + .environment(haptics) + .aidenHapticHost(haptics) +} diff --git a/ios/AidenOnTheGo/Features/Chat/ComposerVoiceInputController.swift b/ios/AidenOnTheGo/Features/Chat/ComposerVoiceInputController.swift new file mode 100644 index 00000000..2f5bfcba --- /dev/null +++ b/ios/AidenOnTheGo/Features/Chat/ComposerVoiceInputController.swift @@ -0,0 +1,394 @@ +import AVFoundation +import Foundation +import Observation +import OSLog +import Speech +import UIKit + +/// On-device-only speech input for the composer. Audio never leaves the device. +@MainActor +@Observable +final class ComposerVoiceInputController { + enum State: Equatable { + case idle + case requestingPermission + case listening + } + + private(set) var state: State = .idle + private(set) var errorMessage: String? + private(set) var liveTranscript = "" + + private let speechRecognizerFactory: () -> SFSpeechRecognizer? + private let audioEngineFactory: () -> AVAudioEngine + private var speechRecognizer: SFSpeechRecognizer? + private var audioEngine: AVAudioEngine? + private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? + private var recognitionTask: SFSpeechRecognitionTask? + private var draftUpdateSession = ComposerVoiceDraftUpdateSession() + private var updateDraft: ((String) -> Void)? + private var suppressNextRecognitionError = false + private var activatedAudioSessionForRecording = false + private var audioTapInstalled = false + private let logger = Logger.aidenVoiceInput + + @ObservationIgnored var locale = Locale.current + + init( + speechRecognizerFactory: @escaping () -> SFSpeechRecognizer? = { + SFSpeechRecognizer(locale: Locale.current) + }, + audioEngineFactory: @escaping () -> AVAudioEngine = { AVAudioEngine() } + ) { + self.speechRecognizerFactory = speechRecognizerFactory + self.audioEngineFactory = audioEngineFactory + } + + var isListening: Bool { state == .listening } + var isRequestingPermission: Bool { state == .requestingPermission } + + func toggle(currentDraft: String, updateDraft: @escaping (String) -> Void) async { + if isListening { + stopKeepingTranscript() + } else { + await start(currentDraft: currentDraft, updateDraft: updateDraft) + } + } + + func stopKeepingTranscript() { + suppressNextRecognitionError = true + stopAcceptingDraftUpdates() + stopAudio(cancelTask: false) + state = .idle + } + + func stopBeforeSubmittingDraft() { + suppressNextRecognitionError = true + stopAcceptingDraftUpdates() + stopAudio(cancelTask: true) + state = .idle + } + + private func start(currentDraft: String, updateDraft: @escaping (String) -> Void) async { + guard state == .idle else { return } + + errorMessage = nil + liveTranscript = "" + suppressNextRecognitionError = false + draftUpdateSession.begin(baseDraft: currentDraft) + self.updateDraft = updateDraft + state = .requestingPermission + + guard let speechRecognizer = onDeviceSpeechRecognizerForRecording() else { + fail( + String(localized: "On-device speech recognition is not available for the current locale."), + logCategory: .speechUnavailable + ) + return + } + + let speechStatus = await requestSpeechAuthorization() + guard state == .requestingPermission else { return } + guard speechStatus == .authorized else { + fail(Self.speechAuthorizationMessage(for: speechStatus), logCategory: .speechAuthorization) + return + } + + let microphoneGranted = await ComposerVoiceMicrophonePermissionRequester.request() + guard state == .requestingPermission else { return } + guard microphoneGranted else { + fail( + String(localized: "Microphone access is disabled. Enable it in Settings to use voice input."), + logCategory: .microphonePermission + ) + return + } + + guard ComposerVoiceInputStartPolicy.canStart( + appIsActive: UIApplication.shared.applicationState == .active + ) else { + fail(ComposerVoiceInputError.appNotActive.localizedDescription, logCategory: .appNotActive) + return + } + + do { + try startRecognition(speechRecognizer: speechRecognizer) + state = .listening + } catch { + fail(error.localizedDescription, logCategory: Self.logCategory(for: error)) + } + } + + private func startRecognition(speechRecognizer: SFSpeechRecognizer) throws { + stopAudio(cancelTask: true) + + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory( + ComposerVoiceAudioSessionConfiguration.category, + mode: ComposerVoiceAudioSessionConfiguration.mode, + options: ComposerVoiceAudioSessionConfiguration.options + ) + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + activatedAudioSessionForRecording = true + try ComposerVoiceInputStartPolicy.validateAudioSessionInput( + isInputAvailable: audioSession.isInputAvailable, + sampleRate: audioSession.sampleRate, + inputNumberOfChannels: audioSession.inputNumberOfChannels + ) + + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + request.requiresOnDeviceRecognition = true + recognitionRequest = request + + let engine = audioEngineFactory() + audioEngine = engine + try ComposerVoiceInputStartPolicy.validateAudioEngine(isRunning: engine.isRunning) + + let inputNode = engine.inputNode + let recordingFormat = inputNode.outputFormat(forBus: 0) + try ComposerVoiceInputPreflight.validate(recordingFormat: recordingFormat) + inputNode.installTap(onBus: 0, bufferSize: 1_024, format: recordingFormat) { [weak request] buffer, _ in + request?.append(buffer) + } + audioTapInstalled = true + + engine.prepare() + recognitionTask = speechRecognizer.recognitionTask(with: request) { [weak self] result, error in + Task { @MainActor in self?.handleRecognition(result: result, error: error) } + } + try engine.start() + AidenComposerAudioCaptureState.shared.setCapturing(true) + } + + private func handleRecognition(result: SFSpeechRecognitionResult?, error: Error?) { + if let result { + liveTranscript = result.bestTranscription.formattedString + if let draft = draftUpdateSession.composedDraft(for: liveTranscript) { + updateDraft?(draft) + } + } + + if let error { + stopAcceptingDraftUpdates() + stopAudio(cancelTask: false) + state = .idle + if suppressNextRecognitionError { + suppressNextRecognitionError = false + } else { + errorMessage = error.localizedDescription + } + } else if result?.isFinal == true { + stopAcceptingDraftUpdates() + stopAudio(cancelTask: false) + state = .idle + suppressNextRecognitionError = false + } + } + + private func stopAcceptingDraftUpdates() { + draftUpdateSession.stopAcceptingUpdates() + updateDraft = nil + } + + private func stopAudio(cancelTask: Bool) { + AidenComposerAudioCaptureState.shared.setCapturing(false) + if let audioEngine { + if audioEngine.isRunning { audioEngine.stop() } + if audioTapInstalled { + audioEngine.inputNode.removeTap(onBus: 0) + audioTapInstalled = false + } + audioEngine.reset() + } + audioEngine = nil + recognitionRequest?.endAudio() + if cancelTask { recognitionTask?.cancel() } + recognitionTask = nil + recognitionRequest = nil + if activatedAudioSessionForRecording { + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + activatedAudioSessionForRecording = false + } + } + + private func onDeviceSpeechRecognizerForRecording() -> SFSpeechRecognizer? { + if let speechRecognizer { + return speechRecognizer.supportsOnDeviceRecognition ? speechRecognizer : nil + } + + let target = Self.normalizedLocaleIdentifier(locale.identifier) + guard SFSpeechRecognizer.supportedLocales().contains(where: { + Self.normalizedLocaleIdentifier($0.identifier) == target + }) else { return nil } + + let recognizer = speechRecognizerFactory() + guard recognizer?.supportsOnDeviceRecognition == true else { return nil } + speechRecognizer = recognizer + return recognizer + } + + private static func normalizedLocaleIdentifier(_ identifier: String) -> String { + identifier.replacingOccurrences(of: "_", with: "-").lowercased() + } + + private func fail(_ message: String, logCategory: VoiceInputFailureLogCategory) { + logger.error("Voice input failed category=\(logCategory.rawValue, privacy: .public)") + suppressNextRecognitionError = false + stopAcceptingDraftUpdates() + stopAudio(cancelTask: true) + state = .idle + errorMessage = message + } + + private func requestSpeechAuthorization() async -> SFSpeechRecognizerAuthorizationStatus { + await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0) } + } + } + + private static func speechAuthorizationMessage(for status: SFSpeechRecognizerAuthorizationStatus) -> String { + switch status { + case .denied: + return String(localized: "Speech recognition is disabled. Enable it in Settings to use voice input.") + case .restricted: + return String(localized: "Speech recognition is restricted on this device.") + case .notDetermined: + return String(localized: "Speech recognition permission was not granted.") + case .authorized: + return "" + @unknown default: + return String(localized: "Speech recognition is not available right now.") + } + } + + private static func logCategory(for error: Error) -> VoiceInputFailureLogCategory { + guard let voiceError = error as? ComposerVoiceInputError else { return .audioStartup } + switch voiceError { + case .noAudioInput: return .noAudioInput + case .invalidInputFormat: return .invalidInputFormat + case .appNotActive: return .appNotActive + case .audioEngineAlreadyRunning: return .audioEngineAlreadyRunning + } + } +} + +enum ComposerVoiceMicrophonePermissionRequester { + static func request() async -> Bool { + await withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { continuation.resume(returning: $0) } + } + } +} + +enum ComposerVoiceAudioSessionConfiguration { + static let category = AVAudioSession.Category.playAndRecord + static let mode = AVAudioSession.Mode.measurement + static let options: AVAudioSession.CategoryOptions = [.mixWithOthers, .allowBluetoothHFP] +} + +enum ComposerVoiceInputError: LocalizedError { + case noAudioInput + case invalidInputFormat + case appNotActive + case audioEngineAlreadyRunning + + var errorDescription: String? { + switch self { + case .noAudioInput: + return String(localized: "No microphone input is available. Check the device microphone settings.") + case .invalidInputFormat: + return String(localized: "Voice input is not available because the microphone input format is invalid.") + case .appNotActive: + return String(localized: "Voice input can start only while Aiden On The Go is active.") + case .audioEngineAlreadyRunning: + return String(localized: "Voice input is already preparing the microphone. Try again in a moment.") + } + } +} + +enum VoiceInputFailureLogCategory: String { + case speechUnavailable, speechAuthorization, microphonePermission, appNotActive + case noAudioInput, invalidInputFormat, audioEngineAlreadyRunning, audioStartup +} + +enum ComposerVoiceInputStartPolicy { + static func canStart(appIsActive: Bool) -> Bool { appIsActive } + + static func validateAudioSessionInput( + isInputAvailable: Bool, + sampleRate: Double, + inputNumberOfChannels: Int + ) throws { + guard isInputAvailable else { throw ComposerVoiceInputError.noAudioInput } + try ComposerVoiceInputPreflight.validate( + sampleRate: sampleRate, + channelCount: UInt32(max(inputNumberOfChannels, 0)) + ) + } + + static func validateAudioEngine(isRunning: Bool) throws { + guard !isRunning else { throw ComposerVoiceInputError.audioEngineAlreadyRunning } + } +} + +enum ComposerVoiceInputPreflight { + static let validSampleRateRange: ClosedRange = 8_000...192_000 + static let validChannelCountRange: ClosedRange = 1...16 + + static func validate(sampleRate: Double, channelCount: UInt32) throws { + guard sampleRate.isFinite, + validSampleRateRange.contains(sampleRate), + validChannelCountRange.contains(channelCount) + else { throw ComposerVoiceInputError.invalidInputFormat } + } + + static func validate(recordingFormat: AVAudioFormat) throws { + try validate(sampleRate: recordingFormat.sampleRate, channelCount: recordingFormat.channelCount) + } +} + +enum ComposerVoiceDraftComposer { + static func composedDraft(baseDraft: String, transcript: String) -> String { + let transcript = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + guard !transcript.isEmpty else { return baseDraft } + let draft = baseDraft.trimmingCharacters(in: .whitespacesAndNewlines) + return draft.isEmpty ? transcript : "\(draft) \(transcript)" + } +} + +struct ComposerVoiceDraftUpdateSession { + private var baseDraft = "" + private var acceptsUpdates = false + + mutating func begin(baseDraft: String) { + self.baseDraft = baseDraft + acceptsUpdates = true + } + + mutating func stopAcceptingUpdates() { acceptsUpdates = false } + + func composedDraft(for transcript: String) -> String? { + guard acceptsUpdates else { return nil } + return ComposerVoiceDraftComposer.composedDraft(baseDraft: baseDraft, transcript: transcript) + } +} + +private extension Logger { + static let aidenVoiceInput = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "AidenOnTheGo", + category: "VoiceInput" + ) +} + +@MainActor +final class AidenComposerAudioCaptureState { + static let shared = AidenComposerAudioCaptureState() + private(set) var isCapturing = false + + private init() {} + + func setCapturing(_ capturing: Bool) { + isCapturing = capturing + } +} diff --git a/ios/AidenOnTheGo/Features/Remote/AidenChatFeature.swift b/ios/AidenOnTheGo/Features/Remote/AidenChatFeature.swift new file mode 100644 index 00000000..e81edf3e --- /dev/null +++ b/ios/AidenOnTheGo/Features/Remote/AidenChatFeature.swift @@ -0,0 +1,3847 @@ +import AVFoundation +import Accessibility +import CoreTransferable +import CryptoKit +import ImageIO +import MarkdownUI +import Observation +import Photos +import PhotosUI +import SwiftUI +import UniformTypeIdentifiers + +enum AidenStreamFeedbackPolicy: Equatable, Sendable { + case localTurn + case restoredStream + + var allowsFeedback: Bool { self == .localTurn } +} +import UIKit + +enum AidenAttachmentPreparationError: LocalizedError, Equatable { + case invalidImage + case imageTooLarge + case invalidText + case unsupportedTextType + case fileTooLarge + + var errorDescription: String? { + switch self { + case .invalidImage: "That image could not be read." + case .imageTooLarge: "That image is too large to attach." + case .invalidText: "That file is not valid UTF-8 text." + case .unsupportedTextType: "Choose an image, plain text, Markdown, CSV, JSON, XML, YAML, JavaScript, or TypeScript file." + case .fileTooLarge: "That file is too large to attach." + } + } +} + +enum AidenAttachmentPreparation { + static let maximumSourceImageBytes = 32 * 1_048_576 + static let maximumImageBytes = 8 * 1_048_576 + static let maximumImageDimension: CGFloat = 16_384 + static let maximumImagePixels: CGFloat = 40_000_000 + static let maximumTextBytes = 400_000 + static let maximumTextScalars = 100_000 + + static func imageUpload(data: Data, name: String) throws -> AidenAttachmentUpload { + try Task.checkCancellation() + guard !data.isEmpty, data.count <= maximumSourceImageBytes, let image = UIImage(data: data) else { + throw data.count > maximumSourceImageBytes + ? AidenAttachmentPreparationError.imageTooLarge + : AidenAttachmentPreparationError.invalidImage + } + let pixelWidth = image.size.width * image.scale + let pixelHeight = image.size.height * image.scale + guard pixelWidth.isFinite, pixelHeight.isFinite, pixelWidth > 0, pixelHeight > 0 else { + throw AidenAttachmentPreparationError.invalidImage + } + guard pixelWidth <= maximumImageDimension, + pixelHeight <= maximumImageDimension, + pixelWidth * pixelHeight <= maximumImagePixels + else { + throw AidenAttachmentPreparationError.imageTooLarge + } + if data.count <= maximumImageBytes { + if AidenAttachmentImageValidation.validatedData( + data, + mimeType: "image/png", + declaredSize: data.count + ) != nil { + return .image(name: safeImageName(name, extension: "png"), mimeType: "image/png", data: data) + } + if AidenAttachmentImageValidation.validatedData( + data, + mimeType: "image/jpeg", + declaredSize: data.count + ) != nil { + return .image(name: safeImageName(name, extension: "jpg"), mimeType: "image/jpeg", data: data) + } + } + let preserveAlpha = hasAlpha(image) + for edge in [3_072.0, 2_048.0, 1_536.0, 1_024.0] { + try Task.checkCancellation() + let rendered = scaled(image, maximumEdge: edge, preserveAlpha: preserveAlpha) + if preserveAlpha, + let encoded = rendered.pngData(), + encoded.count <= maximumImageBytes { + return .image(name: safeImageName(name, extension: "png"), mimeType: "image/png", data: encoded) + } + guard !preserveAlpha else { continue } + for quality in [0.86, 0.72, 0.58] { + try Task.checkCancellation() + if let encoded = rendered.jpegData(compressionQuality: quality), encoded.count <= maximumImageBytes { + return .image(name: safeImageName(name, extension: "jpg"), mimeType: "image/jpeg", data: encoded) + } + } + } + throw AidenAttachmentPreparationError.imageTooLarge + } + + static func textUpload(data: Data, name: String, mimeType: String) throws -> AidenAttachmentUpload { + guard data.count <= maximumTextBytes else { throw AidenAttachmentPreparationError.fileTooLarge } + guard let text = String(data: data, encoding: .utf8) else { + throw AidenAttachmentPreparationError.invalidText + } + guard text.unicodeScalars.count <= maximumTextScalars else { + throw AidenAttachmentPreparationError.fileTooLarge + } + let canonicalMimeType = try allowedTextMimeType(mimeType, name: name) + return .text(name: safeDisplayName(name), mimeType: canonicalMimeType, text: text) + } + + static func fileUpload( + url: URL, + preferredName: String? = nil, + forceImage: Bool = false + ) throws -> AidenAttachmentUpload { + try Task.checkCancellation() + let accessed = url.startAccessingSecurityScopedResource() + defer { if accessed { url.stopAccessingSecurityScopedResource() } } + let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentTypeKey]) + let isImage = forceImage || values.contentType?.conforms(to: .image) == true + let displayName = preferredName ?? url.lastPathComponent + let readLimit = isImage ? maximumSourceImageBytes : maximumTextBytes + if isImage, let fileSize = values.fileSize, fileSize > readLimit { + throw AidenAttachmentPreparationError.fileTooLarge + } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let data = try handle.read(upToCount: readLimit + 1) ?? Data() + try Task.checkCancellation() + if isImage { + guard data.count <= readLimit else { throw AidenAttachmentPreparationError.fileTooLarge } + return try imageUpload(data: data, name: displayName) + } + let mimeType = try allowedTextMimeType( + values.contentType?.preferredMIMEType ?? "text/plain", + name: displayName + ) + let readWasTruncated = data.count > maximumTextBytes || (values.fileSize ?? 0) > maximumTextBytes + let prefix = Data(data.prefix(maximumTextBytes)) + guard let decoded = decodedUTF8Prefix(prefix, allowTrailingPartialScalar: readWasTruncated) else { + throw AidenAttachmentPreparationError.invalidText + } + let suffix = "\n… [truncated]" + let scalars = decoded.unicodeScalars + let scalarWasTruncated = scalars.count > maximumTextScalars + let shouldTruncate = readWasTruncated || scalarWasTruncated + let maximumContentScalars = shouldTruncate + ? maximumTextScalars - suffix.unicodeScalars.count + : maximumTextScalars + let bounded = String(String.UnicodeScalarView(scalars.prefix(maximumContentScalars))) + return .text( + name: safeDisplayName(displayName), + mimeType: mimeType, + text: shouldTruncate ? bounded + suffix : bounded + ) + } + + static func fileUploadAsync( + url: URL, + preferredName: String? = nil, + forceImage: Bool = false + ) async throws -> AidenAttachmentUpload { + let worker = Task.detached(priority: .userInitiated) { + try fileUpload(url: url, preferredName: preferredName, forceImage: forceImage) + } + return try await withTaskCancellationHandler { + try await worker.value + } onCancel: { + worker.cancel() + } + } + + private static func decodedUTF8Prefix(_ data: Data, allowTrailingPartialScalar: Bool) -> String? { + if let exact = String(data: data, encoding: .utf8) { return exact } + guard allowTrailingPartialScalar else { return nil } + for count in 1...3 where data.count >= count { + if let value = String(data: data.dropLast(count), encoding: .utf8) { return value } + } + return nil + } + + private static func scaled(_ image: UIImage, maximumEdge: CGFloat, preserveAlpha: Bool) -> UIImage { + let sourceSize = image.size + let sourceEdge = max(sourceSize.width, sourceSize.height) + guard sourceEdge > maximumEdge, sourceSize.width > 0, sourceSize.height > 0 else { return image } + let scale = maximumEdge / sourceEdge + let target = CGSize( + width: max(1, floor(sourceSize.width * scale)), + height: max(1, floor(sourceSize.height * scale)) + ) + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + format.opaque = !preserveAlpha + return UIGraphicsImageRenderer(size: target, format: format).image { _ in + image.draw(in: CGRect(origin: .zero, size: target)) + } + } + + private static func hasAlpha(_ image: UIImage) -> Bool { + guard let alphaInfo = image.cgImage?.alphaInfo else { return true } + return [.first, .last, .premultipliedFirst, .premultipliedLast].contains(alphaInfo) + } + + private static func allowedTextMimeType(_ value: String, name: String) throws -> String { + let normalized = value.lowercased() + let allowed: Set = [ + "text/plain", "text/markdown", "text/csv", "application/json", "application/xml", + "application/yaml", "application/x-yaml", "application/javascript", "application/typescript", + ] + if allowed.contains(normalized) { return normalized } + switch URL(fileURLWithPath: name).pathExtension.lowercased() { + case "md", "markdown": return "text/markdown" + case "csv": return "text/csv" + case "json": return "application/json" + case "xml": return "application/xml" + case "yaml", "yml": return "application/yaml" + case "js", "jsx": return "application/javascript" + case "ts", "tsx": return "application/typescript" + case "txt", "swift", "m", "mm", "h", "c", "cc", "cpp", "py", "rb", "go", "rs", "java", "kt", "sh": + return "text/plain" + default: throw AidenAttachmentPreparationError.unsupportedTextType + } + } + + private static func safeImageName(_ value: String, extension pathExtension: String) -> String { + let base = URL(fileURLWithPath: safeDisplayName(value)).deletingPathExtension().lastPathComponent + return safeDisplayName("\(base.isEmpty ? "Photo" : base).\(pathExtension)") + } + + private static func safeDisplayName(_ value: String) -> String { + let filtered = value.unicodeScalars.filter { scalar in + scalar.value > 0x1f && scalar.value != 0x7f && scalar != "/" && scalar != "\\" + } + let bounded = String(String.UnicodeScalarView(filtered.prefix(255))).trimmingCharacters(in: .whitespacesAndNewlines) + return bounded.isEmpty ? "Attachment" : bounded + } +} + +private struct AidenPickedImageFile: Transferable, Sendable { + let url: URL + let name: String + + static var transferRepresentation: some TransferRepresentation { + FileRepresentation(importedContentType: .image) { received in + let source = received.file + let values = try source.resourceValues(forKeys: [.fileSizeKey]) + guard let size = values.fileSize, + size > 0, + size <= AidenAttachmentPreparation.maximumSourceImageBytes + else { throw AidenAttachmentPreparationError.imageTooLarge } + let originalName = source.lastPathComponent + let destinationBase = FileManager.default.temporaryDirectory + .appending(path: "AidenPickedImage-\(UUID().uuidString)") + let destination = source.pathExtension.isEmpty + ? destinationBase + : destinationBase.appendingPathExtension(source.pathExtension) + try FileManager.default.copyItem(at: source, to: destination) + return Self(url: destination, name: originalName) + } + } +} + +struct AidenTurnAttemptTracker { + private var pending: (request: AidenTurnStart, key: UUID)? + + mutating func key(for request: AidenTurnStart) -> UUID { + if let pending, pending.request == request { return pending.key } + let key = UUID() + pending = (request, key) + return key + } + + mutating func reset() { + pending = nil + } +} + +enum AidenTurnRequestBuilder { + static func make( + text: String, + providerId: String?, + modelId: String?, + thinkingLevel: String?, + attachments: [AidenAttachmentReference] + ) -> AidenTurnStart { + AidenTurnStart( + text: text, + providerId: providerId, + modelId: modelId, + thinkingLevel: thinkingLevel, + attachmentIds: attachments.isEmpty ? nil : attachments.map(\.id) + ) + } +} + +enum AidenChatTitleReconciliation { + // Apple Foundation Models titles are deliberately generated off the critical + // chat path. Keep reconciliation bounded to the server's 15-second title window. + static let retryMilliseconds = [400, 800, 1_200, 2_000, 3_000, 3_500, 3_500] +} + +struct AidenTerminalReplayGate { + private(set) var hasReplayedTerminalCursor = false + + mutating func shouldReplay(_ state: AidenStreamState) -> Bool { + guard state.isTerminal, !hasReplayedTerminalCursor else { return false } + hasReplayedTerminalCursor = true + return true + } +} + +enum AidenTerminalReconciliation { + static func retryDelayMilliseconds(attempt: Int) -> Int { + let safeAttempt = max(0, min(attempt, 5)) + return min(30_000, 1_000 * (1 << safeAttempt)) + } + + static func isDefinitiveMissingStream(_ error: Error) -> Bool { + guard let clientError = error as? AidenRemoteClientError else { return false } + guard case .server(let statusCode, let body) = clientError, statusCode == 404 else { + return false + } + return body.code.rawValue == "stream_gone" || body.code.rawValue == "not_found" + } +} + +enum AidenAttachmentGalleryWindow { + static func contains(index: Int, selectedIndex: Int, count: Int) -> Bool { + guard count > 0, + (0.. CGFloat { + guard count > 1 else { return 0 } + let isPastLeadingEdge = current <= 0 && translation > 0 + let isPastTrailingEdge = current >= count - 1 && translation < 0 + return isPastLeadingEdge || isPastTrailingEdge + ? translation * edgeResistance + : translation + } + + static func dragProgress(translation: CGFloat, width: CGFloat) -> CGFloat { + guard width > 0 else { return 0 } + return min(max(-translation / width, -1), 1) + } + + static func selectedCardOffset(translation: CGFloat) -> CGFloat { + translation * selectedCardDragMultiplier + } + + static func preferredBackgroundIndex( + selection: Int, + count: Int, + translation: CGFloat + ) -> Int? { + guard count > 1, (0.. 0 ? selection - 1 : selection + 1 + if (0.. 0 ? selection + 1 : selection - 1 + return (0.. Bool { + guard count > 1, + (0.. Int { + guard count > 1 else { return 0 } + let effectiveTranslation = abs(predictedTranslation) > abs(translation) + ? predictedTranslation + : translation + guard abs(translation) >= 44 || abs(effectiveTranslation) >= 80 else { + return min(max(current, 0), count - 1) + } + let direction = effectiveTranslation < 0 ? 1 : -1 + return min(max(current + direction, 0), count - 1) + } +} + +enum AidenMessageMediaEdge: Equatable { + case leading + case trailing + + static func forRole(_ role: AidenChatRole) -> Self { + role == .user ? .trailing : .leading + } + + var alignment: Alignment { + self == .trailing ? .trailing : .leading + } + + var scaleAnchor: UnitPoint { + self == .trailing ? .trailing : .leading + } + + var rotationAnchor: UnitPoint { + self == .trailing ? .bottomTrailing : .bottomLeading + } + + var backgroundRotationDegrees: Double { + self == .trailing ? -1.8 : 1.8 + } +} + +enum AidenMessageContentSurface { + case text + case imageAttachment + case fallbackAttachment + + static func usesRaisedBubble(role: AidenChatRole, content: Self) -> Bool { + guard role == .user else { return false } + return content != .imageAttachment + } +} + +enum AidenMissingStreamResolution: Equatable { + case complete + case failed + case cancelled + case interrupted + + static func resolve(messages: [AidenChatMessage]) -> Self { + guard let userIndex = messages.lastIndex(where: { $0.role == .user }), + userIndex < messages.index(before: messages.endIndex), + let assistant = messages[messages.index(after: userIndex)...] + .first(where: { $0.role == .assistant }) + else { return .interrupted } + switch assistant.outcome?.status { + case .cancelled: return .cancelled + case .failed: return .failed + case nil: return .complete + } + } +} + +enum AidenStreamFeedbackDecision { + static func announcesApproval(_ policy: AidenStreamFeedbackPolicy) -> Bool { + policy.allowsFeedback + } + + static func terminalEvent( + for resolution: AidenMissingStreamResolution, + policy: AidenStreamFeedbackPolicy + ) -> AidenHapticEvent? { + guard policy.allowsFeedback else { return nil } + switch resolution { + case .failed, .interrupted: return .error + case .complete, .cancelled: return nil + } + } +} + +@MainActor +@Observable +final class AidenWorkspaceChatsModel { + private let coordinator: AidenRemoteCoordinator + private let workspaceId: String + private let cache: AidenChatCache + private let hapticScope: UUID + private(set) var chats: [AidenChat] = [] + private(set) var isLoading = false + private(set) var isMutating = false + var presentedError: String? + + init( + coordinator: AidenRemoteCoordinator, + workspaceId: String, + hapticScope: UUID = UUID(), + cache: AidenChatCache = .shared + ) { + self.coordinator = coordinator + self.workspaceId = workspaceId + self.hapticScope = hapticScope + self.cache = cache + } + + var isConnected: Bool { coordinator.connectionState == .connected } + + func setHapticsActive(_ active: Bool) { + if active { + coordinator.haptics.activate(scope: hapticScope) + } else { + coordinator.haptics.deactivate(scope: hapticScope) + } + } + + func accept(_ chat: AidenChat) { + guard chat.workspaceId == workspaceId else { return } + upsert(chat) + } + + func load() async { + guard let context = try? coordinator.requestContext() else { return } + let instanceId = context.instanceId + if chats.isEmpty, let cached = await cache.loadChats(instanceId: instanceId, workspaceId: workspaceId) { + guard coordinator.isCurrent(context) else { return } + chats = cached + } + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + let remote = try await coordinator.remoteClient(for: context).chats(workspaceId: workspaceId) + guard coordinator.isCurrent(context) else { return } + chats = Self.sorted(remote) + try await cache.saveChats(chats, instanceId: instanceId, workspaceId: workspaceId) + } catch { + guard coordinator.isCurrent(context) else { return } + if chats.isEmpty { presentedError = error.localizedDescription } + } + } + + func create() async -> AidenChat? { + guard !isMutating, let context = try? coordinator.requestContext() else { return nil } + let instanceId = context.instanceId + isMutating = true + defer { isMutating = false } + do { + let chat = try await coordinator.remoteClient(for: context).createChat(workspaceId: workspaceId) + guard coordinator.isCurrent(context) else { return nil } + upsert(chat) + try? await persist(chat: chat, instanceId: instanceId) + coordinator.haptics.play(.success, scope: hapticScope, dedupeKey: "chat-create:\(chat.id):\(chat.revision)") + return chat + } catch let error where aidenIsCancellation(error) { + return nil + } catch { + guard coordinator.isCurrent(context) else { return nil } + presentedError = error.localizedDescription + coordinator.haptics.play(.error, scope: hapticScope) + return nil + } + } + + func rename(_ chat: AidenChat, to title: String) async { + let cleaned = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty, !isMutating, let context = try? coordinator.requestContext() else { return } + let instanceId = context.instanceId + isMutating = true + defer { isMutating = false } + var optimistic = chat + optimistic.title = cleaned + upsert(optimistic) + do { + let updated = try await coordinator.remoteClient(for: context).updateChat( + id: chat.id, + revision: chat.revision, + title: cleaned + ) + guard coordinator.isCurrent(context) else { return } + upsert(updated) + try? await persist(chat: updated, instanceId: instanceId) + coordinator.haptics.play(.success, scope: hapticScope, dedupeKey: "chat-rename:\(updated.id):\(updated.revision)") + } catch let error where aidenIsCancellation(error) { + guard coordinator.isCurrent(context) else { return } + upsert(chat) + } catch { + guard coordinator.isCurrent(context) else { return } + upsert(chat) + presentedError = error.localizedDescription + coordinator.haptics.play(.error, scope: hapticScope) + await load() + } + } + + func remove(_ chat: AidenChat) async { + guard !isMutating, let context = try? coordinator.requestContext() else { return } + let instanceId = context.instanceId + isMutating = true + defer { isMutating = false } + chats.removeAll { $0.id == chat.id } + do { + try await coordinator.remoteClient(for: context).removeChat(id: chat.id, revision: chat.revision) + guard coordinator.isCurrent(context) else { return } + await cache.removeChat(instanceId: instanceId, chatId: chat.id) + try? await cache.saveChats(chats, instanceId: instanceId, workspaceId: workspaceId) + coordinator.haptics.play(.success, scope: hapticScope, dedupeKey: "chat-remove:\(chat.id):\(chat.revision)") + } catch let error where aidenIsCancellation(error) { + guard coordinator.isCurrent(context) else { return } + upsert(chat) + } catch { + guard coordinator.isCurrent(context) else { return } + upsert(chat) + presentedError = error.localizedDescription + coordinator.haptics.play(.error, scope: hapticScope) + await load() + } + } + + private func upsert(_ chat: AidenChat) { + chats.removeAll { $0.id == chat.id } + chats.append(chat) + chats = Self.sorted(chats) + } + + private func persist(chat: AidenChat, instanceId: String) async throws { + try await cache.saveChat(chat, instanceId: instanceId) + try await cache.saveChats(chats, instanceId: instanceId, workspaceId: workspaceId) + } + + private static func sorted(_ chats: [AidenChat]) -> [AidenChat] { + chats.sorted { + if $0.updatedAt == $1.updatedAt { return $0.id < $1.id } + return $0.updatedAt > $1.updatedAt + } + } +} + +@MainActor +@Observable +final class AidenChatViewModel { + private let coordinator: AidenRemoteCoordinator + private let instanceId: String + private let cache: AidenChatCache + private let liveActivities: AidenRemoteLiveActivityManager + private let onChatUpdated: @MainActor (AidenChat) -> Void + private let hapticScope: UUID + @ObservationIgnored private var streamTask: Task? + @ObservationIgnored private var titleRefreshTask: Task? + @ObservationIgnored private var terminalReconciliationTask: Task? + @ObservationIgnored private var activeStreamID: String? + @ObservationIgnored private var turnAttempts = AidenTurnAttemptTracker() + + private(set) var chat: AidenChat + private(set) var catalog: AidenModelCatalog? + private(set) var isLoading = false + private(set) var isStarting = false + private(set) var streamState: AidenStreamState? + private(set) var liveText = "" + private(set) var reasoning = "" + private(set) var tools: [AidenLiveTool] = [] + private(set) var activityTimeline: AidenGenerationTimeline? + private(set) var pendingApproval: AidenPendingApproval? + private(set) var pendingAttachments: [AidenAttachmentReference] = [] + private(set) var isUploadingAttachment = false + var draft = "" + var selectedProviderId: String? + var selectedModelId: String? + var selectedThinkingLevel: String? + var presentedError: String? + + init( + coordinator: AidenRemoteCoordinator, + chat: AidenChat, + hapticScope: UUID = UUID(), + cache: AidenChatCache = .shared, + liveActivities: AidenRemoteLiveActivityManager? = nil, + onChatUpdated: @escaping @MainActor (AidenChat) -> Void = { _ in } + ) { + self.coordinator = coordinator + self.chat = chat + self.hapticScope = hapticScope + instanceId = coordinator.activeInstanceId ?? "" + self.cache = cache + self.liveActivities = liveActivities ?? .shared + self.onChatUpdated = onChatUpdated + selectedProviderId = chat.providerId + selectedModelId = chat.modelId + } + + var isConnected: Bool { coordinator.connectionState == .connected } + var isStreaming: Bool { streamState.map { !$0.isTerminal } ?? false } + var canSend: Bool { + (!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !pendingAttachments.isEmpty) && + isConnected && coordinator.activeInstanceId == instanceId + && !isStarting && !isUploadingAttachment && !isStreaming + } + + var selectedProvider: AidenProvider? { + catalog?.providers.first { $0.id == selectedProviderId } + } + + var selectedModel: AidenModel? { + selectedProvider?.models.first { $0.id == selectedModelId } + } + + var visibleProviders: [AidenProvider] { catalog?.visibleProviders ?? [] } + + func setHapticsActive(_ active: Bool) { + if active { + coordinator.haptics.activate(scope: hapticScope) + } else { + coordinator.haptics.deactivate(scope: hapticScope) + } + } + + func load() async { + guard !instanceId.isEmpty, !isLoading else { return } + guard let context = try? coordinator.requestContext(for: instanceId) else { return } + isLoading = true + if let cached = await cache.loadChat(instanceId: instanceId, chatId: chat.id) { + guard coordinator.isCurrent(context) else { return } + chat = cached + } + defer { isLoading = false } + do { + async let chatRequest = coordinator.remoteClient(for: context).chat(id: chat.id) + async let catalogRequest = coordinator.remoteClient(for: context).modelCatalog() + let (remoteChat, remoteCatalog) = try await (chatRequest, catalogRequest) + guard coordinator.isCurrent(context) else { return } + catalog = remoteCatalog + await acceptRemoteChat(remoteChat, context: context) + resolveModelSelection() + } catch { + guard coordinator.isCurrent(context) else { return } + if chat.messages.isEmpty { presentedError = error.localizedDescription } + } + guard coordinator.isCurrent(context) else { return } + await restoreStreamIfNeeded() + } + + func selectProvider(_ providerId: String) { + guard selectedProviderId != providerId else { return } + selectedProviderId = providerId + selectedModelId = visibleProviders.first { $0.id == providerId }?.models.first?.id + selectedThinkingLevel = selectedModel?.effectiveThinkingLevel + } + + func selectModel(_ modelId: String) { + guard selectedModelId != modelId else { return } + selectedModelId = modelId + selectedThinkingLevel = selectedModel?.effectiveThinkingLevel + } + + func selectModel(providerId: String, modelId: String, thinkingLevel: String?) { + let next = [providerId, modelId, thinkingLevel ?? ""] + let current = [selectedProviderId ?? "", selectedModelId ?? "", selectedThinkingLevel ?? ""] + guard next != current else { return } + selectedProviderId = providerId + selectedModelId = modelId + selectedThinkingLevel = thinkingLevel + } + + func send() async { + let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard canSend else { return } + guard let context = try? coordinator.requestContext(for: instanceId) else { return } + let submittedAttachments = pendingAttachments + let request = AidenTurnRequestBuilder.make( + text: text, + providerId: selectedProviderId, + modelId: selectedModelId, + thinkingLevel: selectedThinkingLevel, + attachments: submittedAttachments + ) + let previousUpdatedAt = chat.updatedAt + let optimisticID = "local-\(UUID().uuidString.lowercased())" + let now = Date() + let optimisticMessage = AidenChatMessage( + id: optimisticID, + role: .user, + text: text, + attachments: submittedAttachments.map { + AidenMessageAttachment( + id: $0.id, + name: $0.name, + mimeType: $0.mimeType, + kind: $0.kind, + size: $0.size + ) + }, + createdAt: now + ) + + isStarting = true + defer { isStarting = false } + presentedError = nil + draft = "" + pendingAttachments = [] + chat.messages.append(optimisticMessage) + chat.updatedAt = now + streamState = .queued + let idempotencyKey = turnAttempts.key(for: request) + do { + let response = try await coordinator.remoteClient(for: context).startTurn( + chatId: chat.id, + request: request, + idempotencyKey: idempotencyKey + ) + let stream = AidenChatCache.ActiveStream( + deviceId: context.deviceId, + streamId: response.streamId, + turnId: response.turnId, + lastSequence: 0 + ) + var acceptedChat = chat + acceptedChat.messages.removeAll { $0.id == optimisticID } + if !acceptedChat.messages.contains(where: { $0.id == response.message.id }) { + acceptedChat.messages.append(response.message) + } + // A normal installation switch retains an accepted turn for later + // resume. Forgetting, revoking, or re-pairing the captured device + // invalidates the context before any private cache/activity write. + let retained = await coordinator.withRetainedInstallationData(for: context) { + try? await cache.saveChat(acceptedChat, instanceId: instanceId) + try? await cache.saveActiveStream(stream, instanceId: instanceId, chatId: chat.id) + await liveActivities.start( + instanceID: instanceId, + chatID: chat.id, + title: chat.title, + streamID: response.streamId + ) + } + guard retained else { return } + guard coordinator.isCurrent(context) else { return } + turnAttempts.reset() + chat = acceptedChat + liveText = "" + reasoning = "" + tools = [] + activityTimeline = nil + pendingApproval = nil + streamState = .queued + coordinator.haptics.play(.actionStarted, scope: hapticScope, dedupeKey: "turn-start:\(response.streamId)") + startStreaming(stream, context: context, feedbackPolicy: .localTurn) + } catch let error where aidenIsCancellation(error) { + guard coordinator.isCurrent(context) else { return } + chat.messages.removeAll { $0.id == optimisticID } + chat.updatedAt = previousUpdatedAt + if draft.isEmpty { draft = text } + if pendingAttachments.isEmpty { pendingAttachments = submittedAttachments } + streamState = nil + } catch { + guard coordinator.isCurrent(context) else { return } + chat.messages.removeAll { $0.id == optimisticID } + chat.updatedAt = previousUpdatedAt + if draft.isEmpty { draft = text } + if pendingAttachments.isEmpty { pendingAttachments = submittedAttachments } + streamState = nil + presentedError = error.localizedDescription + coordinator.haptics.play(.error, scope: hapticScope) + } + } + + @discardableResult + func upload(_ uploads: [AidenAttachmentUpload]) async -> Int { + guard isConnected, !isUploadingAttachment, !isStreaming, pendingAttachments.count < 10 else { + return uploads.count + } + guard let context = try? coordinator.requestContext(for: instanceId) else { return uploads.count } + isUploadingAttachment = true + presentedError = nil + defer { isUploadingAttachment = false } + var failedCount = 0 + var acceptedReferences: [AidenAttachmentReference] = [] + for upload in uploads.prefix(10 - pendingAttachments.count) { + if Task.isCancelled { + await cleanupCancelledUpload(acceptedReferences, context: context) + return uploads.count + } + do { + let reference = try await coordinator.remoteClient(for: context).uploadAttachment( + chatId: chat.id, + upload: upload + ) + guard coordinator.isCurrent(context) else { + acceptedReferences.append(reference) + await cleanupCancelledUpload(acceptedReferences, context: context) + return uploads.count + } + guard reference.isValid() else { + throw AidenRemoteClientError.invalidResponse + } + pendingAttachments.append(reference) + acceptedReferences.append(reference) + if case .image(_, let mimeType, let data) = upload { + let attachment = AidenMessageAttachment( + id: reference.id, + name: reference.name, + mimeType: mimeType, + kind: .image, + size: reference.size + ) + try? await cache.saveAttachmentImage( + data, + instanceId: instanceId, + deviceId: context.deviceId, + chatId: chat.id, + attachment: attachment + ) + } + } catch let error where aidenIsCancellation(error) { + await cleanupCancelledUpload(acceptedReferences, context: context) + return uploads.count + } catch { + guard coordinator.isCurrent(context) else { return uploads.count } + failedCount += 1 + } + } + if failedCount > 0 { + presentedError = failedCount == 1 + ? String(localized: "One attachment could not be uploaded. Other attachments are still ready to send.") + : String(localized: "\(failedCount) attachments could not be uploaded. Other attachments are still ready to send.") + coordinator.haptics.play(acceptedReferences.isEmpty ? .error : .warning, scope: hapticScope) + } + return failedCount + } + + private func cleanupCancelledUpload( + _ references: [AidenAttachmentReference], + context: AidenRemoteRequestContext + ) async { + guard !references.isEmpty else { return } + let cleanup = Task { @MainActor [weak self] in + guard let self else { return } + for reference in references { + pendingAttachments.removeAll { $0.id == reference.id } + await cache.removeAttachmentImage( + instanceId: instanceId, + deviceId: context.deviceId, + chatId: chat.id, + attachmentId: reference.id + ) + try? await coordinator.remoteClient(for: context).removeAttachment( + chatId: chat.id, + attachmentId: reference.id + ) + } + } + await cleanup.value + } + + @discardableResult + func upload(_ upload: AidenAttachmentUpload) async -> Int { + await self.upload([upload]) + } + + func removeAttachment(_ attachment: AidenAttachmentReference) async { + pendingAttachments.removeAll { $0.id == attachment.id } + guard let context = try? coordinator.requestContext(for: instanceId) else { return } + await cache.removeAttachmentImage( + instanceId: instanceId, + deviceId: context.deviceId, + chatId: chat.id, + attachmentId: attachment.id + ) + do { + try await coordinator.remoteClient(for: context).removeAttachment(chatId: chat.id, attachmentId: attachment.id) + } catch { + // The reference is short lived and server cleanup is automatic. Local removal remains authoritative for the composer. + } + } + + func attachmentImageData(for attachment: AidenMessageAttachment) async -> Data? { + guard attachment.kind == .image, + let context = try? coordinator.requestContext(for: instanceId) + else { return nil } + if let cached = await cache.attachmentImage( + instanceId: instanceId, + deviceId: context.deviceId, + chatId: chat.id, + attachment: attachment + ) { + return cached + } + do { + let content = try await coordinator.remoteClient(for: context).attachmentContent( + chatId: chat.id, + attachmentId: attachment.id + ) + guard coordinator.isCurrent(context), + content.mimeType == attachment.mimeType, + let data = await AidenAttachmentImageDecoding.validatedData( + content.data, + mimeType: content.mimeType, + declaredSize: attachment.size + ) + else { return nil } + try? await cache.saveAttachmentImage( + data, + instanceId: instanceId, + deviceId: context.deviceId, + chatId: chat.id, + attachment: attachment + ) + return data + } catch { + return nil + } + } + + func stop() async { + guard let stream = await cache.loadActiveStream(instanceId: instanceId, chatId: chat.id) else { return } + guard let context = try? coordinator.requestContext(for: instanceId) else { return } + let previousState = streamState + pendingApproval = nil + streamState = .cancelled + do { + let status = try await coordinator.remoteClient(for: context).cancelStream(id: stream.streamId) + guard coordinator.isCurrent(context) else { return } + guard status.streamId == stream.streamId, status.chatId == chat.id else { return } + if activeStreamID == stream.streamId { + await apply( + status, + streamID: stream.streamId, + context: context, + feedbackPolicy: .restoredStream + ) + } + coordinator.haptics.play(.actionStopped, scope: hapticScope, dedupeKey: "turn-stop:\(stream.streamId)") + } catch let error where aidenIsCancellation(error) { + guard coordinator.isCurrent(context), activeStreamID == stream.streamId else { return } + streamState = previousState + } catch { + guard coordinator.isCurrent(context), activeStreamID == stream.streamId else { return } + streamState = previousState + presentedError = error.localizedDescription + coordinator.haptics.play(.error, scope: hapticScope) + } + } + + func respondToApproval(_ decision: AidenApprovalDecision) async { + guard let approval = pendingApproval, approval.expiresAt > Date() else { + pendingApproval = nil + return + } + let previousState = streamState + guard let streamID = activeStreamID else { return } + guard let context = try? coordinator.requestContext(for: instanceId) else { return } + pendingApproval = nil + streamState = .running + do { + let response = try await coordinator.remoteClient(for: context).respondToApproval(id: approval.id, decision: decision) + guard coordinator.isCurrent(context), activeStreamID == streamID else { return } + guard response.approvalId == approval.id, response.decision == decision else { return } + coordinator.haptics.play(.selection, scope: hapticScope, dedupeKey: "approval-response:\(approval.id):\(decision.rawValue)") + } catch let error where aidenIsCancellation(error) { + guard coordinator.isCurrent(context), activeStreamID == streamID else { return } + pendingApproval = approval + streamState = previousState + } catch { + guard coordinator.isCurrent(context), activeStreamID == streamID else { return } + pendingApproval = approval + streamState = previousState + presentedError = error.localizedDescription + coordinator.haptics.play(.error, scope: hapticScope) + } + } + + private func resolveModelSelection() { + guard let catalog else { return } + if selectedProviderId == nil || !catalog.providers.contains(where: { $0.id == selectedProviderId }) { + selectedProviderId = catalog.defaults["providerId"] ?? catalog.visibleProviders.first?.id + } + if selectedModelId == nil || selectedProvider?.models.contains(where: { $0.id == selectedModelId }) != true { + selectedModelId = catalog.defaults["modelId"] ?? selectedProvider?.visibleModels.first?.id + } + if selectedThinkingLevel == nil { selectedThinkingLevel = selectedModel?.effectiveThinkingLevel } + } + + private func restoreStreamIfNeeded() async { + guard let stream = await cache.loadActiveStream(instanceId: instanceId, chatId: chat.id) else { return } + guard let context = try? coordinator.requestContext(for: instanceId) else { return } + guard stream.deviceId == context.deviceId else { + await cache.removeActiveStream(instanceId: instanceId, chatId: chat.id) + await liveActivities.endAll(forInstanceID: instanceId) + return + } + do { + let status = try await coordinator.remoteClient(for: context).streamStatus(id: stream.streamId) + guard coordinator.isCurrent(context) else { return } + activeStreamID = stream.streamId + if !status.state.isTerminal { + await liveActivities.start( + instanceID: instanceId, + chatID: chat.id, + title: chat.title, + streamID: stream.streamId + ) + } + guard activeStreamID == stream.streamId else { return } + await apply( + status, + streamID: stream.streamId, + context: context, + feedbackPolicy: .restoredStream + ) + // A terminal status can become visible before its final SSE event is + // consumed. Keep the durable cursor and replay first so cancellation + // and provider-failure details are never skipped on reopen. + startStreaming(stream, context: context, feedbackPolicy: .restoredStream) + } catch { + guard coordinator.isCurrent(context) else { return } + presentedError = error.localizedDescription + await liveActivities.markStale(instanceID: instanceId, streamID: stream.streamId) + // Retain and resume the durable cursor even if the first status + // probe happens while the phone is offline. + startStreaming(stream, context: context, feedbackPolicy: .restoredStream) + } + } + + private func startStreaming( + _ stream: AidenChatCache.ActiveStream, + context: AidenRemoteRequestContext, + feedbackPolicy: AidenStreamFeedbackPolicy + ) { + terminalReconciliationTask?.cancel() + terminalReconciliationTask = nil + streamTask?.cancel() + activeStreamID = stream.streamId + streamTask = Task { [weak self] in + await self?.consume(stream, context: context, feedbackPolicy: feedbackPolicy) + } + } + + private func consume( + _ original: AidenChatCache.ActiveStream, + context: AidenRemoteRequestContext, + feedbackPolicy: AidenStreamFeedbackPolicy + ) async { + var stream = original + var terminalReplayGate = AidenTerminalReplayGate() + var retryAttempt = 0 + while !Task.isCancelled && coordinator.isCurrent(context) && activeStreamID == stream.streamId { + do { + let events = try coordinator.remoteClient(for: context).streamEvents( + id: stream.streamId, + after: stream.lastSequence + ) + for try await event in events { + try Task.checkCancellation() + guard coordinator.isCurrent(context), activeStreamID == stream.streamId else { return } + guard event.streamId == stream.streamId else { continue } + if event.sequence <= stream.lastSequence { continue } + if event.sequence != stream.lastSequence + 1 { + await reconcileChat(context: context) + } + await apply(event, context: context, feedbackPolicy: feedbackPolicy) + guard activeStreamID == stream.streamId else { return } + stream.lastSequence = event.sequence + if event.terminal { return } + try await cache.saveActiveStream(stream, instanceId: instanceId, chatId: chat.id) + } + + let status = try await coordinator.remoteClient(for: context).streamStatus(id: stream.streamId) + guard coordinator.isCurrent(context), activeStreamID == stream.streamId else { return } + retryAttempt = 0 + await apply( + status, + streamID: stream.streamId, + context: context, + feedbackPolicy: feedbackPolicy + ) + if status.state.isTerminal { + if terminalReplayGate.shouldReplay(status.state) { continue } + await finishStream(expectedStreamID: stream.streamId, context: context) + return + } + try await Task.sleep(for: .milliseconds(500)) + } catch let error where aidenIsCancellation(error) { + return + } catch { + guard !Task.isCancelled else { return } + do { + let status = try await coordinator.remoteClient(for: context).streamStatus(id: stream.streamId) + guard coordinator.isCurrent(context), activeStreamID == stream.streamId else { return } + await apply( + status, + streamID: stream.streamId, + context: context, + feedbackPolicy: feedbackPolicy + ) + if status.state.isTerminal { + if terminalReplayGate.shouldReplay(status.state) { continue } + await finishStream(expectedStreamID: stream.streamId, context: context) + return + } + try await Task.sleep(for: .seconds(1)) + } catch let error where aidenIsCancellation(error) { + return + } catch { + guard coordinator.isCurrent(context) else { return } + if AidenTerminalReconciliation.isDefinitiveMissingStream(error), + await reconcileMissingStream( + stream, + context: context, + feedbackPolicy: feedbackPolicy + ) { + return + } + presentedError = error.localizedDescription + await liveActivities.markStale(instanceID: instanceId, streamID: stream.streamId) + let delay = AidenTerminalReconciliation.retryDelayMilliseconds(attempt: retryAttempt) + retryAttempt += 1 + do { + try await Task.sleep(for: .milliseconds(delay)) + } catch { + return + } + continue + } + } + } + } + + private func apply( + _ event: AidenRemoteStreamEvent, + context: AidenRemoteRequestContext, + feedbackPolicy: AidenStreamFeedbackPolicy + ) async { + guard coordinator.isCurrent(context), + activeStreamID == event.streamId, + event.shouldApply, + let payload = event.payload else { return } + switch event.type { + case .snapshot: + streamState = .reconciling + await reconcileChat(context: context) + case .status: + if let value = payload.state, let state = AidenStreamState(rawValue: value) { + if state == .waitingForApproval { + await restorePendingApproval( + streamID: event.streamId, + context: context, + announce: feedbackPolicy.allowsFeedback + ) + break + } + streamState = state + if state != .waitingForApproval { pendingApproval = nil } + await liveActivities.updateStatus(instanceID: instanceId, streamID: event.streamId, state: state) + } + case .textDelta: + liveText += payload.text ?? "" + streamState = .running + await liveActivities.appendResponse(payload.text ?? "", instanceID: instanceId, streamID: event.streamId) + case .reasoningDelta: + reasoning += payload.text ?? "" + await liveActivities.reasoning(instanceID: instanceId, streamID: event.streamId) + case .toolStarted: + if let id = payload.toolId, let name = payload.name { + tools.append(AidenLiveTool(id: id, name: name, status: nil)) + } + await liveActivities.toolStarted(name: payload.name, instanceID: instanceId, streamID: event.streamId) + case .toolFinished: + if let id = payload.toolId, let index = tools.firstIndex(where: { $0.id == id }) { + tools[index].status = payload.status + } + await liveActivities.toolFinished(instanceID: instanceId, streamID: event.streamId) + case .timeline: + if let timeline = payload.timeline { activityTimeline = timeline } + case .approvalRequired: + await restorePendingApproval( + streamID: event.streamId, + context: context, + announce: feedbackPolicy.allowsFeedback + ) + case .error: + pendingApproval = nil + presentedError = payload.message ?? "Aiden could not finish this response." + streamState = .error + if feedbackPolicy.allowsFeedback { + coordinator.haptics.play( + .error, + scope: hapticScope, + dedupeKey: "turn-terminal:\(event.streamId):error" + ) + } + await liveActivities.finish( + instanceID: instanceId, + streamID: event.streamId, + status: .failed, + message: String(localized: "Response failed"), + errorSummary: payload.message + ) + await finishStream(expectedStreamID: event.streamId, context: context) + case .cancelled: + pendingApproval = nil + streamState = .cancelled + await liveActivities.finish( + instanceID: instanceId, + streamID: event.streamId, + status: .cancelled, + message: String(localized: "Response cancelled") + ) + await finishStream(expectedStreamID: event.streamId, context: context) + case .done: + pendingApproval = nil + streamState = .done + await liveActivities.finish( + instanceID: instanceId, + streamID: event.streamId, + status: .complete, + message: String(localized: "Response complete") + ) + await finishStream(expectedStreamID: event.streamId, context: context) + case .heartbeat: + break + default: + break + } + } + + private func apply( + _ status: AidenStreamStatus, + streamID: String, + context: AidenRemoteRequestContext, + feedbackPolicy: AidenStreamFeedbackPolicy + ) async { + guard activeStreamID == streamID, + status.streamId == streamID, + status.chatId == chat.id, + coordinator.isCurrent(context) + else { return } + if status.state == .waitingForApproval { + await restorePendingApproval( + streamID: streamID, + context: context, + announce: AidenStreamFeedbackDecision.announcesApproval(feedbackPolicy) + ) + return + } + pendingApproval = nil + streamState = status.state + if feedbackPolicy.allowsFeedback, + status.state == .error || status.state == .interrupted { + coordinator.haptics.play( + .error, + scope: hapticScope, + dedupeKey: "turn-terminal:\(streamID):error" + ) + } + await liveActivities.updateStatus(instanceID: instanceId, streamID: streamID, state: status.state) + } + + private func restorePendingApproval( + streamID: String, + context: AidenRemoteRequestContext, + announce: Bool = false + ) async { + do { + let snapshot = try await coordinator.remoteClient(for: context).streamApproval(id: streamID) + guard coordinator.isCurrent(context), activeStreamID == streamID else { return } + guard let approval = AidenPendingApprovalResolution.resolve( + snapshot.approval, + streamId: streamID, + chatId: chat.id + ) else { + pendingApproval = nil + streamState = .reconciling + await liveActivities.updateStatus( + instanceID: instanceId, + streamID: streamID, + state: .reconciling + ) + return + } + pendingApproval = approval + streamState = .waitingForApproval + if announce { + coordinator.haptics.play( + .warning, + scope: hapticScope, + dedupeKey: "approval-required:\(approval.id)" + ) + } + await liveActivities.approvalRequired(instanceID: instanceId, streamID: streamID) + } catch { + guard coordinator.isCurrent(context), activeStreamID == streamID else { return } + pendingApproval = nil + streamState = .reconciling + await liveActivities.markStale(instanceID: instanceId, streamID: streamID) + } + } + + @discardableResult + private func reconcileChat(context: AidenRemoteRequestContext) async -> Bool { + do { + let remote = try await coordinator.remoteClient(for: context).chat(id: chat.id) + guard coordinator.isCurrent(context) else { return false } + await acceptRemoteChat(remote, context: context) + return true + } catch { + guard coordinator.isCurrent(context) else { return false } + presentedError = error.localizedDescription + return false + } + } + + private func acceptRemoteChat( + _ remote: AidenChat, + context: AidenRemoteRequestContext, + scheduleTitleRefresh: Bool = true + ) async { + guard coordinator.isCurrent(context) else { return } + chat = remote + try? await cache.saveChat(remote, instanceId: instanceId) + onChatUpdated(remote) + if scheduleTitleRefresh, remote.isTitlePending { + schedulePendingTitleRefresh(context: context) + } + } + + private func schedulePendingTitleRefresh(context: AidenRemoteRequestContext) { + guard titleRefreshTask == nil else { return } + titleRefreshTask = Task { [weak self] in + guard let self else { return } + defer { titleRefreshTask = nil } + for delay in AidenChatTitleReconciliation.retryMilliseconds { + do { + try await Task.sleep(for: .milliseconds(delay)) + let remote = try await coordinator.remoteClient(for: context).chat(id: chat.id) + guard coordinator.isCurrent(context) else { return } + await acceptRemoteChat(remote, context: context, scheduleTitleRefresh: false) + if !remote.isTitlePending { return } + } catch let error where aidenIsCancellation(error) { + return + } catch { + // A transient local-network interruption should not surface after a + // successful reply. The next normal refresh remains authoritative. + continue + } + } + } + } + + private func finishStream(expectedStreamID: String, context: AidenRemoteRequestContext) async { + guard coordinator.isCurrent(context), activeStreamID == expectedStreamID else { return } + guard await reconcileChat(context: context) else { + scheduleTerminalReconciliation(expectedStreamID: expectedStreamID, context: context) + return + } + guard activeStreamID == expectedStreamID else { return } + await clearFinishedStream(expectedStreamID: expectedStreamID) + } + + private func reconcileMissingStream( + _ stream: AidenChatCache.ActiveStream, + context: AidenRemoteRequestContext, + feedbackPolicy: AidenStreamFeedbackPolicy + ) async -> Bool { + guard activeStreamID == stream.streamId else { return false } + guard await reconcileChat(context: context) else { return false } + guard activeStreamID == stream.streamId else { return false } + let resolution = AidenMissingStreamResolution.resolve(messages: chat.messages) + if let event = AidenStreamFeedbackDecision.terminalEvent( + for: resolution, + policy: feedbackPolicy + ) { + coordinator.haptics.play( + event, + scope: hapticScope, + dedupeKey: "turn-terminal:\(stream.streamId):error" + ) + } + switch resolution { + case .cancelled: + streamState = .cancelled + await liveActivities.finish( + instanceID: instanceId, + streamID: stream.streamId, + status: .cancelled, + message: String(localized: "Response cancelled") + ) + case .failed: + streamState = .error + await liveActivities.finish( + instanceID: instanceId, + streamID: stream.streamId, + status: .failed, + message: String(localized: "Response failed") + ) + case .complete: + streamState = .done + await liveActivities.finish( + instanceID: instanceId, + streamID: stream.streamId, + status: .complete, + message: String(localized: "Response complete") + ) + case .interrupted: + streamState = .interrupted + await liveActivities.finish( + instanceID: instanceId, + streamID: stream.streamId, + status: .failed, + message: String(localized: "Response interrupted") + ) + } + await clearFinishedStream(expectedStreamID: stream.streamId) + return true + } + + private func scheduleTerminalReconciliation( + expectedStreamID: String, + context: AidenRemoteRequestContext + ) { + guard terminalReconciliationTask == nil else { return } + terminalReconciliationTask = Task { [weak self] in + guard let self else { return } + defer { terminalReconciliationTask = nil } + var attempt = 0 + while !Task.isCancelled && coordinator.isCurrent(context) && activeStreamID == expectedStreamID { + do { + let delay = AidenTerminalReconciliation.retryDelayMilliseconds(attempt: attempt) + try await Task.sleep(for: .milliseconds(delay)) + guard coordinator.isCurrent(context), activeStreamID == expectedStreamID else { return } + if await reconcileChat(context: context) { + guard activeStreamID == expectedStreamID else { return } + await clearFinishedStream(expectedStreamID: expectedStreamID) + return + } + } catch let error where aidenIsCancellation(error) { + return + } catch { + // Keep the durable stream cursor and continue retrying while + // this Mac connection remains current. Long Tailscale or + // local-network outages must not erase terminal evidence. + } + attempt += 1 + } + } + } + + private func clearFinishedStream(expectedStreamID: String) async { + guard activeStreamID == expectedStreamID else { return } + guard await cache.removeActiveStream( + instanceId: instanceId, + chatId: chat.id, + ifStreamId: expectedStreamID + ) else { return } + liveText = "" + reasoning = "" + tools = [] + activityTimeline = nil + pendingApproval = nil + activeStreamID = nil + } +} + +struct AidenWorkspaceChatsView: View { + @Bindable var coordinator: AidenRemoteCoordinator + @Environment(\.aidenPalette) private var palette + let workspace: AidenWorkspace + @State private var model: AidenWorkspaceChatsModel + @State private var createdChat: AidenChat? + @State private var renameChat: AidenChat? + @State private var renameTitle = "" + @State private var deleteChat: AidenChat? + + init(coordinator: AidenRemoteCoordinator, workspace: AidenWorkspace) { + self.coordinator = coordinator + self.workspace = workspace + _model = State(initialValue: AidenWorkspaceChatsModel( + coordinator: coordinator, + workspaceId: workspace.id + )) + } + + var body: some View { + List { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(workspace.name).font(.title2.bold()) + Label(workspace.permission.detail, systemImage: "checkmark.shield") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 8) + } + + Section("Chats") { + if model.chats.isEmpty, !model.isLoading { + ContentUnavailableView( + "No Chats", + systemImage: "bubble.left.and.bubble.right", + description: Text("Start a chat in this workspace to control Aiden Agent.") + ) + .listRowBackground(Color.clear) + } else { + ForEach(model.chats) { chat in + NavigationLink { + AidenChatDetailView( + coordinator: coordinator, + chat: chat, + onChatUpdated: { model.accept($0) } + ) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(chat.title).lineLimit(1) + AidenRelativeTimestampView(date: chat.updatedAt) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { deleteChat = chat } label: { + Label("Delete", systemImage: "trash") + } + Button { beginRename(chat) } label: { + Label("Rename", systemImage: "pencil") + } + .tint(.accentColor) + } + .contextMenu { + Button { beginRename(chat) } label: { Label("Rename", systemImage: "pencil") } + Button(role: .destructive) { deleteChat = chat } label: { Label("Delete", systemImage: "trash") } + } + } + } + } + } + .scrollContentBackground(.hidden) + .background(palette.canvas) + .overlay { if model.isLoading && model.chats.isEmpty { ProgressView() } } + .refreshable { await model.load() } + .task(id: coordinator.activeInstanceId) { await model.load() } + .navigationDestination(isPresented: Binding( + get: { createdChat != nil }, + set: { if !$0 { createdChat = nil } } + )) { + if let createdChat { + AidenChatDetailView( + coordinator: coordinator, + chat: createdChat, + onChatUpdated: { model.accept($0) } + ) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + Task { createdChat = await model.create() } + } label: { + Image(systemName: "square.and.pencil") + } + .disabled(!model.isConnected || model.isMutating) + .accessibilityLabel("New chat") + } + } + .alert("Rename Chat", isPresented: Binding( + get: { renameChat != nil }, + set: { if !$0 { renameChat = nil } } + )) { + TextField("Chat title", text: $renameTitle) + Button("Cancel", role: .cancel) { renameChat = nil } + Button("Save") { + guard let chat = renameChat else { return } + renameChat = nil + Task { await model.rename(chat, to: renameTitle) } + } + } + .confirmationDialog("Delete this chat?", isPresented: Binding( + get: { deleteChat != nil }, + set: { if !$0 { deleteChat = nil } } + ), titleVisibility: .visible) { + Button("Delete Chat", role: .destructive) { + guard let chat = deleteChat else { return } + deleteChat = nil + Task { await model.remove(chat) } + } + Button("Cancel", role: .cancel) { deleteChat = nil } + } message: { + Text("This permanently removes the chat from Aiden Agent.") + } + .alert("Aiden On The Go", isPresented: Binding( + get: { model.presentedError != nil }, + set: { if !$0 { model.presentedError = nil } } + )) { + Button("OK", role: .cancel) { model.presentedError = nil } + } message: { + Text(model.presentedError ?? "The operation could not be completed.") + } + .onAppear { model.setHapticsActive(true) } + .onDisappear { model.setHapticsActive(false) } + } + + private func beginRename(_ chat: AidenChat) { + renameTitle = chat.title + renameChat = chat + } +} + +struct AidenChatDetailView: View { + @Environment(\.aidenReduceMotion) private var reduceMotion + @Environment(\.aidenPalette) private var palette + @State private var model: AidenChatViewModel + @State private var speechPlayback = AidenSpeechPlaybackController() + @State private var composerHeight: CGFloat = 132 + @FocusState private var composerIsFocused: Bool + @Bindable private var coordinator: AidenRemoteCoordinator + let autoStartVoice: Bool + + init( + coordinator: AidenRemoteCoordinator, + chat: AidenChat, + autoStartVoice: Bool = false, + onChatUpdated: @escaping @MainActor (AidenChat) -> Void = { _ in } + ) { + self.coordinator = coordinator + _model = State(initialValue: AidenChatViewModel( + coordinator: coordinator, + chat: chat, + onChatUpdated: onChatUpdated + )) + self.autoStartVoice = autoStartVoice + } + + private var workspace: AidenWorkspace? { + coordinator.workspaces.first { $0.id == model.chat.workspaceId } + } + + var body: some View { + ZStack(alignment: .bottom) { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 18) { + ForEach(model.chat.messages) { message in + AidenMessageView( + message: message, + speechPlayback: speechPlayback, + loadAttachmentImage: { attachment in + await model.attachmentImageData(for: attachment) + } + ) + } + if model.isStreaming || !model.liveText.isEmpty { + AidenLiveResponseView(model: model) + } + Color.clear + .frame(height: max(96, composerHeight + 12)) + .accessibilityHidden(true) + Color.clear.frame(height: 1).id("chat-bottom") + } + .padding(.horizontal) + .padding(.top, 20) + } + .scrollDismissesKeyboard(.interactively) + .simultaneousGesture( + TapGesture().onEnded { + composerIsFocused = false + } + ) + .onChange(of: model.chat.messages.count) { _, _ in scrollToBottom(proxy) } + .onChange(of: model.liveText) { _, _ in scrollToBottom(proxy) } + .onChange(of: model.pendingApproval?.id) { _, approvalID in + guard approvalID != nil else { return } + composerIsFocused = false + scrollToBottom(proxy) + } + } + + AidenComposerView( + model: model, + autoStartVoice: autoStartVoice, + composerFocus: $composerIsFocused + ) + .padding(.horizontal) + .padding(.bottom, 10) + .background { + GeometryReader { proxy in + Color.clear.preference( + key: AidenComposerHeightPreferenceKey.self, + value: proxy.size.height + ) + } + } + } + .background(palette.canvas.ignoresSafeArea()) + .onPreferenceChange(AidenComposerHeightPreferenceKey.self) { height in + guard height > 0 else { return } + composerHeight = height + } + .navigationTitle(model.chat.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if let workspace, workspace.hasFolder { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink { + AidenWorkspaceFilesView(coordinator: coordinator, workspace: workspace) + } label: { + Label("Files", systemImage: "folder") + } + .accessibilityLabel("Workspace files") + } + } + } + .task { await model.load() } + .alert("Aiden On The Go", isPresented: Binding( + get: { model.presentedError != nil }, + set: { if !$0 { model.presentedError = nil } } + )) { + Button("OK", role: .cancel) { model.presentedError = nil } + } message: { + Text(model.presentedError ?? "The operation could not be completed.") + } + .onAppear { model.setHapticsActive(true) } + .onDisappear { model.setHapticsActive(false) } + } + + private func scrollToBottom(_ proxy: ScrollViewProxy) { + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.2)) { + proxy.scrollTo("chat-bottom", anchor: .bottom) + } + } +} + +private struct AidenComposerHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 132 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +private struct AidenMessageView: View { + @Environment(\.aidenPalette) private var palette + let message: AidenChatMessage + let speechPlayback: AidenSpeechPlaybackController + let loadAttachmentImage: (AidenMessageAttachment) async -> Data? + + var body: some View { + HStack(alignment: .top, spacing: 0) { + if message.role == .user { + Spacer(minLength: 48) + messageContent + } else { + messageContent + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) + } + } + .frame(maxWidth: .infinity) + .contextMenu { + if let copyText = AidenMessageActionContent.copyText(for: message) { + Button { + UIPasteboard.general.string = copyText + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + } + } + .accessibilityActions { + if let copyText = AidenMessageActionContent.copyText(for: message) { + Button("Copy response") { + UIPasteboard.general.string = copyText + } + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel(message.role == .user ? "You" : "Aiden") + } + + private var messageContent: some View { + VStack( + alignment: message.role == .user ? .trailing : .leading, + spacing: 10 + ) { + if message.role == .assistant, let timeline = message.timeline, !timeline.steps.isEmpty { + AidenActivityFeed(timeline: timeline, active: false) + } + if !message.text.isEmpty { + AidenMessageTextView(role: message.role, content: message.text) + .padding(AidenMessageContentSurface.usesRaisedBubble( + role: message.role, + content: .text + ) ? 12 : 0) + .background( + AidenMessageContentSurface.usesRaisedBubble( + role: message.role, + content: .text + ) ? palette.raised : Color.clear, + in: RoundedRectangle(cornerRadius: 18, style: .continuous) + ) + } + if let attachments = message.attachments, !attachments.isEmpty { + let identifierCounts = Dictionary(grouping: attachments, by: \.id).mapValues(\.count) + let imageAttachments = attachments.filter { attachment in + attachment.kind == .image + && (attachment.mimeType == "image/jpeg" || attachment.mimeType == "image/png") + && attachment.size > 0 + && attachment.size <= AidenAttachmentImageValidation.maximumBytes + && identifierCounts[attachment.id] == 1 + } + if !imageAttachments.isEmpty { + AidenMessageImageAttachmentsView( + attachments: imageAttachments, + edge: AidenMessageMediaEdge.forRole(message.role), + loadData: loadAttachmentImage + ) + } + let fallbackAttachments = attachments.filter { attachment in + !imageAttachments.contains(where: { $0.id == attachment.id }) + } + ForEach(fallbackAttachments.indices, id: \.self) { index in + let attachment = fallbackAttachments[index] + Label { + VStack(alignment: .leading, spacing: 1) { + Text(attachment.name).lineLimit(1) + Text(ByteCountFormatter.string(fromByteCount: Int64(attachment.size), countStyle: .file)) + .foregroundStyle(palette.secondary) + } + } icon: { + Image(systemName: attachment.kind == .image ? "photo.badge.exclamationmark" : "doc.text") + } + .font(.caption) + .padding(AidenMessageContentSurface.usesRaisedBubble( + role: message.role, + content: .fallbackAttachment + ) ? 10 : 0) + .background( + AidenMessageContentSurface.usesRaisedBubble( + role: message.role, + content: .fallbackAttachment + ) ? palette.raised : Color.clear, + in: RoundedRectangle(cornerRadius: 14, style: .continuous) + ) + .accessibilityElement(children: .combine) + } + } + if message.role == .assistant, let outcome = message.outcome { + AidenMessageOutcomeView(outcome: outcome) + } + if message.role == .assistant, !message.text.isEmpty { + Button { + speechPlayback.speak(message.text) + } label: { + Label("Read aloud", systemImage: "speaker.wave.2") + } + .font(.caption) + .buttonStyle(.plain) + .foregroundStyle(palette.secondary) + } + } + } +} + +private struct AidenActivityFeed: View { + @Environment(\.aidenPalette) private var palette + @Environment(\.aidenReduceMotion) private var reduceMotion + let timeline: AidenGenerationTimeline + let active: Bool + @State private var isExpanded = false + + private var rows: [AidenAgentStep] { Array(timeline.steps.suffix(3)) } + private var isRunning: Bool { active && timeline.status == .running } + + var body: some View { + VStack(alignment: .leading, spacing: isExpanded ? 4 : 0) { + Button { + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.15)) { + isExpanded.toggle() + } + } label: { + HStack(alignment: isRunning && !isExpanded ? .bottom : .center, spacing: 8) { + Group { + if isRunning && !isExpanded { + VStack(alignment: .leading, spacing: 0) { + ForEach(rows) { step in + AidenActivityStepLine(step: step, shimmer: step.id == rows.last?.id && step.isActive) + .frame(height: 24) + .id(step.id) + .transition(.opacity) + } + } + .frame(height: CGFloat(rows.count) * 24, alignment: .bottom) + .clipped() + } else { + Text(AidenAgentActivityPresentation.summary(timeline)) + .font(.caption.weight(.semibold)) + .foregroundStyle(palette.secondary) + .lineLimit(1) + .aidenActivityShimmer(isRunning) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + if timeline.issueCount > 0 { + Text(timeline.issueCount == 1 ? "1 issue" : "\(timeline.issueCount) issues") + .font(.caption.weight(.semibold)) + .foregroundStyle(palette.warning) + } + + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(palette.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(AidenAgentActivityPresentation.summary(timeline)) + .accessibilityHint(isExpanded ? "Collapses activity" : "Expands activity") + + if isExpanded { + VStack(alignment: .leading, spacing: 4) { + ForEach(timeline.steps) { step in + AidenActivityStepLine(step: step, shimmer: isRunning && step.isActive) + } + } + .padding(.top, 2) + .transition(.opacity) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .animation(reduceMotion ? nil : .easeOut(duration: 0.18), value: timeline.steps.last?.id) + .onAppear { + if timeline.issueCount > 0 { isExpanded = true } + } + } +} + +private struct AidenActivityStepLine: View { + @Environment(\.aidenPalette) private var palette + let step: AidenAgentStep + let shimmer: Bool + + private var tone: Color { + switch step.status { + case .failed: palette.danger + case .blocked, .cancelled, .awaitingApproval: palette.warning + default: palette.secondary + } + } + + var body: some View { + HStack(spacing: 6) { + Text(AidenAgentActivityPresentation.line(for: step)) + .lineLimit(1) + .truncationMode(.tail) + if let changes = step.lineChanges, changes.additions > 0 || changes.deletions > 0 { + Text("+\(changes.additions) −\(changes.deletions)") + .font(.caption2.monospaced().weight(.medium)) + } + } + .font(.caption) + .foregroundStyle(tone) + .aidenActivityShimmer(shimmer) + .accessibilityElement(children: .combine) + } +} + +private struct AidenActivityShimmerModifier: ViewModifier { + @Environment(\.aidenReduceMotion) private var reduceMotion + let active: Bool + + @ViewBuilder + func body(content: Content) -> some View { + if active && !reduceMotion { + content.overlay { + GeometryReader { proxy in + TimelineView(.animation(minimumInterval: 1 / 30)) { context in + let cycle = context.date.timeIntervalSinceReferenceDate + .truncatingRemainder(dividingBy: 1.8) / 1.8 + LinearGradient( + colors: [.clear, .white.opacity(0.42), .clear], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: max(proxy.size.width * 0.55, 36)) + .offset(x: (proxy.size.width * 1.55 * cycle) - proxy.size.width * 0.55) + } + } + .mask(content) + .allowsHitTesting(false) + } + } else { + content + } + } +} + +private extension View { + func aidenActivityShimmer(_ active: Bool) -> some View { + modifier(AidenActivityShimmerModifier(active: active)) + } +} + +struct AidenMessageOutcomePresentation: Equatable { + let title: String + let detail: String? + let symbol: String + let isFailure: Bool + + static func make(_ outcome: AidenMessageOutcome) -> Self { + guard outcome.status == .failed else { + return Self(title: "Response cancelled", detail: nil, symbol: "stop.circle", isFailure: false) + } + let detail: String + switch outcome.category { + case "network": + detail = "Aiden could not reach the model provider." + case "timeout": + detail = "The model provider took too long to respond." + case "service_unavailable": + detail = "The model provider is temporarily unavailable." + 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." + case "quota": + detail = "The model provider account has no available quota." + case "invalid_request": + detail = "The model provider could not accept this request." + case "context_window": + detail = "This conversation is too large for the selected model." + case "output_limit": + detail = "The model reached its response limit before it could finish." + case "interrupted": + detail = "The response was interrupted before it could finish." + case "context_management": + detail = "Aiden could not prepare this conversation for the selected model." + default: + detail = "The model provider could not complete this response." + } + return Self(title: "Generation failed", detail: detail, symbol: "exclamationmark.triangle", isFailure: true) + } +} + +private struct AidenMessageOutcomeView: View { + @Environment(\.aidenPalette) private var palette + let outcome: AidenMessageOutcome + + var body: some View { + let presentation = AidenMessageOutcomePresentation.make(outcome) + HStack(alignment: .top, spacing: 9) { + Image(systemName: presentation.symbol) + .foregroundStyle(presentation.isFailure ? Color.red : palette.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(presentation.title).fontWeight(.semibold) + if let detail = presentation.detail { + Text(detail).foregroundStyle(palette.secondary) + } + } + } + .font(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background( + presentation.isFailure ? Color.red.opacity(0.08) : palette.raised, + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + .accessibilityElement(children: .combine) + } +} + +private struct AidenAttachmentGallerySelection: Identifiable { + let id: String +} + +private struct AidenMessageImageAttachmentsView: View { + @Environment(AidenHapticCenter.self) private var haptics + @Environment(\.aidenReduceMotion) private var reduceMotion + let attachments: [AidenMessageAttachment] + let edge: AidenMessageMediaEdge + let loadData: (AidenMessageAttachment) async -> Data? + @State private var gallerySelection: AidenAttachmentGallerySelection? + @State private var deckSelection = 0 + @State private var deckDragTranslation: CGFloat = 0 + @State private var deckDragAxis: Axis? + + var body: some View { + Group { + if attachments.count == 1 { + AidenAttachmentThumbnailView( + attachment: attachments[0], + loadData: loadData, + contentMode: .fit, + showsBackground: false, + imageCornerRadius: AidenInlineCardDeckLayout.singleImageCornerRadius, + imageAlignment: edge.alignment + ) + .aspectRatio(AidenInlineCardDeckLayout.viewportAspectRatio, contentMode: .fit) + .onTapGesture { openGallery(at: 0) } + } else { + cardDeck + } + } + .frame(maxWidth: 360, alignment: edge.alignment) + .contentShape(Rectangle()) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint(attachments.count == 1 + ? "Double-tap to open the image viewer" + : "Swipe up or down to choose a photo. Double-tap to open the image viewer") + .accessibilityAddTraits(.isButton) + .accessibilityAction { openGallery(at: deckSelection) } + .accessibilityAdjustableAction { direction in + guard attachments.count > 1 else { return } + switch direction { + case .increment: setDeckSelection(min(deckSelection + 1, attachments.count - 1)) + case .decrement: setDeckSelection(max(deckSelection - 1, 0)) + @unknown default: break + } + } + .onChange(of: attachments.map(\.id)) { + deckSelection = min(deckSelection, max(attachments.count - 1, 0)) + } + .fullScreenCover(item: $gallerySelection) { selection in + AidenAttachmentGalleryView( + attachments: attachments, + initialAttachmentID: selection.id, + loadData: loadData + ) + } + } + + private var cardDeck: some View { + GeometryReader { proxy in + let width = max(proxy.size.width - 54, 1) + let dragProgress = AidenInlineCardDeckLayout.dragProgress( + translation: deckDragTranslation, + width: width + ) + ZStack { + ForEach(Array(attachments.enumerated()), id: \.element.id) { index, attachment in + if AidenInlineCardDeckLayout.isVisible( + index: index, + selection: deckSelection, + count: attachments.count + ) { + deckCard( + attachment: attachment, + index: index, + dragProgress: dragProgress, + width: width + ) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: edge.alignment) + .padding(.horizontal, 27) + .padding(.vertical, 18) + .contentShape(Rectangle()) + .onTapGesture { openGallery(at: deckSelection) } + .simultaneousGesture(deckDragGesture(width: width)) + } + .aspectRatio(AidenInlineCardDeckLayout.viewportAspectRatio, contentMode: .fit) + } + + private func deckCard( + attachment: AidenMessageAttachment, + index: Int, + dragProgress: CGFloat, + width: CGFloat + ) -> some View { + let isSelected = index == deckSelection + let isPreferredBackground = index == AidenInlineCardDeckLayout.preferredBackgroundIndex( + selection: deckSelection, + count: attachments.count, + translation: deckDragTranslation + ) + return AidenAttachmentThumbnailView( + attachment: attachment, + loadData: loadData, + contentMode: .fit, + showsBackground: false, + imageCornerRadius: AidenInlineCardDeckLayout.cardCornerRadius, + imageAlignment: edge.alignment + ) + .frame(width: width) + .aspectRatio(AidenInlineCardDeckLayout.viewportAspectRatio, contentMode: .fit) + .scaleEffect(isSelected ? 1 : 0.94, anchor: edge.scaleAnchor) + .rotationEffect( + .degrees(isSelected + ? Double(dragProgress * 2.4) + : edge.backgroundRotationDegrees), + anchor: edge.rotationAnchor + ) + .offset( + x: isSelected + ? AidenInlineCardDeckLayout.selectedCardOffset(translation: deckDragTranslation) + : 0, + y: isSelected ? 0 : 7 + ) + .shadow( + color: .black.opacity(isSelected ? 0.14 : 0), + radius: isSelected ? 8 : 0, + y: isSelected ? 5 : 0 + ) + .zIndex(isSelected ? 2 : (isPreferredBackground ? 1 : 0)) + .accessibilityHidden(true) + } + + private func deckDragGesture(width: CGFloat) -> some Gesture { + DragGesture(minimumDistance: 6) + .onChanged { value in + if deckDragAxis == nil { + deckDragAxis = abs(value.translation.width) > abs(value.translation.height) + ? .horizontal + : .vertical + } + guard deckDragAxis == .horizontal else { return } + deckDragTranslation = reduceMotion ? 0 : AidenInlineCardDeckLayout.resistedTranslation( + current: deckSelection, + count: attachments.count, + translation: value.translation.width + ) + } + .onEnded { value in + defer { deckDragAxis = nil } + guard deckDragAxis == .horizontal else { + deckDragTranslation = 0 + return + } + let selection = AidenInlineCardDeckLayout.resolvedSelection( + current: deckSelection, + count: attachments.count, + translation: value.translation.width, + predictedTranslation: value.predictedEndTranslation.width + ) + setDeckSelection(selection) + } + } + + private var accessibilityLabel: String { + if attachments.count == 1 { + return "Image attachment, \(attachments[0].name)" + } + return "\(attachments.count) image attachments, photo \(deckSelection + 1) of \(attachments.count)" + } + + private func setDeckSelection(_ selection: Int) { + guard selection != deckSelection else { + deckDragTranslation = 0 + return + } + let update = { + deckSelection = selection + deckDragTranslation = 0 + } + if reduceMotion { + update() + } else { + withAnimation(.spring(duration: 0.22, bounce: 0.08), update) + } + haptics.play(.selection) + } + + private func openGallery(at index: Int) { + guard attachments.indices.contains(index) else { return } + gallerySelection = AidenAttachmentGallerySelection(id: attachments[index].id) + } +} + +private struct AidenAttachmentThumbnailView: View { + enum LoadState { + case loading + case image(UIImage) + case failed + } + + @Environment(\.aidenPalette) private var palette + let attachment: AidenMessageAttachment + let loadData: (AidenMessageAttachment) async -> Data? + let contentMode: ContentMode + var showsBackground = true + var imageCornerRadius: CGFloat = 0 + var imageAlignment: Alignment = .center + @State private var state: LoadState = .loading + + var body: some View { + ZStack { + if showsBackground { + palette.raised + } + switch state { + case .loading: + ProgressView() + .controlSize(.small) + .accessibilityLabel("Loading \(attachment.name)") + case .image(let image): + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: contentMode) + .clipShape(RoundedRectangle( + cornerRadius: imageCornerRadius, + style: .continuous + )) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: imageAlignment) + case .failed: + VStack(spacing: 6) { + Image(systemName: "photo.badge.exclamationmark") + Text("Open to retry") + } + .font(.caption) + .foregroundStyle(palette.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityLabel("Open \(attachment.name) to retry") + } + } + .clipped() + .task { + state = .loading + guard let data = await loadData(attachment), !Task.isCancelled, + let image = await AidenAttachmentImageDecoding.thumbnail( + data: data, + maximumPixelSize: 960 + ), + !Task.isCancelled + else { + if !Task.isCancelled { state = .failed } + return + } + state = .image(image) + } + } +} + +private struct AidenAttachmentGalleryView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.openURL) private var openURL + @Environment(AidenHapticCenter.self) private var haptics + let attachments: [AidenMessageAttachment] + let loadData: (AidenMessageAttachment) async -> Data? + @State private var selectedID: String + @State private var isSaving = false + @State private var toastMessage: String? + @State private var showsPhotoSettingsRecovery = false + @State private var hapticScope = UUID() + + init( + attachments: [AidenMessageAttachment], + initialAttachmentID: String, + loadData: @escaping (AidenMessageAttachment) async -> Data? + ) { + self.attachments = Array(attachments.prefix(20)) + self.loadData = loadData + _selectedID = State(initialValue: initialAttachmentID) + } + + var body: some View { + NavigationStack { + TabView(selection: $selectedID) { + ForEach(attachments) { attachment in + AidenFullSizeAttachmentView( + attachment: attachment, + loadData: loadData, + isActive: isNearSelection(attachment) + ) + .tag(attachment.id) + } + } + .tabViewStyle(.page(indexDisplayMode: attachments.count > 1 ? .always : .never)) + .background(Color.black.ignoresSafeArea()) + .navigationTitle(positionLabel) + .navigationBarTitleDisplayMode(.inline) + .toolbarColorScheme(.dark, for: .navigationBar) + .toolbarBackground(.black.opacity(0.72), for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Done") { dismiss() } + .foregroundStyle(.white) + } + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + save(selectedAttachments) + } label: { + Label("Save Image", systemImage: "square.and.arrow.down") + } + if attachments.count > 1 { + Button { + save(attachments) + } label: { + Label("Save All Images", systemImage: "square.stack.3d.down.right") + } + } + } label: { + if isSaving { + ProgressView().tint(.white) + } else { + Image(systemName: "square.and.arrow.down") + } + } + .disabled(isSaving) + .accessibilityLabel("Save images") + } + } + .overlay(alignment: .bottom) { + if let toastMessage { + Text(toastMessage) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.ultraThinMaterial, in: Capsule()) + .padding(.bottom, 44) + .transition(.opacity) + .accessibilityAddTraits(.isStaticText) + } + } + } + .alert("Photos Access Needed", isPresented: $showsPhotoSettingsRecovery) { + Button("Not Now", role: .cancel) {} + Button("Open Settings") { + guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return } + openURL(settingsURL) + } + } message: { + Text("Allow Aiden On The Go to add images in Settings, then try again.") + } + .onAppear { haptics.activate(scope: hapticScope) } + .onDisappear { haptics.deactivate(scope: hapticScope) } + } + + private var selectedAttachments: [AidenMessageAttachment] { + attachments.first { $0.id == selectedID }.map { [$0] } ?? [] + } + + private var positionLabel: String { + guard attachments.count > 1, + let index = attachments.firstIndex(where: { $0.id == selectedID }) + else { return attachments.first?.name ?? "Image" } + return "\(index + 1) of \(attachments.count)" + } + + private func isNearSelection(_ attachment: AidenMessageAttachment) -> Bool { + guard let selectedIndex = attachments.firstIndex(where: { $0.id == selectedID }), + let attachmentIndex = attachments.firstIndex(where: { $0.id == attachment.id }) + else { return false } + return AidenAttachmentGalleryWindow.contains( + index: attachmentIndex, + selectedIndex: selectedIndex, + count: attachments.count + ) + } + + private func save(_ requested: [AidenMessageAttachment]) { + guard !requested.isEmpty, !isSaving else { return } + let operationID = UUID() + isSaving = true + toastMessage = nil + Task { + defer { isSaving = false } + do { + let savedCount = try await AidenPhotoLibrarySaving.save( + attachments: Array(requested.prefix(20)), + loadData: loadData + ) + announce(savedCount == 1 + ? String(localized: "Saved to Photos") + : String(localized: "Saved \(savedCount) images to Photos")) + haptics.play( + .success, + scope: hapticScope, + dedupeKey: "photo-save:\(operationID.uuidString)" + ) + } catch AidenPhotoLibrarySavingError.denied { + announce(AidenPhotoLibrarySavingError.denied.localizedDescription) + showsPhotoSettingsRecovery = true + haptics.play( + .warning, + scope: hapticScope, + dedupeKey: "photo-save:\(operationID.uuidString)" + ) + } catch let error where aidenIsCancellation(error) { + return + } catch { + announce(error.localizedDescription) + haptics.play( + .error, + scope: hapticScope, + dedupeKey: "photo-save:\(operationID.uuidString)" + ) + } + try? await Task.sleep(for: .seconds(2.5)) + if !Task.isCancelled { toastMessage = nil } + } + } + + private func announce(_ message: String) { + toastMessage = message + AccessibilityNotification.Announcement(message).post() + } +} + +private struct AidenFullSizeAttachmentView: View { + let attachment: AidenMessageAttachment + let loadData: (AidenMessageAttachment) async -> Data? + let isActive: Bool + @State private var image: UIImage? + @State private var failed = false + @State private var attempt = 0 + + var body: some View { + ZStack { + Color.black + if let image { + Image(uiImage: image) + .resizable() + .scaledToFit() + .accessibilityLabel(attachment.name) + } else if failed { + Button { + attempt += 1 + } label: { + Label("Retry Image", systemImage: "arrow.clockwise") + .foregroundStyle(.white) + .padding() + } + } else { + ProgressView().tint(.white) + .accessibilityLabel("Loading \(attachment.name)") + } + } + .task(id: "\(attempt)-\(isActive)") { + image = nil + failed = false + guard isActive else { return } + guard let data = await loadData(attachment), !Task.isCancelled, + let decoded = await AidenAttachmentImageDecoding.thumbnail( + data: data, + maximumPixelSize: 2_560 + ), + !Task.isCancelled + else { + if !Task.isCancelled { failed = true } + return + } + image = decoded + } + } +} + +enum AidenAttachmentImageDecoding { + static func validatedData( + _ data: Data, + mimeType: String, + declaredSize: Int + ) async -> Data? { + await AidenAttachmentImageDecoder.shared.validatedData( + data, + mimeType: mimeType, + declaredSize: declaredSize + ) + } + + static func thumbnail(data: Data, maximumPixelSize: Int) async -> UIImage? { + await AidenAttachmentImageDecoder.shared.thumbnail( + data: data, + maximumPixelSize: maximumPixelSize + ) + } +} + +enum AidenAttachmentThumbnailCacheKey { + static func make(data: Data, maximumPixelSize: Int) -> String { + let digest = Data(SHA256.hash(data: data)).base64EncodedString() + return "\(maximumPixelSize):\(digest)" + } +} + +private actor AidenAttachmentImageDecoder { + static let shared = AidenAttachmentImageDecoder() + private let thumbnailCache: NSCache + + init() { + thumbnailCache = NSCache() + thumbnailCache.countLimit = 24 + thumbnailCache.totalCostLimit = 32 * 1_024 * 1_024 + } + + func validatedData(_ data: Data, mimeType: String, declaredSize: Int) -> Data? { + guard !Task.isCancelled else { return nil } + return AidenAttachmentImageValidation.validatedData( + data, + mimeType: mimeType, + declaredSize: declaredSize + ) + } + + func thumbnail(data: Data, maximumPixelSize: Int) -> UIImage? { + guard !Task.isCancelled else { return nil } + guard maximumPixelSize > 0 else { return nil } + let cacheKey = AidenAttachmentThumbnailCacheKey.make( + data: data, + maximumPixelSize: maximumPixelSize + ) as NSString + if let cached = thumbnailCache.object(forKey: cacheKey) { + return cached + } + guard !Task.isCancelled, + let source = CGImageSourceCreateWithData(data as CFData, nil), + let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maximumPixelSize, + kCGImageSourceShouldCacheImmediately: true, + ] as CFDictionary) + else { return nil } + guard !Task.isCancelled else { return nil } + let decoded = UIImage(cgImage: image) + thumbnailCache.setObject( + decoded, + forKey: cacheKey, + cost: image.bytesPerRow * image.height + ) + return decoded + } +} + +enum AidenPhotoLibrarySavingError: LocalizedError { + case denied + case invalidImage + + var errorDescription: String? { + switch self { + case .denied: String(localized: "Allow Aiden On The Go to add images in Photos Settings, then try again.") + case .invalidImage: String(localized: "One or more images could not be saved.") + } + } +} + +enum AidenPhotoLibrarySaving { + @MainActor + static func save( + attachments: [AidenMessageAttachment], + loadData: (AidenMessageAttachment) async -> Data? + ) async throws -> Int { + guard !attachments.isEmpty, attachments.count <= 20 else { + throw AidenPhotoLibrarySavingError.invalidImage + } + let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly) + guard status == .authorized || status == .limited else { + throw AidenPhotoLibrarySavingError.denied + } + + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory + .appending(path: "AidenPhotoSave-\(UUID().uuidString)", directoryHint: .isDirectory) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: directory) } + var urls: [URL] = [] + urls.reserveCapacity(attachments.count) + for (index, attachment) in attachments.enumerated() { + try Task.checkCancellation() + guard let data = await loadData(attachment) else { + throw AidenPhotoLibrarySavingError.invalidImage + } + let url = try await stage( + data: data, + attachment: attachment, + index: index, + directory: directory + ) + urls.append(url) + } + try await PHPhotoLibrary.shared().performChanges { + for url in urls { + PHAssetCreationRequest.forAsset().addResource( + with: .photo, + fileURL: url, + options: nil + ) + } + } + return urls.count + } + + private static func stage( + data: Data, + attachment: AidenMessageAttachment, + index: Int, + directory: URL + ) async throws -> URL { + let worker = Task.detached(priority: .utility) { + try Task.checkCancellation() + guard AidenAttachmentImageValidation.validatedData( + data, + mimeType: attachment.mimeType, + declaredSize: attachment.size + ) != nil else { throw AidenPhotoLibrarySavingError.invalidImage } + let ext = attachment.mimeType == "image/png" ? "png" : "jpg" + let url = directory.appending(path: "\(index)-\(UUID().uuidString).\(ext)") + try data.write(to: url, options: [.atomic, .completeFileProtection]) + try Task.checkCancellation() + return url + } + return try await withTaskCancellationHandler { + try await worker.value + } onCancel: { + worker.cancel() + } + } +} + +enum AidenMessageActionContent { + static func copyText(for message: AidenChatMessage) -> String? { + guard message.role == .assistant, !message.text.isEmpty else { return nil } + return message.text + } +} + +struct AidenMessageTextView: View { + let role: AidenChatRole + let content: String + + @ViewBuilder + var body: some View { + if role == .assistant { + AidenMarkdownView(content: content) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Text(verbatim: content) + .font(.body) + .textSelection(.enabled) + } + } +} + +enum AidenMarkdownFallbackReason: Equatable { + case tooManyCharacters + case tooManyLines +} + +enum AidenMarkdownRenderingPolicy { + static let maximumCharacterCount = 80_000 + static let maximumLineCount = 2_000 + + static func fallbackReason(for content: String) -> AidenMarkdownFallbackReason? { + if content.count > maximumCharacterCount { return .tooManyCharacters } + + var lineCount = 1 + var previousWasCarriageReturn = false + for scalar in content.unicodeScalars { + switch scalar.value { + case 0x0A: + if !previousWasCarriageReturn { lineCount += 1 } + previousWasCarriageReturn = false + case 0x0D: + lineCount += 1 + previousWasCarriageReturn = true + case 0x2028, 0x2029: + lineCount += 1 + previousWasCarriageReturn = false + default: + previousWasCarriageReturn = false + } + if lineCount > maximumLineCount { return .tooManyLines } + } + return nil + } +} + +enum AidenMarkdownDocument { + static func plainText(from content: String) -> String { + MarkdownContent(content).renderPlainText() + } +} + +struct AidenMarkdownView: View { + @Environment(\.colorScheme) private var colorScheme + let content: String + + var body: some View { + Group { + if content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(verbatim: " ") + } else if AidenMarkdownRenderingPolicy.fallbackReason(for: content) != nil { + Text(verbatim: content) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + } else { + MarkdownUI.Markdown(content) + .markdownTheme(.aidenChat(colorScheme: colorScheme)) + .markdownImageProvider(AidenMarkdownNoNetworkImageProvider()) + .markdownCodeSyntaxHighlighter(.plainText) + .markdownTextStyle { + ForegroundColor(.primary) + BackgroundColor(nil) + } + .markdownTextStyle(\.code) { + FontFamilyVariant(.monospaced) + FontSize(.em(0.88)) + BackgroundColor(Color(.tertiarySystemGroupedBackground)) + } + .markdownBlockStyle(\.paragraph) { configuration in + configuration.label + .fixedSize(horizontal: false, vertical: true) + .relativeLineSpacing(.em(0.18)) + .markdownMargin(top: 0, bottom: 8) + } + } + } + .textSelection(.enabled) + } +} + +private struct AidenMarkdownNoNetworkImageProvider: ImageProvider { + func makeImage(url: URL?) -> some View { + EmptyView() + } +} + +private extension MarkdownUI.Theme { + static func aidenChat(colorScheme: ColorScheme) -> MarkdownUI.Theme { + MarkdownUI.Theme.gitHub + .text { + ForegroundColor(.primary) + BackgroundColor(nil) + FontSize(16) + } + .code { + FontFamilyVariant(.monospaced) + FontSize(.em(0.85)) + BackgroundColor( + colorScheme == .dark + ? Color(red: 0.08, green: 0.09, blue: 0.12) + : Color(.tertiarySystemGroupedBackground) + ) + } + } +} + +private struct AidenLiveResponseView: View { + @Environment(\.aidenPalette) private var palette + @Environment(\.aidenReduceMotion) private var reduceMotion + @Bindable var model: AidenChatViewModel + + private var activity: (label: String, orb: OrbState) { + if model.streamState == .waitingForApproval { + return ("Waiting for approval", .listening) + } + if let tool = model.tools.last(where: { $0.status == nil }) { + let name = tool.name.lowercased() + let isSearch = ["search", "find", "read", "list", "glob", "grep"] + .contains { name.contains($0) } + return (isSearch ? "Searching…" : "Working…", isSearch ? .searching : .working) + } + if !model.liveText.isEmpty { + return ("Responding…", .composing) + } + if model.streamState == .queued { + return ("Preparing…", .shaping) + } + return ("Thinking…", .solving) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + if model.isStreaming && model.reasoning.isEmpty && model.activityTimeline?.steps.isEmpty != false { + HStack(spacing: 8) { + ThinkingOrb(state: activity.orb, size: .px20) + Text(activity.label) + .foregroundStyle(palette.secondary) + } + .font(.callout) + .accessibilityElement(children: .combine) + .transition(.opacity) + } + + if !model.reasoning.isEmpty { + AidenReasoningCard(text: model.reasoning, active: model.isStreaming) + .transition(.opacity) + } + + if let timeline = model.activityTimeline, !timeline.steps.isEmpty { + AidenActivityFeed(timeline: timeline, active: model.isStreaming) + .transition(.opacity) + } else if !model.tools.isEmpty { + AidenToolActivityCard(tools: model.tools) + } + + if let approval = model.pendingApproval { + AidenApprovalCard( + summary: approval.summary, + canAllow: approval.canAllow, + onDeny: { Task { await model.respondToApproval(.deny) } }, + onAllow: { Task { await model.respondToApproval(.allow) } } + ) + .id(approval.id) + } + + if !model.liveText.isEmpty { + AidenMarkdownView(content: model.liveText) + .contextMenu { + Button { + UIPasteboard.general.string = model.liveText + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + } + .accessibilityActions { + Button("Copy response") { + UIPasteboard.general.string = model.liveText + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .animation(reduceMotion ? nil : .easeOut(duration: 0.18), value: model.activityTimeline?.steps.last?.id) + } +} + +private struct AidenApprovalCard: View { + @Environment(\.aidenPalette) private var palette + @Environment(\.aidenReduceMotion) private var reduceMotion + @State private var isExpanded = false + + let summary: String + let canAllow: Bool + let onDeny: () -> Void + let onAllow: () -> Void + + private let shape = RoundedRectangle(cornerRadius: 14, style: .continuous) + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "shield") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(palette.warning) + .frame(width: 32, height: 32) + .background(palette.warning.opacity(0.12), in: Circle()) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text("Approval needed") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(palette.foreground) + + Text("Review this one action before Aiden continues.") + .font(.caption) + .foregroundStyle(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Button { + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.18)) { + isExpanded.toggle() + } + } label: { + HStack(spacing: 8) { + Text(AidenApprovalPresentation.oneLineSummary(summary)) + .font(.caption.monospaced()) + .foregroundStyle(palette.foreground) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(palette.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .padding(.horizontal, 10) + .frame(height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(palette.canvas, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .accessibilityLabel("Requested action") + .accessibilityValue(AidenApprovalPresentation.oneLineSummary(summary)) + .accessibilityHint(isExpanded ? "Collapses action details" : "Expands action details") + + if isExpanded { + Text(summary) + .font(.caption.monospaced()) + .foregroundStyle(palette.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(palette.canvas, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .transition(.opacity.combined(with: .move(edge: .top))) + } + + HStack(spacing: 8) { + Spacer(minLength: 0) + + Button(action: onDeny) { + Text("Deny") + .font(.caption.weight(.semibold)) + .foregroundStyle(palette.foreground) + .padding(.horizontal, 13) + .frame(height: 34) + .aidenApprovalActionGlass() + } + .buttonStyle(.plain) + .padding(.vertical, 5) + + if canAllow { + Button(action: onAllow) { + Text("Allow once") + .font(.caption.weight(.semibold)) + .foregroundStyle(palette.canvas) + .padding(.horizontal, 13) + .frame(height: 34) + .aidenApprovalActionGlass(tint: palette.accent) + } + .buttonStyle(.plain) + .padding(.vertical, 5) + } + } + } + .padding(12) + .background(palette.raised, in: shape) + .overlay(shape.stroke(palette.foreground.opacity(0.08), lineWidth: 0.5)) + .shadow(color: palette.foreground.opacity(0.08), radius: 8, y: 3) + .accessibilityElement(children: .contain) + } +} + +private struct AidenApprovalActionGlassModifier: ViewModifier { + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.aidenPalette) private var palette + + let tint: Color? + + @ViewBuilder + func body(content: Content) -> some View { + if #available(iOS 26, *), !reduceTransparency { + if let tint { + content.glassEffect(.regular.tint(tint).interactive(), in: Capsule()) + } else { + content.glassEffect(.regular.interactive(), in: Capsule()) + } + } else if let tint { + content.background(tint, in: Capsule()) + } else if reduceTransparency { + content + .background(palette.canvas, in: Capsule()) + .overlay(Capsule().stroke(palette.foreground.opacity(0.14), lineWidth: 0.5)) + } else { + content + .background(.regularMaterial, in: Capsule()) + .overlay(Capsule().stroke(palette.foreground.opacity(0.10), lineWidth: 0.5)) + } + } +} + +private extension View { + func aidenApprovalActionGlass(tint: Color? = nil) -> some View { + modifier(AidenApprovalActionGlassModifier(tint: tint)) + } +} + +private struct AidenReasoningCard: View { + @Environment(\.aidenPalette) private var palette + @Environment(\.aidenReduceMotion) private var reduceMotion + let text: String + let active: Bool + @State private var isExpanded = true + @State private var userControlled = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + userControlled = true + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.15)) { + isExpanded.toggle() + } + } label: { + HStack(spacing: 8) { + Text(active ? "Thinking…" : "Thinking") + .font(.caption.weight(.semibold)) + .aidenActivityShimmer(active) + Spacer(minLength: 6) + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(palette.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .frame(height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + ScrollView { + Text(text) + .font(.caption) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } + .frame(maxHeight: 144, alignment: .top) + .padding(.bottom, 10) + .transition(.opacity) + } + } + .padding(.horizontal, 12) + .background(palette.raised, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .accessibilityElement(children: .contain) + .task { + guard active else { + isExpanded = false + return + } + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled, !userControlled else { return } + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.15)) { + isExpanded = false + } + } + } +} + +private struct AidenToolActivityCard: View { + @Environment(\.aidenPalette) private var palette + @Environment(\.aidenReduceMotion) private var reduceMotion + let tools: [AidenLiveTool] + @State private var isExpanded = false + + private var isComplete: Bool { + !tools.isEmpty && tools.allSatisfy { tool in + guard let status = tool.status?.lowercased() else { return false } + return ["completed", "complete", "succeeded", "success"].contains(status) + } + } + private var hasIssue: Bool { + tools.contains { tool in + guard let status = tool.status?.lowercased() else { return false } + return ["failed", "blocked", "cancelled", "canceled", "denied"].contains(status) + } + } + private var summary: String { + let names = Array(Set(tools.map(\.name))).sorted() + let visible = names.prefix(3).joined(separator: ", ") + return names.count > 3 ? "\(visible), +\(names.count - 3)" : visible + } + + var body: some View { + VStack(alignment: .leading, spacing: isExpanded ? 8 : 0) { + Button { + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.18)) { + isExpanded.toggle() + } + } label: { + HStack(spacing: 8) { + Image(systemName: hasIssue ? "exclamationmark.circle.fill" : (isComplete ? "checkmark.circle.fill" : "wrench.and.screwdriver.fill")) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(palette.secondary) + .frame(width: 18, height: 18) + Text(hasIssue ? "Tool issue" : (isComplete ? "Tools used" : "Using tools")) + .font(.caption.weight(.semibold)) + Text(summary).font(.caption).foregroundStyle(palette.secondary).lineLimit(1) + Spacer(minLength: 6) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption2.weight(.semibold)) + .foregroundStyle(palette.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + VStack(alignment: .leading, spacing: 6) { + ForEach(tools) { tool in + HStack(spacing: 8) { + Image(systemName: legacySymbol(for: tool.status)) + Text(tool.name).lineLimit(1) + Spacer() + Text(tool.status ?? "Running").foregroundStyle(palette.secondary) + } + .font(.caption) + } + } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + + private func legacySymbol(for status: String?) -> String { + guard let status = status?.lowercased() else { return "circle.dotted" } + if ["completed", "complete", "succeeded", "success"].contains(status) { return "checkmark.circle" } + if ["failed", "blocked", "cancelled", "canceled", "denied"].contains(status) { + return "exclamationmark.circle" + } + return "circle.dotted" + } +} + +private struct AidenComposerView: View { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.aidenPalette) private var palette + @Environment(\.aidenReduceMotion) private var reduceMotion + @Bindable var model: AidenChatViewModel + let autoStartVoice: Bool + let composerFocus: FocusState.Binding + @State private var voiceInput = ComposerVoiceInputController() + @State private var didAutoStartVoice = false + @State private var selectedPhotos: [PhotosPickerItem] = [] + @State private var isPhotoPickerPresented = false + @State private var isFileImporterPresented = false + @State private var isPreparingAttachments = false + @State private var attachmentPreparationTask: Task? + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + if !model.pendingAttachments.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(model.pendingAttachments) { attachment in + HStack(spacing: 6) { + Label(attachment.name, systemImage: attachment.kind == .image ? "photo" : "doc.text") + .lineLimit(1) + Button { + Task { await model.removeAttachment(attachment) } + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .accessibilityLabel("Remove \(attachment.name)") + } + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(palette.raised, in: Capsule()) + } + } + } + .accessibilityLabel("Attachments") + } + + TextField("Message Aiden", text: $model.draft, axis: .vertical) + .lineLimit(1...6) + .padding(.horizontal, 4) + .padding(.top, 5) + .focused(composerFocus) + .submitLabel(.send) + .onSubmit { + guard !model.isStreaming else { return } + voiceInput.stopBeforeSubmittingDraft() + Task { await model.send() } + } + + HStack(alignment: .center, spacing: 10) { + AidenUIKitMenuButton { + if model.isUploadingAttachment || isPreparingAttachments { + ProgressView().controlSize(.small).frame(width: 44, height: 44) + } else { + Image(systemName: "plus") + .font(.title3.weight(.medium)) + .frame(width: 44, height: 44) + } + } menu: { + attachmentMenu() + } + .disabled( + !model.isConnected || model.isStreaming || model.isUploadingAttachment + || isPreparingAttachments || model.pendingAttachments.count >= 10 + ) + .accessibilityLabel("Add attachment") + .accessibilityHint("Attach an image or bounded text file") + .photosPicker( + isPresented: $isPhotoPickerPresented, + selection: $selectedPhotos, + maxSelectionCount: max(1, attachmentCapacity), + matching: .images + ) + + if !model.visibleProviders.isEmpty { + Menu { + ForEach(model.visibleProviders) { provider in + Section { + ForEach(provider.models) { candidate in + if let levels = candidate.thinkingLevels, !levels.isEmpty { + Menu { + ForEach(levels, id: \.self) { level in + Button { + select( + candidate, + providerId: provider.id, + thinkingLevel: level + ) + } label: { + if isSelected(candidate, providerId: provider.id, thinkingLevel: level) { + Label(candidate.thinkingLabel(for: level), systemImage: "checkmark") + } else { + Text(candidate.thinkingLabel(for: level)) + } + } + } + } label: { Text(candidate.label) } + } else { + Button { + select(candidate, providerId: provider.id, thinkingLevel: nil) + } label: { + if isSelected(candidate, providerId: provider.id, thinkingLevel: nil) { + Label(candidate.label, systemImage: "checkmark") + } else { + Text(candidate.label) + } + } + } + } + } header: { + Label { + Text(provider.label) + } icon: { + AidenProviderIcon( + providerID: provider.id, + providerLabel: provider.label, + artwork: provider.artwork, + size: 16, + color: palette.secondary + ) + } + } + } + } label: { + HStack(spacing: 4) { + if let provider = model.selectedProvider { + AidenProviderIcon( + providerID: provider.id, + providerLabel: provider.label, + modelID: model.selectedModel?.id, + artwork: provider.artwork, + size: 15, + color: palette.secondary + ) + } + Text(model.selectedModel?.label ?? "Model").lineLimit(1) + if let level = model.selectedThinkingLevel, + model.selectedModel?.thinkingLevels?.isEmpty == false { + Text("· \(level.capitalized)") + .lineLimit(1) + .foregroundStyle(palette.secondary.opacity(0.8)) + } + Image(systemName: "chevron.down").font(.caption2) + } + .font(.caption) + .foregroundStyle(palette.secondary) + .frame(maxWidth: 180, alignment: .leading) + .frame(minHeight: 44) + } + .accessibilityLabel("Model") + .accessibilityValue(selectedModelAccessibilityValue) + } else if let selectedModel = model.selectedModel { + HStack(spacing: 4) { + Text(selectedModel.label).lineLimit(1) + Text("· Hidden") + .lineLimit(1) + .foregroundStyle(palette.secondary.opacity(0.8)) + } + .font(.caption) + .foregroundStyle(palette.secondary) + .frame(maxWidth: 180, alignment: .leading) + .frame(minHeight: 44) + .accessibilityElement(children: .combine) + .accessibilityLabel("Model") + .accessibilityValue("\(selectedModel.label), hidden from picker") + } + + Spacer(minLength: 0) + + Button { + Task { + await voiceInput.toggle(currentDraft: model.draft) { model.draft = $0 } + } + } label: { + Group { + if voiceInput.isListening { + AidenListeningWaveform(isAnimated: !reduceMotion) + } else { + Image(systemName: "mic") + .font(.body.weight(.medium)) + } + } + .frame(width: 44, height: 44) + } + .disabled(model.isStreaming || voiceInput.isRequestingPermission) + .accessibilityLabel(voiceInput.isListening ? "Stop voice input" : "Start voice input") + + if model.isStreaming { + Button { Task { await model.stop() } } label: { + Image(systemName: "stop.fill") + .frame(width: 30, height: 30) + .background(palette.foreground, in: Circle()) + .foregroundStyle(palette.canvas) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel("Stop response") + } else { + Button { + voiceInput.stopBeforeSubmittingDraft() + Task { await model.send() } + } label: { + Image(systemName: "arrow.up") + .font(.headline.bold()) + .frame(width: 30, height: 30) + .background(sendButtonBackground, in: Circle()) + .foregroundStyle(sendButtonForeground) + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .disabled(!model.canSend) + .accessibilityLabel("Send message") + } + } + + if let error = voiceInput.errorMessage, !voiceInput.isListening { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 4) + .aidenComposerGlass() + .overlay { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(palette.secondary.opacity(0.35), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.12), radius: 14, y: 6) + .task { + guard autoStartVoice, !didAutoStartVoice else { return } + didAutoStartVoice = true + await voiceInput.toggle(currentDraft: model.draft) { model.draft = $0 } + } + .onChange(of: selectedPhotos) { _, items in + guard !items.isEmpty, !isPreparingAttachments else { return } + let selected = Array(items.prefix(attachmentCapacity)) + selectedPhotos = [] + isPreparingAttachments = true + attachmentPreparationTask = Task { + defer { + isPreparingAttachments = false + attachmentPreparationTask = nil + } + var uploads: [AidenAttachmentUpload] = [] + var preparationFailures = 0 + for item in selected { + guard !Task.isCancelled else { return } + do { + guard let picked = try await item.loadTransferable(type: AidenPickedImageFile.self) else { + throw AidenAttachmentPreparationError.invalidImage + } + defer { try? FileManager.default.removeItem(at: picked.url) } + let upload = try await AidenAttachmentPreparation.fileUploadAsync( + url: picked.url, + preferredName: picked.name, + forceImage: true + ) + uploads.append(upload) + } catch let error where aidenIsCancellation(error) { + return + } catch { + preparationFailures += 1 + } + } + guard !Task.isCancelled else { return } + let uploadFailures = await model.upload(uploads) + guard !Task.isCancelled else { return } + presentAttachmentFailures(preparationFailures + uploadFailures) + } + } + .fileImporter( + isPresented: $isFileImporterPresented, + allowedContentTypes: [.image, .plainText, .sourceCode, .json, .xml, .commaSeparatedText], + allowsMultipleSelection: true + ) { result in + let capacity = attachmentCapacity + guard capacity > 0, !isPreparingAttachments else { return } + isPreparingAttachments = true + attachmentPreparationTask = Task { + defer { + isPreparingAttachments = false + attachmentPreparationTask = nil + } + var uploads: [AidenAttachmentUpload] = [] + var preparationFailures = 0 + do { + let urls = try result.get() + preparationFailures += max(0, urls.count - capacity) + for url in urls.prefix(capacity) { + guard !Task.isCancelled else { return } + do { + let upload = try await AidenAttachmentPreparation.fileUploadAsync(url: url) + uploads.append(upload) + } catch let error where aidenIsCancellation(error) { + return + } catch { + preparationFailures += 1 + } + } + } catch { + preparationFailures += 1 + } + guard !Task.isCancelled else { return } + let uploadFailures = await model.upload(uploads) + guard !Task.isCancelled else { return } + presentAttachmentFailures(preparationFailures + uploadFailures) + } + } + .onDisappear { + voiceInput.stopKeepingTranscript() + attachmentPreparationTask?.cancel() + attachmentPreparationTask = nil + isPreparingAttachments = false + } + } + + private func attachmentMenu() -> UIMenu { + UIMenu(children: [ + UIAction( + title: String(localized: "Photo Library"), + image: UIImage(systemName: "photo.on.rectangle") + ) { _ in + Task { @MainActor in + composerFocus.wrappedValue = false + isPhotoPickerPresented = true + } + }, + UIAction( + title: String(localized: "Choose File"), + image: UIImage(systemName: "doc") + ) { _ in + Task { @MainActor in + composerFocus.wrappedValue = false + isFileImporterPresented = true + } + }, + ]) + } + + private var attachmentCapacity: Int { + max(0, 10 - model.pendingAttachments.count) + } + + private func presentAttachmentFailures(_ count: Int) { + guard count > 0 else { return } + model.presentedError = count == 1 + ? String(localized: "One selected attachment could not be added. Other attachments are still ready to send.") + : String(localized: "\(count) selected attachments could not be added. Other attachments are still ready to send.") + } + + private var sendButtonBackground: Color { + if model.canSend { return palette.accent } + return palette.foreground.opacity(colorScheme == .dark ? 0.18 : 0.12) + } + + private var sendButtonForeground: Color { + model.canSend ? palette.canvas : palette.secondary + } + + private var selectedModelAccessibilityValue: String { + guard let selectedModel = model.selectedModel else { return "Not selected" } + guard let level = model.selectedThinkingLevel, + selectedModel.thinkingLevels?.isEmpty == false + else { return selectedModel.label } + return "\(selectedModel.label), \(level.capitalized) thinking" + } + + private func select(_ candidate: AidenModel, providerId: String, thinkingLevel: String?) { + model.selectModel( + providerId: providerId, + modelId: candidate.id, + thinkingLevel: thinkingLevel + ) + } + + private func isSelected( + _ candidate: AidenModel, + providerId: String, + thinkingLevel: String? + ) -> Bool { + guard model.selectedProviderId == providerId, + model.selectedModelId == candidate.id + else { return false } + return thinkingLevel == nil || model.selectedThinkingLevel == thinkingLevel + } +} + +private struct AidenListeningWaveform: View { + let isAnimated: Bool + + var body: some View { + TimelineView(.animation(minimumInterval: 1 / 12, paused: !isAnimated)) { context in + let phase = isAnimated ? context.date.timeIntervalSinceReferenceDate * 7 : 0 + HStack(alignment: .center, spacing: 2) { + ForEach(0..<5, id: \.self) { index in + let offset = Double(index) * 0.85 + let amplitude = isAnimated ? abs(sin(phase + offset)) : 0.45 + Capsule(style: .continuous) + .frame(width: 2.5, height: 19) + .scaleEffect( + x: 1, + y: (7 + (amplitude * 12)) / 19, + anchor: .center + ) + } + } + .frame(width: 24, height: 22) + } + .accessibilityHidden(true) + } +} + +private struct AidenUIKitMenuButton: View { + @Environment(\.isEnabled) private var isEnabled + + private let menu: () -> UIMenu + private let label: Label + + init( + @ViewBuilder label: () -> Label, + menu: @escaping () -> UIMenu + ) { + self.label = label() + self.menu = menu + } + + var body: some View { + label + .opacity(isEnabled ? 1 : 0.62) + .overlay { + AidenUIKitMenuButtonBacker(menu: menu) + } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + } +} + +private struct AidenUIKitMenuButtonBacker: UIViewControllerRepresentable { + @Environment(\.isEnabled) private var isEnabled + let menu: () -> UIMenu + + func makeCoordinator() -> Coordinator { + Coordinator(menu: menu) + } + + func makeUIViewController(context: Context) -> AidenMenuButtonHostController { + let controller = AidenMenuButtonHostController() + let button = controller.button + button.menu = UIMenu(children: [ + UIDeferredMenuElement.uncached { completion in + completion(context.coordinator.menu().children) + }, + ]) + button.isEnabled = isEnabled + button.isAccessibilityElement = false + return controller + } + + func updateUIViewController(_ controller: AidenMenuButtonHostController, context: Context) { + context.coordinator.menu = menu + controller.button.isEnabled = isEnabled + } + + func sizeThatFits( + _ proposal: ProposedViewSize, + uiViewController: AidenMenuButtonHostController, + context: Context + ) -> CGSize? { + CGSize( + width: proposal.width ?? UIView.noIntrinsicMetric, + height: proposal.height ?? UIView.noIntrinsicMetric + ) + } + + final class Coordinator { + var menu: () -> UIMenu + + init(menu: @escaping () -> UIMenu) { + self.menu = menu + } + } +} + +private final class AidenMenuButtonHostController: UIViewController { + let button = UIButton(type: .custom) + + override func loadView() { + let container = UIView() + container.backgroundColor = .clear + container.isOpaque = false + view = container + + button.showsMenuAsPrimaryAction = true + button.backgroundColor = .clear + button.setTitle(nil, for: .normal) + button.setImage(nil, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + + container.addSubview(button) + NSLayoutConstraint.activate([ + button.leadingAnchor.constraint(equalTo: container.leadingAnchor), + button.trailingAnchor.constraint(equalTo: container.trailingAnchor), + button.topAnchor.constraint(equalTo: container.topAnchor), + button.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + } +} + +private struct AidenComposerGlassModifier: ViewModifier { + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.aidenPalette) private var palette + + private let shape = RoundedRectangle(cornerRadius: 24, style: .continuous) + + @ViewBuilder + func body(content: Content) -> some View { + if #available(iOS 26, *), !reduceTransparency { + content.glassEffect(.regular.interactive(), in: shape) + } else if reduceTransparency { + content.background(palette.raised, in: shape) + } else { + content.background(.regularMaterial, in: shape) + } + } +} + +private extension View { + func aidenComposerGlass() -> some View { + modifier(AidenComposerGlassModifier()) + } +} + +@MainActor +final class AidenSpeechPlaybackController { + private let synthesizer = AVSpeechSynthesizer() + + func speak(_ text: String) { + let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { return } + if synthesizer.isSpeaking { + synthesizer.stopSpeaking(at: .immediate) + } + let utterance = AVSpeechUtterance(string: String(cleaned.prefix(8_000))) + utterance.voice = AVSpeechSynthesisVoice(language: Locale.current.language.languageCode?.identifier) + synthesizer.speak(utterance) + } +} diff --git a/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift b/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift new file mode 100644 index 00000000..cd7ad325 --- /dev/null +++ b/ios/AidenOnTheGo/Features/Remote/AidenPairingView.swift @@ -0,0 +1,1017 @@ +import AVFoundation +import SwiftUI + +struct AidenDiscoveredAgent: Identifiable, Equatable { + let id: String + let name: String + let endpoint: String? +} + +enum AidenPairingAlertCopy { + static let title = String(localized: "Aiden On The Go") + static let fallbackMessage = String(localized: "Try again from Aiden Agent Remote Access settings.") +} + +enum AidenDiscoveryIdentity { + static func instanceID(fromTXTRecord data: Data?) -> String? { + guard let data, + let value = NetService.dictionary(fromTXTRecord: data)["instance"], + let instanceID = String(data: value, encoding: .utf8), + !instanceID.isEmpty, + instanceID.unicodeScalars.count <= AidenRemoteProtocol.maxIdentifierLength, + instanceID.unicodeScalars.allSatisfy({ + CharacterSet.alphanumerics.contains($0) + || CharacterSet(charactersIn: "_-").contains($0) + }) else { return nil } + return instanceID + } +} + +final class AidenDiscoveryModel: NSObject, ObservableObject, NetServiceBrowserDelegate, NetServiceDelegate { + @Published private(set) var agents: [AidenDiscoveredAgent] = [] + @Published private(set) var isSearching = false + + private let browser = NetServiceBrowser() + private var services: [String: NetService] = [:] + + override init() { + super.init() + browser.delegate = self + } + + func start() { + guard !isSearching else { return } + isSearching = true + browser.searchForServices(ofType: "_aiden-agent._tcp.", inDomain: "local.") + } + + func stop() { + browser.stop() + isSearching = false + } + + func netServiceBrowserWillSearch(_ browser: NetServiceBrowser) { + isSearching = true + } + + func netServiceBrowserDidStopSearch(_ browser: NetServiceBrowser) { + isSearching = false + } + + func netServiceBrowser( + _ browser: NetServiceBrowser, + didNotSearch errorDict: [String: NSNumber] + ) { + isSearching = false + } + + func netServiceBrowser( + _ browser: NetServiceBrowser, + didFind service: NetService, + moreComing: Bool + ) { + services[service.name] = service + service.delegate = self + service.resolve(withTimeout: 5) + if !moreComing { publish() } + } + + func netServiceBrowser( + _ browser: NetServiceBrowser, + didRemove service: NetService, + moreComing: Bool + ) { + services[service.name] = nil + if !moreComing { publish() } + } + + func netServiceDidResolveAddress(_ sender: NetService) { + publish() + } + + private func publish() { + agents = services.values.map { service in + let endpoint: String? + if let hostName = service.hostName?.trimmingCharacters(in: CharacterSet(charactersIn: ".")), + !hostName.isEmpty, + service.port > 0 { + endpoint = "https://\(hostName):\(service.port)\(AidenRemoteProtocol.basePath)" + } else { + endpoint = nil + } + return AidenDiscoveredAgent( + id: AidenDiscoveryIdentity.instanceID(fromTXTRecord: service.txtRecordData()) + ?? "\(service.domain)|\(service.type)|\(service.name)", + name: service.name, + endpoint: endpoint + ) + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } +} + +enum AidenPairingMethod: String, CaseIterable, Identifiable, Hashable { + case scanQRCode + case nearbyMac + case privateAddress + case pastePayload + + var id: String { rawValue } + + static let primary: [AidenPairingMethod] = [ + .scanQRCode, + .nearbyMac, + .privateAddress, + ] + + static let advanced: [AidenPairingMethod] = [.pastePayload] + + var tabTitle: String { + switch self { + case .scanQRCode: return String(localized: "QR") + case .nearbyMac: return String(localized: "Nearby") + case .privateAddress: return String(localized: "Tailscale") + case .pastePayload: return String(localized: "Payload") + } + } + + var title: String { + switch self { + case .scanQRCode: return String(localized: "Scan QR Code") + case .nearbyMac: return String(localized: "Nearby Mac + Setup Code") + case .privateAddress: return String(localized: "Private Address + Setup Code") + case .pastePayload: return String(localized: "Paste Pairing Payload") + } + } + + var detail: String { + switch self { + 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.") + case .privateAddress: + return String(localized: "Enter the private Tailscale address and setup code shown on your Mac.") + case .pastePayload: + return String(localized: "Use the complete one-time payload when the camera is unavailable.") + } + } + + var systemImage: String { + switch self { + case .scanQRCode: return "qrcode.viewfinder" + case .nearbyMac: return "wifi" + case .privateAddress: return "network" + case .pastePayload: return "doc.on.clipboard" + } + } + + var badge: String? { + switch self { + case .scanQRCode: return String(localized: "Recommended") + case .nearbyMac: return String(localized: "Local Network") + case .privateAddress: return String(localized: "Tailscale") + case .pastePayload: return nil + } + } +} + +enum AidenMobileOnboardingPhase: String, CaseIterable, Identifiable, Hashable { + case build + case extend + case control + + var id: String { rawValue } + + var eyebrow: String { + switch self { + case .build: return String(localized: "BUILD IN YOUR WORKSPACE") + case .extend: return String(localized: "CHOOSE AND EXTEND") + case .control: return String(localized: "AUTOMATE AND STAY IN CONTROL") + } + } + + var title: String { + switch self { + case .build: return String(localized: "Your Mac, ready to work") + case .extend: return String(localized: "Bring the right intelligence") + case .control: return String(localized: "Keep Aiden moving") + } + } + + var detail: String { + switch self { + case .build: + return String(localized: "Chat with a workspace agent that can read and edit files, run commands, review diffs, and work with Git.") + 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: + return String(localized: "Approve actions, manage scheduled work, use voice, and follow private usage from your iPhone or iPad.") + } + } + + var imageName: String { + switch self { + case .build: return "OnboardingBuild" + case .extend: return "OnboardingExtend" + case .control: return "OnboardingControl" + } + } +} + +enum AidenMobileOnboardingLayout { + static let maximumContentWidth: CGFloat = 620 + static let maximumContentHeight: CGFloat = 760 + static let maximumActionWidth: CGFloat = 360 + static let actionHorizontalPadding: CGFloat = 24 + static let actionBottomPadding: CGFloat = 12 + static let artworkSide: CGFloat = 232 + + static func contentWidth(for availableWidth: CGFloat) -> CGFloat { + max(0, min(availableWidth, maximumContentWidth)) + } + + static func contentHeight(for availableHeight: CGFloat) -> CGFloat { + max(0, min(availableHeight, maximumContentHeight)) + } +} + +struct AidenPairingView: View { + @Bindable var coordinator: AidenRemoteCoordinator + @Environment(AidenAppearanceStore.self) private var appearance + @Environment(\.aidenPalette) private var palette + @Environment(\.aidenCodeTypography) private var codeTypography + var onPaired: (() -> Void)? + + @StateObject private var discovery = AidenDiscoveryModel() + @State private var pairingPayload = "" + @State private var manualCode = "" + @State private var manualEndpoint = "" + @State private var selectedAgentID: String? + @State private var selectedPairingMethod: AidenPairingMethod = .scanQRCode + @State private var selectedOnboardingPhase: AidenMobileOnboardingPhase = .build + @State private var isShowingScanner = false + @State private var isShowingAppearance = false + @State private var isShowingPayloadFallback = false + @State private var pairingTask: Task? + @State private var hapticScope = UUID() + @State private var step: Int + + private let onIntroductionComplete: (() -> Void)? + + init( + coordinator: AidenRemoteCoordinator, + showsIntroduction: Bool = true, + onIntroductionComplete: (() -> Void)? = nil, + onPaired: (() -> Void)? = nil + ) { + self.coordinator = coordinator + self.onIntroductionComplete = onIntroductionComplete + self.onPaired = onPaired + _step = State(initialValue: onPaired == nil && showsIntroduction ? 0 : 2) + } + + private var canPair: Bool { + !pairingPayload.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !coordinator.isMutating + } + + private var manualEndpointURL: URL? { + let value = manualEndpoint.trimmingCharacters(in: .whitespacesAndNewlines) + guard isCanonicalAidenEndpoint(value) else { return nil } + return URL(string: value) + } + + private var canPairManually: Bool { + (try? AidenRemoteClient.normalizeManualPairingCode(manualCode)) != nil + && manualEndpointURL != nil + && !coordinator.isMutating + } + + var body: some View { + NavigationStack { + Group { + switch step { + case 0: welcomePage + case 1: preparePage + default: pairingPage + } + } + .background(palette.canvas) + .toolbar { + if step > 0 && onPaired == nil { + ToolbarItem(placement: .topBarLeading) { + Button { step -= 1 } label: { Image(systemName: "chevron.left") } + .accessibilityLabel("Back") + } + } + ToolbarItem(placement: .topBarTrailing) { + if step == 2 { + Menu { + Button { + isShowingPayloadFallback = true + } label: { + Label("Paste Pairing Payload", systemImage: "doc.on.clipboard") + } + Button { + isShowingAppearance = true + } label: { + Label("Appearance", systemImage: "circle.lefthalf.filled") + } + } label: { + Image(systemName: "ellipsis") + } + .accessibilityLabel("More pairing options") + } else { + Button { + isShowingAppearance = true + } label: { + Image(systemName: "circle.lefthalf.filled") + } + .accessibilityLabel("Appearance") + } + } + } + .overlay { + if coordinator.isMutating { + ProgressView("Pairing…") + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + } + .task(id: step) { + if step == 2 { + discovery.start() + } else { + discovery.stop() + } + } + .onDisappear { + coordinator.haptics.deactivate(scope: hapticScope) + pairingTask?.cancel() + pairingTask = nil + discovery.stop() + pairingPayload = "" + manualCode = "" + manualEndpoint = "" + selectedAgentID = nil + } + .onAppear { coordinator.haptics.activate(scope: hapticScope) } + .sheet(isPresented: $isShowingScanner) { + NavigationStack { + AidenQRCodeScanner { payload in + pairingPayload = payload + isShowingScanner = false + startPairing { await coordinator.pair(qrPayload: payload) } + } + .ignoresSafeArea(edges: .bottom) + .navigationTitle("Scan Pairing QR") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { isShowingScanner = false } + } + } + } + } + .sheet(isPresented: $isShowingAppearance) { + AidenAppearanceSettingsView(appearance: appearance) + } + .sheet(isPresented: $isShowingPayloadFallback) { + NavigationStack { + pastePayloadPairingPage + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { isShowingPayloadFallback = false } + } + } + } + } + .alert( + AidenPairingAlertCopy.title, + isPresented: Binding( + get: { coordinator.presentedError != nil }, + set: { if !$0 { coordinator.presentedError = nil } } + ) + ) { + Button("OK", role: .cancel) { coordinator.presentedError = nil } + } message: { + Text(coordinator.presentedError ?? AidenPairingAlertCopy.fallbackMessage) + } + } + } + + private var welcomePage: some View { + GeometryReader { proxy in + VStack(spacing: 0) { + HStack(spacing: 10) { + Image("AidenAppIcon") + .resizable() + .scaledToFit() + .frame(width: 42, height: 42) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text("Aiden On The Go") + .font(.headline) + Text("Aiden, wherever you are.") + .font(.caption) + .foregroundStyle(palette.secondary) + } + Spacer() + } + .padding(.horizontal, 24) + .padding(.top, 16) + + TabView(selection: $selectedOnboardingPhase) { + ForEach(AidenMobileOnboardingPhase.allCases) { phase in + onboardingPhasePage(phase) + .tag(phase) + } + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .accessibilityLabel("Aiden capabilities") + + } + .frame( + width: AidenMobileOnboardingLayout.contentWidth(for: proxy.size.width), + height: AidenMobileOnboardingLayout.contentHeight(for: proxy.size.height) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 12) { + onboardingPageIndicator + onboardingActionButton(action: advanceOnboarding) { + Text(isOnboardingLastPage ? "Set Up Connection" : "Continue") + } + } + .padding(.bottom, AidenMobileOnboardingLayout.actionBottomPadding) + } + .navigationBarHidden(true) + } + + private func onboardingPhasePage(_ phase: AidenMobileOnboardingPhase) -> some View { + ViewThatFits(in: .vertical) { + VStack(spacing: 0) { + Spacer(minLength: 16) + onboardingPhaseContent(phase) + Spacer(minLength: 16) + } + + ScrollView { + onboardingPhaseContent(phase) + .padding(.vertical, 16) + } + .scrollIndicators(.hidden) + } + } + + private func onboardingPhaseContent(_ phase: AidenMobileOnboardingPhase) -> some View { + VStack(spacing: 22) { + ZStack { + Circle() + .fill(palette.accent.opacity(0.07)) + .frame( + width: AidenMobileOnboardingLayout.artworkSide, + height: AidenMobileOnboardingLayout.artworkSide + ) + Circle() + .stroke(palette.accent.opacity(0.14), lineWidth: 1) + .frame(width: 196, height: 196) + Image(phase.imageName) + .resizable() + .scaledToFit() + .frame( + maxWidth: AidenMobileOnboardingLayout.artworkSide, + maxHeight: AidenMobileOnboardingLayout.artworkSide + ) + .accessibilityHidden(true) + } + .frame(maxWidth: .infinity) + + VStack(spacing: 10) { + Text(phase.eyebrow) + .font(.caption2.weight(.bold)) + .tracking(0.8) + .foregroundStyle(palette.accent) + Text(phase.title) + .font(.title.bold()) + .multilineTextAlignment(.center) + Text(phase.detail) + .font(.subheadline) + .foregroundStyle(palette.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: 480) + .padding(.horizontal, 26) + } + .frame(maxWidth: .infinity) + } + + private var isOnboardingLastPage: Bool { + selectedOnboardingPhase == AidenMobileOnboardingPhase.allCases.last + } + + private var onboardingPageIndicator: some View { + HStack(spacing: 4) { + ForEach(AidenMobileOnboardingPhase.allCases) { phase in + Button { + selectedOnboardingPhase = phase + } label: { + Capsule() + .fill(phase == selectedOnboardingPhase ? palette.accent : palette.secondary.opacity(0.24)) + .frame(width: phase == selectedOnboardingPhase ? 22 : 7, height: 7) + .frame(width: 34, height: 28) + } + .buttonStyle(.plain) + .accessibilityLabel(phase.title) + .accessibilityValue(phase == selectedOnboardingPhase ? "Current page" : "") + } + } + } + + private func advanceOnboarding() { + if isOnboardingLastPage { + step = 1 + } else if let index = AidenMobileOnboardingPhase.allCases.firstIndex(of: selectedOnboardingPhase) { + selectedOnboardingPhase = AidenMobileOnboardingPhase.allCases[index + 1] + } + } + + private func onboardingActionButton( + action: @escaping () -> Void, + @ViewBuilder label: () -> Label + ) -> some View { + prominentGlassButton(action: action, label: label) + .frame(maxWidth: AidenMobileOnboardingLayout.maximumActionWidth) + .padding(.horizontal, AidenMobileOnboardingLayout.actionHorizontalPadding) + } + + @ViewBuilder + private func prominentGlassButton( + action: @escaping () -> Void, + @ViewBuilder label: () -> Label + ) -> some View { + let button = Button(action: action) { + label() + .frame(maxWidth: .infinity) + } + .font(.headline) + .frame(maxWidth: .infinity) + .controlSize(.large) + + if #available(iOS 26, *) { + button.buttonStyle(.glassProminent) + } else { + button.buttonStyle(.borderedProminent) + } + } + + private var preparePage: some View { + ScrollView { + VStack(alignment: .leading, spacing: 28) { + VStack(alignment: .leading, spacing: 8) { + Text("Prepare your Mac").font(.largeTitle.bold()) + Text("Aiden Agent remains the server and keeps provider credentials on your Mac.") + .foregroundStyle(palette.secondary) + } + + pairingStep(number: 1, title: "Open Aiden Agent", detail: "On your Mac, go to Settings → Remote Access.") + pairingStep(number: 2, title: "Turn on Remote Access", detail: "Choose Local Network, Tailscale, or both. Tailscale is best when you are away from home.") + pairingStep(number: 3, title: "Create a pairing code", detail: "Keep the QR or setup code visible. Both expire after five minutes and can be used once.") + + VStack(alignment: .leading, spacing: 10) { + Label("Per-device credential", systemImage: "key.fill") + Label("Pinned HTTPS identity", systemImage: "lock.shield.fill") + Label("Revocable from your Mac", systemImage: "checkmark.shield") + } + .font(.subheadline) + .foregroundStyle(palette.secondary) + } + .frame(maxWidth: AidenMobileOnboardingLayout.maximumContentWidth, alignment: .leading) + .frame(maxWidth: .infinity) + .padding(24) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + onboardingActionButton(action: { + onIntroductionComplete?() + step = 2 + }) { + Text("Choose How to Connect") + } + .padding(.bottom, AidenMobileOnboardingLayout.actionBottomPadding) + } + .navigationTitle("Connect") + .navigationBarTitleDisplayMode(.inline) + } + + private func pairingStep(number: Int, title: String, detail: String) -> some View { + HStack(alignment: .top, spacing: 14) { + Text("\(number)") + .font(.caption.bold()) + .foregroundStyle(palette.canvas) + .frame(width: 26, height: 26) + .background(palette.accent, in: Circle()) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline) + Text(detail).font(.subheadline).foregroundStyle(palette.secondary) + } + } + } + + private var pairingPage: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 12) { + Text("Choose the connection shown in Aiden Agent’s Add Device window.") + .font(.subheadline) + .foregroundStyle(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + + Picker("Connection method", selection: $selectedPairingMethod) { + ForEach(AidenPairingMethod.primary) { method in + Text(method.tabTitle).tag(method) + } + } + .pickerStyle(.segmented) + .accessibilityHint("Swipe the content below or choose a tab.") + } + .padding(.horizontal, 18) + .padding(.top, 12) + .padding(.bottom, 10) + + TabView(selection: $selectedPairingMethod) { + qrPairingPage.tag(AidenPairingMethod.scanQRCode) + nearbyMacPairingPage.tag(AidenPairingMethod.nearbyMac) + privateAddressPairingPage.tag(AidenPairingMethod.privateAddress) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .accessibilityLabel("Connection setup") + } + .background(palette.canvas) + .navigationTitle("Pair Aiden") + .navigationBarTitleDisplayMode(.inline) + .onChange(of: discovery.agents) { _, agents in + guard let selectedAgentID else { return } + guard let selected = agents.first(where: { $0.id == selectedAgentID }), + selected.endpoint == manualEndpoint else { + self.selectedAgentID = nil + return + } + } + } + + private var qrPairingPage: some View { + Form { + Section { + 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") + } + + Section("Private pairing") { + Text("The QR expires after five minutes and can be used once. Aiden pins the Mac’s HTTPS identity during pairing.") + .foregroundStyle(palette.secondary) + } + } + .scrollContentBackground(.hidden) + .safeAreaInset(edge: .bottom, spacing: 0) { + onboardingActionButton(action: { isShowingScanner = true }) { + Label("Open Camera", systemImage: "qrcode.viewfinder") + } + .padding(.bottom, AidenMobileOnboardingLayout.actionBottomPadding) + } + } + + private var nearbyMacPairingPage: some View { + Form { + Section { + if discovery.agents.isEmpty { + HStack(spacing: 10) { + if discovery.isSearching { ProgressView() } + Text(discovery.isSearching ? "Looking for Aiden Agent…" : "No nearby Aiden Agent found") + .foregroundStyle(palette.secondary) + } + } else { + ForEach(discovery.agents) { agent in + discoveredAgentButton(agent) + } + } + } header: { + Text("Nearby Macs") + } 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.") + } + + Section { + manualEndpointField( + placeholder: "https://mac-name.local:49220/api/aiden/v1", + accessibilityLabel: "Nearby Aiden Agent address" + ) + manualSetupCodeField + manualPairingButton + } 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.") + } + } + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.interactively) + } + + private var privateAddressPairingPage: some View { + Form { + Section { + manualEndpointField( + placeholder: "https://mac-name.tailnet.ts.net/api/aiden/v1", + accessibilityLabel: "Private Tailscale address" + ) + manualSetupCodeField + manualPairingButton + } header: { + Text("Private address and setup code") + } footer: { + Text("Copy both values exactly from Aiden Agent. The private address must use HTTPS and end in /api/aiden/v1. Your setup code never leaves this device.") + } + + 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") + } + } + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.interactively) + } + + private var pastePayloadPairingPage: some View { + Form { + Section { + PasteButton(payloadType: String.self) { values in + if let payload = values.first { pairingPayload = payload } + } + + TextEditor(text: $pairingPayload) + .frame(minHeight: 120) + .font(codeTypography.swiftUIFont(relativeTo: .footnote)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .accessibilityLabel("Full pairing setup payload") + + Button("Pair with Setup Payload") { + let payload = pairingPayload.trimmingCharacters(in: .whitespacesAndNewlines) + startPairing { await coordinator.pair(qrPayload: payload) } + } + .disabled(!canPair) + } 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.") + } + } + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.interactively) + .navigationTitle("Paste Payload") + .navigationBarTitleDisplayMode(.inline) + } + + private func discoveredAgentButton(_ agent: AidenDiscoveredAgent) -> some View { + Button { + guard let endpoint = agent.endpoint else { return } + selectedAgentID = agent.id + manualEndpoint = endpoint + } label: { + HStack { + VStack(alignment: .leading, spacing: 3) { + Label(agent.name, systemImage: "desktopcomputer") + if let endpoint = agent.endpoint { + Text(endpoint) + .font(codeTypography.swiftUIFont(relativeTo: .caption)) + .foregroundStyle(palette.secondary) + } + } + Spacer() + if selectedAgentID == agent.id { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(palette.accent) + .accessibilityHidden(true) + } + } + } + .buttonStyle(.plain) + .disabled(agent.endpoint == nil) + .accessibilityLabel(agent.endpoint.map { "\(agent.name), \($0)" } ?? agent.name) + .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.") + } + + private func manualEndpointField( + placeholder: String, + accessibilityLabel: String + ) -> some View { + TextField(placeholder, text: $manualEndpoint) + .font(codeTypography.swiftUIFont(relativeTo: .footnote)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .accessibilityLabel(accessibilityLabel) + .onChange(of: manualEndpoint) { _, value in + guard let selectedAgentID else { return } + let selectedEndpoint = discovery.agents.first { + $0.id == selectedAgentID + }?.endpoint + if selectedEndpoint != value { self.selectedAgentID = nil } + } + } + + private var manualSetupCodeField: some View { + TextField("XXXX-XXXX-XXXX-XXXX-XXXX", text: $manualCode) + .font(codeTypography.swiftUIFont(relativeTo: .body)) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + .onChange(of: manualCode) { _, value in + manualCode = formattedManualCodeInput(value) + } + .accessibilityLabel("Manual pairing setup code") + } + + private var manualPairingButton: some View { + Button("Pair with Setup Code") { + guard let endpoint = manualEndpointURL else { return } + let code = manualCode + startPairing { + await coordinator.pair(manualCode: code, endpoint: endpoint) + } + } + .disabled(!canPairManually) + } + + private func formattedManualCodeInput(_ value: String) -> String { + guard value.unicodeScalars.allSatisfy({ scalar in + scalar.isASCII && (scalar.value == 32 || scalar.value == 45 + || (48...57).contains(scalar.value) + || (65...90).contains(scalar.value) + || (97...122).contains(scalar.value)) + }) else { return value } + let allowed = Set("0123456789ABCDEFGHJKMNPQRSTVWXYZ") + let characters = value + .uppercased() + .filter { $0 != "-" && $0 != " " } + .prefix(20) + guard characters.allSatisfy(allowed.contains) else { return value } + return stride(from: 0, to: characters.count, by: 4).map { start in + let lower = characters.index(characters.startIndex, offsetBy: start) + let upper = characters.index( + lower, + offsetBy: min(4, characters.count - start), + limitedBy: characters.endIndex + ) ?? characters.endIndex + return String(characters[lower.. AidenPairingAttemptResult + ) { + guard pairingTask == nil else { return } + let attemptID = UUID() + pairingTask = Task { @MainActor in + let result = await operation() + guard !Task.isCancelled else { + pairingTask = nil + return + } + if result == .succeeded { + coordinator.haptics.play( + .success, + scope: hapticScope, + dedupeKey: "pair:\(attemptID.uuidString)" + ) + pairingPayload = "" + manualCode = "" + manualEndpoint = "" + selectedAgentID = nil + onPaired?() + } else if result == .failed { + coordinator.haptics.play( + .error, + scope: hapticScope, + dedupeKey: "pair:\(attemptID.uuidString)" + ) + } + pairingTask = nil + } + } +} + +private struct AidenQRCodeScanner: UIViewControllerRepresentable { + let onCode: (String) -> Void + + func makeUIViewController(context: Context) -> AidenQRCodeScannerViewController { + let controller = AidenQRCodeScannerViewController() + controller.onCode = onCode + return controller + } + + func updateUIViewController(_ uiViewController: AidenQRCodeScannerViewController, context: Context) {} +} + +private final class AidenQRCodeScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate { + var onCode: ((String) -> Void)? + + private let session = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? + private var deliveredCode = false + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + requestCameraAndConfigure() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if session.isRunning { + DispatchQueue.global(qos: .userInitiated).async { [session] in session.stopRunning() } + } + } + + private func requestCameraAndConfigure() { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + configureSession() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + guard granted else { return } + DispatchQueue.main.async { self?.configureSession() } + } + default: + showCameraUnavailable() + } + } + + private func configureSession() { + guard let camera = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: camera), + session.canAddInput(input) else { + showCameraUnavailable() + return + } + session.addInput(input) + + let output = AVCaptureMetadataOutput() + guard session.canAddOutput(output) else { + showCameraUnavailable() + return + } + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: .main) + output.metadataObjectTypes = [.qr] + + let layer = AVCaptureVideoPreviewLayer(session: session) + layer.videoGravity = .resizeAspectFill + layer.frame = view.bounds + view.layer.addSublayer(layer) + previewLayer = layer + + DispatchQueue.global(qos: .userInitiated).async { [session] in session.startRunning() } + } + + private func showCameraUnavailable() { + let label = UILabel() + label.text = "Camera access is required to scan the pairing QR. You can paste the pairing payload instead." + label.textColor = .white + label.textAlignment = .center + label.numberOfLines = 0 + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24), + label.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard !deliveredCode, + let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + object.type == .qr, + let value = object.stringValue else { return } + deliveredCode = true + session.stopRunning() + onCode?(value) + } +} diff --git a/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift b/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift new file mode 100644 index 00000000..1a883816 --- /dev/null +++ b/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift @@ -0,0 +1,843 @@ +import Foundation +import Observation +import UIKit + +actor AidenInstallationDataGate { + private var held = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if !held { + held = true + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func release() { + if waiters.isEmpty { + held = false + } else { + waiters.removeFirst().resume() + } + } +} + +enum AidenRemoteConnectionState: Equatable { + case needsPairing + case connecting + case connected + case offline(message: String) +} + +enum AidenPairingAttemptResult: Equatable { + case succeeded + case cancelled + case failed +} + +enum AidenRemoteMutationOutcome { + case success(Value) + case failure + case cancelled + case stale + case busy + + var value: Value? { + guard case .success(let value) = self else { return nil } + return value + } + + var isDefinitiveFailure: Bool { + if case .failure = self { return true } + return false + } +} + +/// An opaque activation lease for one selected Aiden installation. The +/// generation prevents an A -> B -> A switch from making work started during +/// the first A activation current again. +struct AidenRemoteRequestContext: Equatable, Hashable, Sendable { + let instanceId: String + let deviceId: String + fileprivate let generation: Int +} + +@MainActor +@Observable +final class AidenRemoteCoordinator { + typealias ClientFactory = (AidenInstallation, String) throws -> AidenRemoteClient + + let installationStore: AidenInstallationStore + let haptics: any AidenHapticEmitting + private let clientFactory: ClientFactory + private(set) var connectionState: AidenRemoteConnectionState + private(set) var server: AidenServer? + private(set) var workspaces: [AidenWorkspace] = [] + private(set) var workspaceSnapshotRevision = 0 + private(set) var isMutating = false + var presentedError: String? + let workspaceArchiveStore: AidenWorkspaceArchiveStore + private let chatCache: AidenChatCache + private let scheduledTaskCache: AidenScheduledTaskCache + private let workspaceEnvironmentCache: AidenWorkspaceEnvironmentCache + private let installationDataGate = AidenInstallationDataGate() + private var pendingManagedWorktreeDeletionKeys: [String: UUID] = [:] + private var connectionGeneration = 0 + private var activationGeneration = 0 + + init(haptics: (any AidenHapticEmitting)? = nil) { + let installationStore = AidenInstallationStore() + self.installationStore = installationStore + self.haptics = haptics ?? AidenHapticCenter() + workspaceArchiveStore = AidenWorkspaceArchiveStore() + chatCache = .shared + scheduledTaskCache = .shared + workspaceEnvironmentCache = .shared + clientFactory = { try AidenRemoteClient(installation: $0, credential: $1) } + connectionState = installationStore.activeInstallation == nil ? .needsPairing : .connecting + } + + init( + installationStore: AidenInstallationStore, + workspaceArchiveStore: AidenWorkspaceArchiveStore? = nil, + chatCache: AidenChatCache = .shared, + scheduledTaskCache: AidenScheduledTaskCache = .shared, + workspaceEnvironmentCache: AidenWorkspaceEnvironmentCache = .shared, + haptics: (any AidenHapticEmitting)? = nil, + clientFactory: @escaping ClientFactory = { try AidenRemoteClient(installation: $0, credential: $1) } + ) { + self.installationStore = installationStore + self.haptics = haptics ?? AidenHapticCenter() + self.workspaceArchiveStore = workspaceArchiveStore ?? AidenWorkspaceArchiveStore() + self.chatCache = chatCache + self.scheduledTaskCache = scheduledTaskCache + self.workspaceEnvironmentCache = workspaceEnvironmentCache + self.clientFactory = clientFactory + connectionState = installationStore.activeInstallation == nil ? .needsPairing : .connecting + } + + func start() async { + guard installationStore.activeInstallation != nil else { + connectionGeneration &+= 1 + connectionState = .needsPairing + updateIntentCatalog(for: nil) + return + } + await connectActiveInstallation() + } + + func pair(qrPayload: String) async -> AidenPairingAttemptResult { + guard !isMutating else { return .failed } + isMutating = true + presentedError = nil + defer { isMutating = false } + + let previousConnectionState = connectionState + var credentialIssued = false + do { + guard let data = qrPayload.data(using: .utf8) else { + throw AidenRemoteClientError.invalidResponse + } + let payload = try AidenRemoteJSONDecoder.decodePairingPayload(from: data) + let exchange = try await AidenRemoteClient.pair( + payload: payload, + deviceName: Self.deviceName, + deviceType: Self.deviceType, + clientVersion: Self.clientVersion + ) + credentialIssued = true + try await activatePairing(payload: payload, exchange: exchange) + return .succeeded + } catch let error where aidenIsCancellation(error) { + connectionState = installationStore.activeInstallation == nil ? .needsPairing : previousConnectionState + if credentialIssued { + presentedError = Self.pendingCredentialRecoveryMessage + } + return .cancelled + } catch { + connectionState = installationStore.activeInstallation == nil ? .needsPairing : previousConnectionState + presentedError = credentialIssued + ? Self.pendingCredentialRecoveryMessage + : error.localizedDescription + return .failed + } + } + + func pair(manualCode: String, endpoint: URL) async -> AidenPairingAttemptResult { + guard !isMutating else { return .failed } + isMutating = true + presentedError = nil + defer { isMutating = false } + + let previousConnectionState = connectionState + var credentialIssued = false + do { + let result = try await AidenRemoteClient.pair( + manualCode: manualCode, + endpoint: endpoint, + deviceName: Self.deviceName, + deviceType: Self.deviceType, + clientVersion: Self.clientVersion + ) + credentialIssued = true + try await activatePairing(payload: result.payload, exchange: result.exchange) + return .succeeded + } catch let error where aidenIsCancellation(error) { + connectionState = installationStore.activeInstallation == nil ? .needsPairing : previousConnectionState + if credentialIssued { + presentedError = Self.pendingCredentialRecoveryMessage + } + return .cancelled + } catch { + connectionState = installationStore.activeInstallation == nil ? .needsPairing : previousConnectionState + presentedError = credentialIssued + ? Self.pendingCredentialRecoveryMessage + : error.localizedDescription + return .failed + } + } + + func activatePairing( + payload: AidenRemoteContractFixture.PairingPayload, + exchange: AidenRemoteContractFixture.PairingExchange + ) async throws { + let temporaryName = exchange.displayName + ?? payload.bootstrap.endpoint.host + ?? String(localized: "Aiden Agent") + let stagedInstallation = AidenInstallation( + exchange: exchange, + pairingTrust: payload.trust, + name: temporaryName + ) + let stagedClient = try clientFactory(stagedInstallation, exchange.credential) + async let serverRequest = stagedClient.server() + async let workspaceRequest = stagedClient.workspaces() + let (validatedServer, validatedWorkspaces) = try await (serverRequest, workspaceRequest) + guard validatedServer.instanceId == exchange.instanceId else { + throw AidenRemoteContractError.invalidPairingExchange + } + try Task.checkCancellation() + let previousDeviceId = installationStore.installations.first { + $0.id == exchange.instanceId + }?.deviceId + let knownWorkspaceIds = activeInstanceId == exchange.instanceId + ? Set(workspaces.map(\.id)) + : [] + let installation = try installationStore.savePairing( + exchange, + trust: payload.trust, + name: validatedServer.name, + connectedAt: Date() + ) + activationGeneration &+= 1 + connectionGeneration &+= 1 + if let previousDeviceId, previousDeviceId != installation.deviceId { + await purgeInstallationData(installation.id, knownWorkspaceIds: knownWorkspaceIds) + } + self.server = validatedServer + applyWorkspaceSnapshot(validatedWorkspaces, instanceId: installation.id) + connectionState = .connected + } + + private static let pendingCredentialRecoveryMessage = String(localized: + "Aiden created a pending device credential, but this iPhone could not finish saving it. Revoke the pending device in Aiden Agent, then create a new pairing code." + ) + + func connectActiveInstallation() async { + connectionGeneration &+= 1 + let generation = connectionGeneration + guard let installation = installationStore.activeInstallation else { + connectionState = .needsPairing + server = nil + workspaces = [] + updateIntentCatalog(for: nil) + return + } + connectionState = .connecting + presentedError = nil + do { + guard let credential = try installationStore.credential(for: installation), + !credential.isEmpty else { + throw AidenRemoteClientError.missingCredential + } + try await load(installation: installation, credential: credential, generation: generation) + } catch { + guard isCurrentContext(installationId: installation.id, generation: generation) else { return } + await handleConnectionError(error, installationId: installation.id) + } + } + + func switchInstallation(to installationId: String) async { + _ = await switchInstallationOutcome(to: installationId) + } + + func switchInstallationOutcome(to installationId: String) async -> AidenRemoteMutationOutcome { + guard !isMutating else { return .busy } + do { + let previousInstallationId = activeInstanceId + try installationStore.setActive(installationId) + if previousInstallationId != activeInstanceId { + activationGeneration &+= 1 + } + server = nil + workspaces = [] + updateIntentCatalog(for: nil) + await connectActiveInstallation() + guard activeInstanceId == installationId else { return .stale } + guard connectionState == .connected else { return .failure } + return .success(()) + } catch let error where aidenIsCancellation(error) { + return .cancelled + } catch { + presentedError = error.localizedDescription + return .failure + } + } + + func removeInstallation(_ installationId: String) async { + _ = await removeInstallationOutcome(installationId) + } + + func removeInstallationOutcome(_ installationId: String) async -> AidenRemoteMutationOutcome { + guard !isMutating else { return .busy } + do { + let previousInstallationId = activeInstanceId + let knownWorkspaceIds = previousInstallationId == installationId + ? Set(workspaces.map(\.id)) + : [] + try installationStore.remove(installationId) + if previousInstallationId != activeInstanceId { + activationGeneration &+= 1 + } + connectionGeneration &+= 1 + server = nil + workspaces = [] + await purgeInstallationData(installationId, knownWorkspaceIds: knownWorkspaceIds) + updateIntentCatalog(for: installationId) + await start() + guard !installationStore.installations.contains(where: { $0.id == installationId }) else { + return .failure + } + return .success(()) + } catch let error where aidenIsCancellation(error) { + return .cancelled + } catch { + presentedError = error.localizedDescription + return .failure + } + } + + func refreshWorkspaces() async { + guard let installationId = activeInstanceId, + let client = try? activeClient() else { + connectionState = .needsPairing + return + } + let generation = connectionGeneration + do { + let refreshed = try await client.workspaces() + guard isCurrentContext(installationId: installationId, generation: generation) else { return } + applyWorkspaceSnapshot(refreshed, instanceId: installationId) + connectionState = .connected + } catch { + guard isCurrentContext(installationId: installationId, generation: generation) else { return } + await handleConnectionError(error, installationId: installationId) + } + } + + func createWorkspace(_ create: AidenWorkspaceCreate) async -> AidenWorkspace? { + await createWorkspaceOutcome(create).value + } + + func createWorkspaceOutcome(_ create: AidenWorkspaceCreate) async -> AidenRemoteMutationOutcome { + await mutateOutcome { client in try await client.createWorkspace(create) } + } + + func updateWorkspace( + _ workspace: AidenWorkspace, + name: String? = nil, + permission: AidenWorkspacePermission? = nil + ) async -> AidenWorkspace? { + await updateWorkspaceOutcome(workspace, name: name, permission: permission).value + } + + func updateWorkspaceOutcome( + _ workspace: AidenWorkspace, + name: String? = nil, + permission: AidenWorkspacePermission? = nil + ) async -> AidenRemoteMutationOutcome { + guard !isMutating else { return .busy } + isMutating = true + presentedError = nil + defer { isMutating = false } + + guard let installationId = activeInstanceId else { return .stale } + let generation = connectionGeneration + let client: AidenRemoteClient + do { + client = try activeClient() + } catch { + await handleConnectionError(error, installationId: installationId) + return aidenIsCancellation(error) ? .cancelled : .failure + } + + var optimistic = workspace + if let name { optimistic.name = name } + if let permission { optimistic.permission = permission } + upsert(optimistic) + + do { + let updated = try await client.updateWorkspace( + id: workspace.id, + revision: workspace.revision, + patch: AidenWorkspacePatch(name: name, permission: permission) + ) + guard isCurrentContext(installationId: installationId, generation: generation) else { return .stale } + upsert(updated) + return .success(updated) + } catch let error where aidenIsCancellation(error) { + guard isCurrentContext(installationId: installationId, generation: generation) else { return .stale } + upsert(workspace) + return .cancelled + } catch { + guard isCurrentContext(installationId: installationId, generation: generation) else { return .stale } + if let canonical = try? await client.workspaces(), + isCurrentContext(installationId: installationId, generation: generation) { + applyWorkspaceSnapshot(canonical, instanceId: installationId) + if let reconciled = canonical.first(where: { $0.id == workspace.id }), + (name == nil || reconciled.name == name), + (permission == nil || reconciled.permission == permission) { + return .success(reconciled) + } + } else { + upsert(workspace) + } + await handleConnectionError(error, installationId: installationId) + return .failure + } + } + + func removeWorkspace(_ workspace: AidenWorkspace) async -> Bool { + if case .success = await removeWorkspaceOutcome(workspace) { return true } + return false + } + + func removeWorkspaceOutcome(_ workspace: AidenWorkspace) async -> AidenRemoteMutationOutcome { + guard !isMutating else { return .busy } + isMutating = true + presentedError = nil + defer { isMutating = false } + + guard let installationId = activeInstanceId else { return .stale } + let generation = connectionGeneration + let client: AidenRemoteClient + do { + client = try activeClient() + } catch { + await handleConnectionError(error, installationId: installationId) + return aidenIsCancellation(error) ? .cancelled : .failure + } + workspaces.removeAll { $0.id == workspace.id } + updateIntentCatalog(for: installationId) + do { + try await client.removeWorkspace(id: workspace.id, revision: workspace.revision) + guard isCurrentContext(installationId: installationId, generation: generation) else { return .stale } + // The desktop guarantees at least one registry workspace and may + // seed a replacement when the last record is removed. Canonicalize + // after the confirmed delete without rolling the delete back if + // this follow-up read happens to fail. + if let canonicalWorkspaces = try? await client.workspaces() { + guard isCurrentContext(installationId: installationId, generation: generation) else { return .stale } + applyWorkspaceSnapshot(canonicalWorkspaces, instanceId: installationId) + } + return .success(()) + } catch let error where aidenIsCancellation(error) { + guard isCurrentContext(installationId: installationId, generation: generation) else { return .stale } + upsert(workspace) + return .cancelled + } catch { + guard isCurrentContext(installationId: installationId, generation: generation) else { return .stale } + if let canonical = try? await client.workspaces(), + isCurrentContext(installationId: installationId, generation: generation) { + applyWorkspaceSnapshot(canonical, instanceId: installationId) + if !canonical.contains(where: { $0.id == workspace.id }) { + return .success(()) + } + } else { + upsert(workspace) + } + await handleConnectionError(error, installationId: installationId) + return .failure + } + } + + func removeManagedWorktree(_ workspace: AidenWorkspace) async -> Bool { + if case .success = await removeManagedWorktreeOutcome(workspace) { return true } + return false + } + + func removeManagedWorktreeOutcome(_ workspace: AidenWorkspace) async -> AidenRemoteMutationOutcome { + guard workspace.isManagedWorktree else { return .failure } + guard !isMutating else { return .busy } + isMutating = true + presentedError = nil + defer { isMutating = false } + + guard let installationId = activeInstanceId else { return .stale } + let generation = connectionGeneration + let client: AidenRemoteClient + do { + client = try activeClient() + } catch { + await handleConnectionError(error, installationId: installationId) + return aidenIsCancellation(error) ? .cancelled : .failure + } + let scope = "\(installationId):\(workspace.id)" + let key = pendingManagedWorktreeDeletionKeys[scope] ?? UUID() + pendingManagedWorktreeDeletionKeys[scope] = key + workspaces.removeAll { $0.id == workspace.id } + updateIntentCatalog(for: activeInstanceId) + do { + _ = try await client.deleteManagedGitWorktree( + workspaceId: workspace.id, + revision: workspace.revision, + idempotencyKey: key + ) + guard isCurrentContext(installationId: installationId, generation: generation) else { return .stale } + pendingManagedWorktreeDeletionKeys[scope] = nil + return .success(()) + } catch let error where aidenIsCancellation(error) { + guard isCurrentContext(installationId: installationId, generation: generation) else { return .stale } + upsert(workspace) + return .cancelled + } catch { + guard isCurrentContext(installationId: installationId, generation: generation) else { return .stale } + if let canonical = try? await client.workspaces(), + isCurrentContext(installationId: installationId, generation: generation) { + applyWorkspaceSnapshot(canonical, instanceId: installationId) + if !canonical.contains(where: { $0.id == workspace.id }) { + pendingManagedWorktreeDeletionKeys[scope] = nil + return .success(()) + } + } else { + upsert(workspace) + } + if !Self.isAmbiguousMutationError(error) { + pendingManagedWorktreeDeletionKeys[scope] = nil + } + await handleConnectionError(error, installationId: installationId) + return .failure + } + } + + func browserRoots(context: AidenRemoteRequestContext) async throws -> [AidenBrowserRoot] { + try await remoteClient(for: context).browserRoots() + } + + func browserChildren( + context: AidenRemoteRequestContext, + location: String, + cursor: String? = nil + ) async throws -> AidenBrowserPage { + try await remoteClient(for: context).browserChildren(location: location, cursor: cursor) + } + + func createSelectedFolderWorkspace( + context: AidenRemoteRequestContext, + location: String, + name: String? + ) async -> AidenWorkspace? { + await createSelectedFolderWorkspaceOutcome( + context: context, + location: location, + name: name + ).value + } + + func createSelectedFolderWorkspaceOutcome( + context: AidenRemoteRequestContext, + location: String, + name: String? + ) async -> AidenRemoteMutationOutcome { + guard !isMutating else { return .busy } + isMutating = true + presentedError = nil + defer { isMutating = false } + guard isCurrent(context) else { return .stale } + let installationId = context.instanceId + do { + let client = try remoteClient(for: context) + let selection = try await client.createWorkspaceSelection(location: location) + guard isCurrent(context) else { return .stale } + guard selection.expiresAt > Date() else { + throw AidenRemoteClientError.invalidResponse + } + let workspace = try await client.createWorkspace( + .selectedFolder(selection: selection.selection, name: name) + ) + guard isCurrent(context) else { return .stale } + upsert(workspace) + return .success(workspace) + } catch let error where aidenIsCancellation(error) { + guard isCurrent(context) else { return .stale } + return .cancelled + } catch { + guard isCurrent(context) else { return .stale } + await handleConnectionError(error, installationId: installationId) + return .failure + } + } + + var activeInstanceId: String? { + installationStore.activeInstallationId + } + + func setDeviceArchivedWorkspaceIDs(_ workspaceIDs: Set, for instanceId: String?) { + guard let instanceId, !instanceId.isEmpty else { return } + _ = workspaceIDs + updateIntentCatalog(for: instanceId) + } + + func remoteClient() throws -> AidenRemoteClient { + try activeClient() + } + + func requestContext(for instanceId: String? = nil) throws -> AidenRemoteRequestContext { + guard let activeInstallation = installationStore.activeInstallation, + let activeInstanceId, + instanceId == nil || instanceId == activeInstanceId else { + throw AidenRemoteClientError.installationChanged + } + return AidenRemoteRequestContext( + instanceId: activeInstanceId, + deviceId: activeInstallation.deviceId, + generation: activationGeneration + ) + } + + func remoteClient(for context: AidenRemoteRequestContext) throws -> AidenRemoteClient { + guard isCurrent(context) else { + throw AidenRemoteClientError.installationChanged + } + return try activeClient() + } + + func isCurrent(_ context: AidenRemoteRequestContext) -> Bool { + activeInstanceId == context.instanceId + && activationGeneration == context.generation + && isRetained(context) + } + + func isRetained(_ context: AidenRemoteRequestContext) -> Bool { + installationStore.installations.contains { + $0.id == context.instanceId && $0.deviceId == context.deviceId + } + } + + func withRetainedInstallationData( + for context: AidenRemoteRequestContext, + operation: @MainActor () async -> Void + ) async -> Bool { + await installationDataGate.acquire() + guard isRetained(context) else { + await installationDataGate.release() + return false + } + await operation() + let retained = isRetained(context) + if !retained { + await purgeInstallationDataUnlocked(context.instanceId) + } + await installationDataGate.release() + return retained + } + + private func load( + installation: AidenInstallation, + credential: String, + generation: Int + ) async throws { + let client = try clientFactory(installation, credential) + async let serverRequest = client.server() + async let workspaceRequest = client.workspaces() + let (server, workspaces) = try await (serverRequest, workspaceRequest) + guard server.instanceId == installation.instanceId else { + throw AidenRemoteContractError.invalidPairingExchange + } + guard isCurrentContext(installationId: installation.id, generation: generation) else { return } + try installationStore.updateServer(server) + self.server = server + applyWorkspaceSnapshot(workspaces, instanceId: installation.id) + connectionState = .connected + } + + private func activeClient() throws -> AidenRemoteClient { + guard let installation = installationStore.activeInstallation else { + throw AidenRemoteClientError.missingCredential + } + guard let credential = try installationStore.credential(for: installation), + !credential.isEmpty else { + throw AidenRemoteClientError.missingCredential + } + return try clientFactory(installation, credential) + } + + private func mutateOutcome( + operation: (AidenRemoteClient) async throws -> AidenWorkspace + ) async -> AidenRemoteMutationOutcome { + guard !isMutating else { return .busy } + isMutating = true + presentedError = nil + defer { isMutating = false } + guard let installationId = activeInstanceId else { return .stale } + let generation = connectionGeneration + do { + let client = try activeClient() + let workspace = try await operation(client) + guard isCurrentContext(installationId: installationId, generation: generation) else { return .stale } + upsert(workspace) + return .success(workspace) + } catch let error where aidenIsCancellation(error) { + guard isCurrentContext(installationId: installationId, generation: generation) else { return .stale } + return .cancelled + } catch { + guard isCurrentContext(installationId: installationId, generation: generation) else { return .stale } + await handleConnectionError(error, installationId: installationId) + return .failure + } + } + + private func applyWorkspaceSnapshot(_ workspaces: [AidenWorkspace], instanceId: String) { + guard activeInstanceId == instanceId else { return } + self.workspaces = workspaces + workspaceArchiveStore.prune( + instanceID: instanceId, + validWorkspaceIDs: Set(workspaces.map(\.id)) + ) + workspaceSnapshotRevision &+= 1 + updateIntentCatalog(for: instanceId) + } + + private func isCurrentContext(installationId: String, generation: Int) -> Bool { + activeInstanceId == installationId && connectionGeneration == generation + } + + private func upsert(_ workspace: AidenWorkspace) { + workspaces.removeAll { $0.id == workspace.id } + workspaces.append(workspace) + workspaces.sort { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + updateIntentCatalog(for: activeInstanceId) + } + + private func handleConnectionError(_ error: Error, installationId: String?) async { + guard installationId == nil || installationId == activeInstanceId else { return } + if let clientError = error as? AidenRemoteClientError, + clientError.isCredentialRevoked, + let installationId { + let revocationMessage = String(localized: "This device was revoked. Pair it with Aiden Agent again.") + let previousInstallationId = activeInstanceId + let knownWorkspaceIds = previousInstallationId == installationId + ? Set(workspaces.map(\.id)) + : [] + try? installationStore.remove(installationId) + if previousInstallationId != activeInstanceId { + activationGeneration &+= 1 + } + connectionGeneration &+= 1 + server = nil + workspaces = [] + await purgeInstallationData(installationId, knownWorkspaceIds: knownWorkspaceIds) + updateIntentCatalog(for: installationId) + connectionState = installationStore.activeInstallation == nil ? .needsPairing : .connecting + if installationStore.activeInstallation != nil { + await connectActiveInstallation() + } + presentedError = revocationMessage + return + } + if let clientError = error as? AidenRemoteClientError, + case .server = clientError { + connectionState = .connected + } else { + connectionState = .offline(message: error.localizedDescription) + } + presentedError = error.localizedDescription + } + + private func purgeInstallationData( + _ installationId: String, + knownWorkspaceIds: Set = [] + ) async { + await installationDataGate.acquire() + await purgeInstallationDataUnlocked( + installationId, + knownWorkspaceIds: knownWorkspaceIds + ) + await installationDataGate.release() + } + + private func purgeInstallationDataUnlocked( + _ installationId: String, + knownWorkspaceIds: Set = [] + ) async { + workspaceArchiveStore.purge(instanceID: installationId) + await chatCache.purge(instanceId: installationId) + await scheduledTaskCache.purge(instanceId: installationId) + await workspaceEnvironmentCache.purge( + instanceId: installationId, + knownWorkspaceIds: knownWorkspaceIds + ) + await AidenRemoteLiveActivityManager.shared.endAll(forInstanceID: installationId) + } + + private func updateIntentCatalog(for instanceId: String?) { + let installations = installationStore.installations.map { + AidenIntentInstallationRecord(id: $0.id, name: $0.name) + } + let cachedWorkspaces: [AidenIntentWorkspaceRecord] + if let instanceId, instanceId == activeInstanceId { + let archivedWorkspaceIDs = workspaceArchiveStore.archivedWorkspaceIDs(for: instanceId) + cachedWorkspaces = workspaces.filter { !archivedWorkspaceIDs.contains($0.id) }.map { + AidenIntentWorkspaceRecord(id: $0.id, instanceId: instanceId, name: $0.name) + } + } else { + cachedWorkspaces = [] + } + try? AidenIntentCatalogStore.shared.update( + installations: installations, + activeInstallationId: activeInstanceId, + workspaces: cachedWorkspaces, + for: instanceId + ) + } + + private static var deviceType: AidenDeviceType { + UIDevice.current.userInterfaceIdiom == .pad ? .ipad : .iphone + } + + private static var deviceName: String { + let name = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines) + return String((name.isEmpty ? "Aiden On The Go" : name).prefix(80)) + } + + private static var clientVersion: String { + let value = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + return String((value ?? "1.0").prefix(40)) + } + + private static func isAmbiguousMutationError(_ error: Error) -> Bool { + if error is URLError { return true } + guard let clientError = error as? AidenRemoteClientError else { return false } + switch clientError { + case .invalidResponse, .unexpectedStatus: + return true + case .server(_, let body): + return body.code.rawValue == "idempotency_in_flight" || body.code.rawValue == "internal_error" + case .invalidEndpoint, .missingCredential, .missingTrustConfiguration, .installationChanged: + return false + } + } +} diff --git a/ios/AidenOnTheGo/Features/Remote/AidenScheduledTasksView.swift b/ios/AidenOnTheGo/Features/Remote/AidenScheduledTasksView.swift new file mode 100644 index 00000000..c03c3daf --- /dev/null +++ b/ios/AidenOnTheGo/Features/Remote/AidenScheduledTasksView.swift @@ -0,0 +1,835 @@ +import CryptoKit +import Foundation +import Observation +import SwiftUI + +actor AidenScheduledTaskCache { + static let shared = AidenScheduledTaskCache() + + struct Snapshot: Codable, Sendable { + let instanceId: String + var tasks: [AidenScheduledTask] + var settings: AidenScheduledSettings? + var runs: [String: [AidenScheduledRun]] + } + + private let root: URL + private let fileManager: FileManager + private let maximumBytes = 10 * 1_024 * 1_024 + + init(root: URL? = nil, fileManager: FileManager = .default) { + self.fileManager = fileManager + if let root { + self.root = root + } else { + let support = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + self.root = support + .appending(path: "AidenOnTheGo", directoryHint: .isDirectory) + .appending(path: "RemoteScheduledTaskCache-v1", directoryHint: .isDirectory) + } + } + + func load(instanceId: String) -> Snapshot? { + let url = file(instanceId: instanceId) + guard let data = try? Data(contentsOf: url), data.count <= maximumBytes, + let value = try? JSONDecoder().decode(Snapshot.self, from: data), + value.instanceId == instanceId else { return nil } + return value + } + + func store( + instanceId: String, + tasks: [AidenScheduledTask], + settings: AidenScheduledSettings? + ) throws { + let retainedTaskIDs = Set(tasks.map(\.id)) + let retainedRuns = (load(instanceId: instanceId)?.runs ?? [:]).filter { + retainedTaskIDs.contains($0.key) + } + try persist(Snapshot(instanceId: instanceId, tasks: tasks, settings: settings, runs: retainedRuns)) + } + + func store(runs: [AidenScheduledRun], taskId: String, instanceId: String) throws { + guard var snapshot = load(instanceId: instanceId), + snapshot.tasks.contains(where: { $0.id == taskId }) else { return } + snapshot.runs[taskId] = Array(runs.prefix(50)) + try persist(snapshot) + } + + func purge(instanceId: String) { + try? fileManager.removeItem(at: file(instanceId: instanceId)) + } + + private func persist(_ snapshot: Snapshot) throws { + let data = try JSONEncoder().encode(snapshot) + guard data.count <= maximumBytes else { throw CocoaError(.fileWriteOutOfSpace) } + let url = file(instanceId: snapshot.instanceId) + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] + ) + try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + private func file(instanceId: String) -> URL { + let digest = SHA256.hash(data: Data(instanceId.utf8)) + .map { String(format: "%02x", $0) }.joined() + return root.appending(path: "\(digest).json") + } +} + +@MainActor +@Observable +final class AidenScheduledTasksModel { + private let coordinator: AidenRemoteCoordinator + private let cache: AidenScheduledTaskCache + private let activationContext: AidenRemoteRequestContext? + private var pendingRunKeys: [String: UUID] = [:] + private let hapticScope = UUID() + private(set) var tasks: [AidenScheduledTask] = [] + private(set) var settings: AidenScheduledSettings? + private(set) var catalog: AidenModelCatalog? + private(set) var scripts: [AidenScheduledScript] = [] + private(set) var mcpServers: [AidenScheduledMcpServer] = [] + private(set) var isLoading = false + private(set) var isMutating = false + var presentedError: String? + var outcomeMessage: String? + + init( + coordinator: AidenRemoteCoordinator, + cache: AidenScheduledTaskCache = .shared + ) { + self.coordinator = coordinator + self.cache = cache + activationContext = try? coordinator.requestContext() + } + + var isConnected: Bool { + coordinator.connectionState == .connected + && activationContext.map(coordinator.isCurrent) == true + } + var workspaces: [AidenWorkspace] { + guard activationContext.map(coordinator.isCurrent) == true else { return [] } + return coordinator.workspaces.filter { $0.permission != .none } + } + + func setHapticsActive(_ active: Bool) { + if active { + coordinator.haptics.activate(scope: hapticScope) + } else { + coordinator.haptics.deactivate(scope: hapticScope) + } + } + + private func requestContext() throws -> AidenRemoteRequestContext { + guard let activationContext, coordinator.isCurrent(activationContext) else { + throw AidenRemoteClientError.installationChanged + } + return activationContext + } + + func load() async { + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + guard let context = try? requestContext() else { return } + let instanceId = context.instanceId + if tasks.isEmpty, let cached = await cache.load(instanceId: instanceId) { + guard coordinator.isCurrent(context) else { return } + tasks = cached.tasks.sorted(by: Self.sort) + settings = cached.settings + } + guard isConnected else { return } + do { + let client = try coordinator.remoteClient(for: context) + async let loadedTasks = client.scheduledTasks() + async let loadedSettings = client.scheduledSettings() + async let loadedCatalog = client.modelCatalog() + async let loadedMcpServers = client.scheduledMcpServers() + let values = try await (loadedTasks, loadedSettings, loadedCatalog, loadedMcpServers) + guard coordinator.isCurrent(context) else { return } + tasks = values.0.sorted(by: Self.sort) + settings = values.1 + catalog = values.2 + mcpServers = values.3 + try? await cache.store(instanceId: instanceId, tasks: tasks, settings: settings) + presentedError = nil + } catch { + guard coordinator.isCurrent(context) else { return } + presentedError = error.localizedDescription + } + } + + func loadScripts(workspaceId: String?) async { + guard let context = try? requestContext() else { return } + do { + let loaded = try await coordinator.remoteClient(for: context).scheduledScripts(workspaceId: workspaceId) + guard coordinator.isCurrent(context) else { return } + scripts = loaded + } catch { + guard coordinator.isCurrent(context) else { return } + scripts = [] + presentedError = error.localizedDescription + } + } + + func preview(_ draft: AidenScheduledTaskDraft) async throws -> [Date] { + let context = try requestContext() + let result = try await coordinator.remoteClient(for: context).previewSchedule( + cron: draft.schedule.trimmingCharacters(in: .whitespacesAndNewlines), + timezone: draft.timezone.trimmingCharacters(in: .whitespacesAndNewlines) + ) + guard coordinator.isCurrent(context) else { + throw AidenRemoteClientError.installationChanged + } + return result + } + + func save(_ draft: AidenScheduledTaskDraft, replacing task: AidenScheduledTask?) async -> Bool { + guard draft.validationMessage == nil, !isMutating, isConnected else { return false } + isMutating = true + defer { isMutating = false } + guard let context = try? requestContext() else { return false } + do { + let client = try coordinator.remoteClient(for: context) + let saved = if let task { + try await client.updateScheduledTask(id: task.id, revision: task.revision, mutation: draft.mutation) + } else { + try await client.createScheduledTask(draft.mutation) + } + guard coordinator.isCurrent(context) else { return false } + upsert(saved) + outcomeMessage = task == nil ? String(localized: "Scheduled task created.") : String(localized: "Scheduled task updated.") + coordinator.haptics.play( + .success, + scope: hapticScope, + dedupeKey: "scheduled-save:\(saved.id):\(saved.revision)" + ) + return true + } catch let error where aidenIsCancellation(error) { + return false + } catch { + guard coordinator.isCurrent(context) else { return false } + presentedError = error.localizedDescription + coordinator.haptics.play(.error, scope: hapticScope) + await load() + return false + } + } + + func pauseOrResume(_ task: AidenScheduledTask) async { + await mutate(successEvent: .selection) { + task.enabled + ? try await $0.pauseScheduledTask(id: task.id, revision: task.revision) + : try await $0.resumeScheduledTask(id: task.id, revision: task.revision) + } + } + + func remove(_ task: AidenScheduledTask) async -> Bool { + guard !isMutating, isConnected else { return false } + isMutating = true + defer { isMutating = false } + guard let context = try? requestContext() else { return false } + do { + try await coordinator.remoteClient(for: context).removeScheduledTask(id: task.id, revision: task.revision) + guard coordinator.isCurrent(context) else { return false } + tasks.removeAll { $0.id == task.id } + try? await cache.store(instanceId: context.instanceId, tasks: tasks, settings: settings) + coordinator.haptics.play( + .success, + scope: hapticScope, + dedupeKey: "scheduled-remove:\(task.id):\(task.revision)" + ) + return true + } catch let error where aidenIsCancellation(error) { + return false + } catch { + guard coordinator.isCurrent(context) else { return false } + presentedError = error.localizedDescription + coordinator.haptics.play(.error, scope: hapticScope) + await load() + return false + } + } + + func run(_ task: AidenScheduledTask) async { + guard !isMutating, isConnected else { return } + isMutating = true + defer { isMutating = false } + let key = pendingRunKeys[task.id] ?? UUID() + pendingRunKeys[task.id] = key + guard let context = try? requestContext() else { return } + do { + let accepted = try await coordinator.remoteClient(for: context).runScheduledTask(id: task.id, idempotencyKey: key) + guard coordinator.isCurrent(context) else { return } + pendingRunKeys[task.id] = nil + outcomeMessage = String(localized: "Run accepted (\(accepted.runId.prefix(12))…). It continues on your Mac if this phone disconnects.") + coordinator.haptics.play( + .actionStarted, + scope: hapticScope, + dedupeKey: "scheduled-run:\(task.id):\(key.uuidString)" + ) + await load() + } catch let error where aidenIsCancellation(error) { + return + } catch { + guard coordinator.isCurrent(context) else { return } + if !Self.isAmbiguous(error) { pendingRunKeys[task.id] = nil } + presentedError = error.localizedDescription + coordinator.haptics.play(Self.isAmbiguous(error) ? .warning : .error, scope: hapticScope) + } + } + + func runs(_ task: AidenScheduledTask) async throws -> [AidenScheduledRun] { + let context = try requestContext() + let instanceId = context.instanceId + do { + let values = try await coordinator.remoteClient(for: context).scheduledRuns(taskId: task.id) + guard coordinator.isCurrent(context) else { + throw AidenRemoteClientError.installationChanged + } + try? await cache.store(runs: values, taskId: task.id, instanceId: instanceId) + return values + } catch { + if let retained = await cache.load(instanceId: instanceId)?.runs[task.id] { + guard coordinator.isCurrent(context) else { + throw AidenRemoteClientError.installationChanged + } + return retained + } + guard coordinator.isCurrent(context) else { + throw AidenRemoteClientError.installationChanged + } + throw error + } + } + + func updateSettings(_ mutation: AidenScheduledSettingsMutation) async -> Bool { + guard let settings, !isMutating, isConnected else { return false } + isMutating = true + defer { isMutating = false } + guard let context = try? requestContext() else { return false } + do { + let updated = try await coordinator.remoteClient(for: context).updateScheduledSettings( + revision: settings.revision, + mutation: mutation + ) + guard coordinator.isCurrent(context) else { return false } + self.settings = updated + try? await cache.store(instanceId: context.instanceId, tasks: tasks, settings: updated) + coordinator.haptics.play( + .success, + scope: hapticScope, + dedupeKey: "scheduled-settings:\(updated.revision)" + ) + return true + } catch let error where aidenIsCancellation(error) { + return false + } catch { + guard coordinator.isCurrent(context) else { return false } + presentedError = error.localizedDescription + coordinator.haptics.play(.error, scope: hapticScope) + await load() + return false + } + } + + private func mutate( + successEvent: AidenHapticEvent = .success, + _ operation: (AidenRemoteClient) async throws -> AidenScheduledTask + ) async { + guard !isMutating, isConnected, let context = try? requestContext() else { return } + isMutating = true + defer { isMutating = false } + do { + let updated = try await operation(coordinator.remoteClient(for: context)) + guard coordinator.isCurrent(context) else { return } + upsert(updated) + coordinator.haptics.play( + successEvent, + scope: hapticScope, + dedupeKey: "scheduled-mutate:\(updated.id):\(updated.revision)" + ) + } catch let error where aidenIsCancellation(error) { + return + } + catch { + guard coordinator.isCurrent(context) else { return } + presentedError = error.localizedDescription + coordinator.haptics.play(.error, scope: hapticScope) + await load() + } + } + + private func upsert(_ task: AidenScheduledTask) { + tasks.removeAll { $0.id == task.id } + tasks.append(task) + tasks.sort(by: Self.sort) + if let instanceId = activationContext?.instanceId, + activationContext.map(coordinator.isCurrent) == true { + let taskSnapshot = tasks + let settingsSnapshot = settings + Task { + try? await cache.store( + instanceId: instanceId, + tasks: taskSnapshot, + settings: settingsSnapshot + ) + } + } + } + + private static func sort(_ left: AidenScheduledTask, _ right: AidenScheduledTask) -> Bool { + if left.enabled != right.enabled { return left.enabled } + if left.nextRunAt != right.nextRunAt { return (left.nextRunAt ?? .distantFuture) < (right.nextRunAt ?? .distantFuture) } + return left.name.localizedCaseInsensitiveCompare(right.name) == .orderedAscending + } + + private static func isAmbiguous(_ error: Error) -> Bool { + if error is URLError { return true } + guard let error = error as? AidenRemoteClientError else { return false } + switch error { + case .invalidResponse, .unexpectedStatus: return true + case .server(_, let body): return body.code.rawValue == "idempotency_in_flight" || body.code.rawValue == "internal_error" + case .invalidEndpoint, .missingCredential, .missingTrustConfiguration, .installationChanged: return false + } + } +} + +struct AidenScheduledTasksView: View { + private enum TaskFilter: String, CaseIterable { + case all = "All" + case active = "Active" + case paused = "Paused" + case running = "Running" + } + + @Environment(\.dismiss) private var dismiss + @State private var model: AidenScheduledTasksModel + @State private var editorTask: AidenScheduledTask? + @State private var isCreating = false + @State private var isShowingSettings = false + @State private var searchText = "" + @State private var filter: TaskFilter = .all + + init(coordinator: AidenRemoteCoordinator) { + _model = State(initialValue: AidenScheduledTasksModel(coordinator: coordinator)) + } + + private var visibleTasks: [AidenScheduledTask] { + model.tasks.filter { task in + let matchesFilter = switch filter { + case .all: true + case .active: task.enabled + case .paused: !task.enabled + case .running: task.running + } + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + return matchesFilter && (query.isEmpty + || task.name.localizedCaseInsensitiveContains(query) + || task.schedule.localizedCaseInsensitiveContains(query)) + } + } + + var body: some View { + NavigationStack { + List { + Section { + Picker("Filter", selection: $filter) { + ForEach(TaskFilter.allCases, id: \.self) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + } + if let message = model.outcomeMessage { + Section { Text(message).font(.footnote).foregroundStyle(.secondary) } + } + if model.tasks.isEmpty && !model.isLoading { + ContentUnavailableView( + "No Scheduled Tasks", + systemImage: "clock.badge.plus", + description: Text("Create unattended work that Aiden Agent runs on your Mac, even while this phone is disconnected.") + ) + .listRowBackground(Color.clear) + } else if visibleTasks.isEmpty && !model.isLoading { + ContentUnavailableView.search(text: searchText) + .listRowBackground(Color.clear) + } else { + Section("Tasks") { + ForEach(visibleTasks) { task in + NavigationLink { + AidenScheduledTaskDetailView(model: model, taskId: task.id) + } label: { + VStack(alignment: .leading, spacing: 5) { + HStack { + Text(task.name).font(.headline) + if task.running { ProgressView().controlSize(.small) } + Spacer() + Text(task.enabled ? "Active" : "Paused") + .font(.caption).foregroundStyle(.secondary) + } + Text(task.schedule).font(.subheadline).foregroundStyle(.secondary) + if let next = task.nextRunAt { + Text("Next \(next.formatted(date: .abbreviated, time: .shortened))") + .font(.caption).foregroundStyle(.secondary) + } + } + } + .swipeActions(edge: .trailing) { + Button(task.enabled ? "Pause" : "Resume") { + Task { await model.pauseOrResume(task) } + } + .tint(.orange) + } + } + } + } + } + .navigationTitle("Scheduled Tasks") + .searchable(text: $searchText, prompt: "Search tasks") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } + ToolbarItemGroup(placement: .primaryAction) { + Button { isShowingSettings = true } label: { Label("Settings", systemImage: "gearshape") } + Button { isCreating = true } label: { Label("New Task", systemImage: "plus") } + .disabled(!model.isConnected || model.isMutating) + } + } + .refreshable { await model.load() } + .overlay { if model.isLoading && model.tasks.isEmpty { ProgressView("Loading tasks…") } } + .safeAreaInset(edge: .top) { + if !model.isConnected { + Text("Offline — task history remains visible, but changes are disabled.") + .font(.footnote).frame(maxWidth: .infinity).padding(8).background(.thinMaterial) + } + } + .sheet(isPresented: $isCreating) { + AidenScheduledTaskEditor(model: model, task: nil) { isCreating = false } + } + .sheet(item: $editorTask) { task in + AidenScheduledTaskEditor(model: model, task: task) { editorTask = nil } + } + .sheet(isPresented: $isShowingSettings) { + AidenScheduledSettingsView(model: model) + } + .alert("Scheduled Tasks", isPresented: Binding( + get: { model.presentedError != nil }, + set: { if !$0 { model.presentedError = nil } } + )) { Button("OK") { model.presentedError = nil } } message: { + Text(model.presentedError ?? "") + } + .task { await model.load() } + .onAppear { model.setHapticsActive(true) } + .onDisappear { model.setHapticsActive(false) } + } + } +} + +private struct AidenScheduledTaskDetailView: View { + @Bindable var model: AidenScheduledTasksModel + let taskId: String + @State private var runs: [AidenScheduledRun] = [] + @State private var isEditing = false + @State private var isConfirmingDelete = false + @State private var isConfirmingRun = false + @Environment(\.dismiss) private var dismiss + + private var task: AidenScheduledTask? { model.tasks.first { $0.id == taskId } } + + var body: some View { + List { + if let task { + Section("Task") { + LabeledContent("Status", value: task.running ? "Running" : (task.enabled ? "Active" : "Paused")) + LabeledContent("Schedule", value: task.schedule) + LabeledContent("Timezone", value: task.timezone) + LabeledContent("Mode", value: task.mode.title) + LabeledContent("Permission", value: task.permission.title) + if let prompt = task.prompt { Text(prompt).textSelection(.enabled) } + } + Section("Actions") { + Button("Run Now", systemImage: "play.fill") { isConfirmingRun = true } + Button(task.enabled ? "Pause" : "Resume", systemImage: task.enabled ? "pause" : "play") { + Task { await model.pauseOrResume(task) } + } + Button("Edit", systemImage: "pencil") { isEditing = true } + Button("Delete", systemImage: "trash", role: .destructive) { isConfirmingDelete = true } + } + .disabled(!model.isConnected || model.isMutating) + Section("Run History") { + if runs.isEmpty { Text("No completed runs yet.").foregroundStyle(.secondary) } + ForEach(runs) { run in + VStack(alignment: .leading, spacing: 5) { + HStack { Text(run.status.capitalized).font(.headline); Spacer(); Text(run.startedAt, style: .relative).font(.caption) } + if let summary = run.summary { Text(summary).font(.body).textSelection(.enabled) } + if let code = run.errorCode { Text(code).font(.caption).foregroundStyle(.red) } + } + } + } + } else { + ContentUnavailableView("Task Removed", systemImage: "trash") + } + } + .navigationTitle(task?.name ?? "Task") + .sheet(isPresented: $isEditing) { + if let task { AidenScheduledTaskEditor(model: model, task: task) { isEditing = false } } + } + .confirmationDialog("Run this task now?", isPresented: $isConfirmingRun, titleVisibility: .visible) { + if let task { Button("Run Now") { Task { await model.run(task); await loadRuns() } } } + Button("Cancel", role: .cancel) {} + } message: { Text("Aiden Agent owns the run. It continues if this phone disconnects.") } + .confirmationDialog("Delete this scheduled task?", isPresented: $isConfirmingDelete, titleVisibility: .visible) { + if let task { Button("Delete", role: .destructive) { Task { if await model.remove(task) { dismiss() } } } } + Button("Cancel", role: .cancel) {} + } + .task { await loadRuns() } + } + + private func loadRuns() async { + guard let task else { return } + do { runs = try await model.runs(task) } catch { model.presentedError = error.localizedDescription } + } +} + +private struct AidenScheduledTaskEditor: View { + @Bindable var model: AidenScheduledTasksModel + let task: AidenScheduledTask? + let onSaved: () -> Void + @State private var draft: AidenScheduledTaskDraft + @State private var preview: [Date] = [] + @State private var isReviewing = false + @Environment(\.dismiss) private var dismiss + + init(model: AidenScheduledTasksModel, task: AidenScheduledTask?, onSaved: @escaping () -> Void) { + self.model = model + self.task = task + self.onSaved = onSaved + _draft = State(initialValue: task.map(AidenScheduledTaskDraft.init(task:)) ?? AidenScheduledTaskDraft()) + } + + private var allProviders: [AidenProvider] { model.catalog?.providers ?? [] } + private var providers: [AidenProvider] { model.catalog?.visibleProviders ?? [] } + private var selectedProvider: AidenProvider? { allProviders.first { $0.id == draft.providerId } } + private var models: [AidenModel] { selectedProvider?.visibleModels ?? [] } + private var currentHiddenModel: AidenModel? { + selectedProvider?.models.first { $0.id == draft.modelId && $0.isHidden } + } + private var currentHiddenProvider: AidenProvider? { + guard let selectedProvider, selectedProvider.visibleModels.isEmpty else { return nil } + return selectedProvider + } + + private var providerPickerSelection: Binding { + Binding( + get: { + providers.contains { $0.id == draft.providerId } ? draft.providerId : nil + }, + set: { draft.providerId = $0 } + ) + } + + private var modelPickerSelection: Binding { + Binding( + get: { + models.contains { $0.id == draft.modelId } ? draft.modelId : nil + }, + set: { draft.modelId = $0 } + ) + } + + var body: some View { + NavigationStack { + Form { + Section("Task") { + TextField("Name", text: $draft.name) + Picker("Mode", selection: $draft.mode) { + ForEach(AidenScheduledTaskMode.allCases, id: \.self) { Text($0.title).tag($0) } + } + Picker("Workspace", selection: $draft.workspaceId) { + Text("No workspace").tag(String?.none) + ForEach(model.workspaces) { Text($0.name).tag(Optional($0.id)) } + } + } + Section("Schedule") { + TextField("Cron schedule", text: $draft.schedule) + .textInputAutocapitalization(.never).autocorrectionDisabled() + TextField("Timezone", text: $draft.timezone) + .textInputAutocapitalization(.never).autocorrectionDisabled() + Button("Preview Next Runs") { + Task { do { preview = try await model.preview(draft) } catch { model.presentedError = error.localizedDescription } } + } + ForEach(preview, id: \.self) { Text($0.formatted(date: .abbreviated, time: .shortened)).foregroundStyle(.secondary) } + } + if draft.mode == .llm { + Section("Ask Aiden") { + TextEditor(text: $draft.prompt).frame(minHeight: 120) + if let currentHiddenProvider { + LabeledContent("Current provider", value: "\(currentHiddenProvider.label) · Hidden") + } + Picker("Provider", selection: providerPickerSelection) { + Text("Default").tag(String?.none) + ForEach(providers) { provider in + Label { + Text(provider.label) + } icon: { + AidenProviderIcon( + providerID: provider.id, + providerLabel: provider.label, + artwork: provider.artwork, + size: 16 + ) + } + .tag(Optional(provider.id)) + } + } + if let currentHiddenModel { + LabeledContent("Current model", value: "\(currentHiddenModel.label) · Hidden") + } + Picker("Model", selection: modelPickerSelection) { + Text("Default").tag(String?.none) + ForEach(models) { candidate in + Text(candidate.label).tag(Optional(candidate.id)) + } + } + } + if !model.mcpServers.isEmpty { + Section("MCP Access") { + ForEach(model.mcpServers) { server in + Toggle(server.name, isOn: Binding( + get: { draft.mcpServerIds.contains(server.id) }, + set: { selected in + if selected { draft.mcpServerIds.insert(server.id) } + else { draft.mcpServerIds.remove(server.id) } + } + )) + } + Text("Only enabled server names are shown. Connection details and credentials remain on your Mac.") + .font(.footnote).foregroundStyle(.secondary) + } + } + } else { + Section("Script") { + Picker("Aiden script", selection: $draft.scriptId) { + Text("Choose a script").tag(String?.none) + ForEach(model.scripts) { Text($0.name).tag(Optional($0.id)) } + } + Text("Only scripts inventoried by Aiden Agent can be selected. The phone never sends a path.") + .font(.footnote).foregroundStyle(.secondary) + } + } + Section("Unattended Access") { + 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.") + .font(.footnote).foregroundStyle(.secondary) + } + if let validation = draft.validationMessage { Section { Text(validation).foregroundStyle(.red) } } + } + .navigationTitle(task == nil ? "New Task" : "Edit Task") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { Button("Review") { isReviewing = true }.disabled(draft.validationMessage != nil) } + } + .onChange(of: draft.mode) { _, mode in + if mode == .script { draft.permission = .full; Task { await model.loadScripts(workspaceId: draft.workspaceId) } } + } + .onChange(of: draft.providerId) { _, providerId in + guard let providerId, + let provider = providers.first(where: { $0.id == providerId }) else { + draft.modelId = nil + return + } + if !provider.visibleModels.contains(where: { $0.id == draft.modelId }) { + draft.modelId = provider.visibleModels.first?.id + } + } + .onChange(of: draft.workspaceId) { _, workspaceId in + if draft.mode == .script { draft.scriptId = nil; Task { await model.loadScripts(workspaceId: workspaceId) } } + } + .task { + if draft.providerId == nil { draft.providerId = model.catalog?.defaults["providerId"] } + if draft.modelId == nil { draft.modelId = model.catalog?.defaults["modelId"] } + if draft.mode == .script { await model.loadScripts(workspaceId: draft.workspaceId) } + } + .sheet(isPresented: $isReviewing) { + NavigationStack { + Form { + Section("Final Review") { + LabeledContent("Task", value: draft.name) + LabeledContent("Schedule", value: draft.schedule) + LabeledContent("Timezone", value: draft.timezone) + LabeledContent("Mode", value: draft.mode.title) + LabeledContent("Permission", value: draft.permission.title) + 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.") } + } + .navigationTitle("Review Task") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Back") { isReviewing = false } } + ToolbarItem(placement: .confirmationAction) { + Button(task == nil ? "Create" : "Save") { + Task { if await model.save(draft, replacing: task) { isReviewing = false; onSaved(); dismiss() } } + } + .disabled(model.isMutating) + } + } + } + } + } + } +} + +private struct AidenScheduledSettingsView: View { + @Bindable var model: AidenScheduledTasksModel + @Environment(\.dismiss) private var dismiss + @State private var enabled = true + @State private var mode: AidenScheduledTaskMode = .llm + @State private var permission: AidenScheduledTaskPermission = .readOnly + @State private var mcpEnabled = false + @State private var notify = true + @State private var timezone = TimeZone.current.identifier + + var body: some View { + NavigationStack { + Form { + Section("Global") { + Toggle("Scheduled tasks enabled", isOn: $enabled) + Picker("Default mode", selection: $mode) { ForEach(AidenScheduledTaskMode.allCases, id: \.self) { Text($0.title).tag($0) } } + Picker("Default permission", selection: $permission) { ForEach(AidenScheduledTaskPermission.allCases, id: \.self) { Text($0.title).tag($0) } } + Toggle("Enable MCP by default", isOn: $mcpEnabled) + Toggle("Default notifications", isOn: $notify) + TextField("Default timezone", text: $timezone) + } + Section { Text("Disabling scheduled tasks stops active scheduled work without deleting task definitions.") } + } + .navigationTitle("Task Settings") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + Task { + if await model.updateSettings(AidenScheduledSettingsMutation( + enabled: enabled, defaultMode: mode, defaultPermission: permission, + defaultMcpEnabled: mcpEnabled, defaultNotify: notify, defaultTimezone: timezone + )) { dismiss() } + } + } + .disabled(!model.isConnected || model.isMutating) + } + } + .task { + if let settings = model.settings { + enabled = settings.enabled; mode = settings.defaultMode + permission = settings.defaultPermission; notify = settings.defaultNotify + mcpEnabled = settings.defaultMcpEnabled + timezone = settings.defaultTimezone + } + } + } + } +} diff --git a/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceEnvironmentView.swift b/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceEnvironmentView.swift new file mode 100644 index 00000000..27796919 --- /dev/null +++ b/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceEnvironmentView.swift @@ -0,0 +1,1077 @@ +import CryptoKit +import Foundation +import Observation +import SwiftUI + +actor AidenWorkspaceEnvironmentCache { + struct Snapshot: Codable, Equatable { + var index: AidenWorkspaceFileIndex + var documents: [String: AidenWorkspaceFileDocument] + var updatedAt: Date + } + + static let shared = AidenWorkspaceEnvironmentCache() + private let directory: URL + private let maximumBytes = 8 * 1_048_576 + + init(directory: URL? = nil) { + self.directory = directory ?? FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + )[0].appending(path: "AidenWorkspaceEnvironment", directoryHint: .isDirectory) + } + + func load(instanceId: String, workspaceId: String) -> Snapshot? { + if let current = try? JSONDecoder().decode( + Snapshot.self, + from: Data(contentsOf: file(instanceId: instanceId, workspaceId: workspaceId)) + ) { + return current + } + let legacyURL = legacyFile(instanceId: instanceId, workspaceId: workspaceId) + guard let legacy = try? JSONDecoder().decode( + Snapshot.self, + from: Data(contentsOf: legacyURL) + ) else { return nil } + try? persist(legacy, instanceId: instanceId, workspaceId: workspaceId) + try? FileManager.default.removeItem(at: legacyURL) + return legacy + } + + func store( + index: AidenWorkspaceFileIndex, + instanceId: String, + workspaceId: String + ) throws { + let retained = load(instanceId: instanceId, workspaceId: workspaceId)?.documents ?? [:] + let validIDs = Set(index.entries.lazy.filter { $0.kind == .file }.map(\.id)) + try persist( + Snapshot( + index: index, + documents: retained.filter { validIDs.contains($0.key) }, + updatedAt: Date() + ), + instanceId: instanceId, + workspaceId: workspaceId + ) + } + + func purge(instanceId: String, knownWorkspaceIds: Set = []) { + let instanceDirectory = directory.appending( + path: instanceDigest(instanceId), + directoryHint: .isDirectory + ) + 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. + for workspaceId in knownWorkspaceIds { + try? FileManager.default.removeItem( + at: legacyFile(instanceId: instanceId, workspaceId: workspaceId) + ) + } + } + + func store( + document: AidenWorkspaceFileDocument, + instanceId: String, + workspaceId: String + ) throws { + guard var snapshot = load(instanceId: instanceId, workspaceId: workspaceId) else { return } + snapshot.documents[document.id] = document + snapshot.updatedAt = Date() + try persist(snapshot, instanceId: instanceId, workspaceId: workspaceId) + } + + private func persist(_ snapshot: Snapshot, instanceId: String, workspaceId: String) throws { + let destination = file(instanceId: instanceId, workspaceId: workspaceId) + try FileManager.default.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var value = snapshot + var data = try JSONEncoder().encode(value) + if data.count > maximumBytes { + value.documents = [:] + data = try JSONEncoder().encode(value) + } + guard data.count <= maximumBytes else { return } + try data.write(to: destination, options: .atomic) + } + + private func file(instanceId: String, workspaceId: String) -> URL { + let digest = SHA256.hash(data: Data(workspaceId.utf8)) + .map { String(format: "%02x", $0) } + .joined() + return directory + .appending(path: instanceDigest(instanceId), directoryHint: .isDirectory) + .appending(path: "\(digest).json") + } + + private func instanceDigest(_ instanceId: String) -> String { + SHA256.hash(data: Data(instanceId.utf8)) + .map { String(format: "%02x", $0) } + .joined() + } + + private func legacyFile(instanceId: String, workspaceId: String) -> URL { + let digest = SHA256.hash(data: Data("\(instanceId)\u{0}\(workspaceId)".utf8)) + .map { String(format: "%02x", $0) } + .joined() + return directory.appending(path: "\(digest).json") + } +} + +@MainActor +@Observable +final class AidenWorkspaceFilesModel { + var index: AidenWorkspaceFileIndex? + var document: AidenWorkspaceFileDocument? + var draft = "" + var isLoading = false + var isSaving = false + var isOfflineSnapshot = false + var errorMessage: String? + + private let workspace: AidenWorkspace + private let cache: AidenWorkspaceEnvironmentCache + private let hapticScope = UUID() + + init(workspace: AidenWorkspace, cache: AidenWorkspaceEnvironmentCache = .shared) { + self.workspace = workspace + self.cache = cache + } + + func setHapticsActive(_ active: Bool, coordinator: AidenRemoteCoordinator) { + if active { + coordinator.haptics.activate(scope: hapticScope) + } else { + coordinator.haptics.deactivate(scope: hapticScope) + } + } + + func load(coordinator: AidenRemoteCoordinator) async { + guard !isLoading, let context = try? coordinator.requestContext() else { return } + let instanceId = context.instanceId + isLoading = true + errorMessage = nil + defer { isLoading = false } + if coordinator.connectionState == .connected { + do { + let value = try await coordinator.remoteClient(for: context).workspaceFiles(workspaceId: workspace.id) + guard coordinator.isCurrent(context) else { return } + index = value + isOfflineSnapshot = false + try? await cache.store(index: value, instanceId: instanceId, workspaceId: workspace.id) + return + } catch { + guard coordinator.isCurrent(context) else { return } + errorMessage = error.localizedDescription + } + } + if let cached = await cache.load(instanceId: instanceId, workspaceId: workspace.id) { + guard coordinator.isCurrent(context) else { return } + index = cached.index + isOfflineSnapshot = true + } else if coordinator.isCurrent(context), errorMessage == nil { + errorMessage = "Connect to Aiden Agent to load workspace files." + } + } + + func open(_ entry: AidenWorkspaceFileEntry, coordinator: AidenRemoteCoordinator) async { + guard entry.kind == .file, let context = try? coordinator.requestContext() else { return } + let instanceId = context.instanceId + errorMessage = nil + if coordinator.connectionState == .connected { + do { + let value = try await coordinator.remoteClient(for: context).workspaceFile( + workspaceId: workspace.id, + fileId: entry.id + ) + guard coordinator.isCurrent(context) else { return } + document = value + draft = value.content + isOfflineSnapshot = false + try? await cache.store(document: value, instanceId: instanceId, workspaceId: workspace.id) + return + } catch { + guard coordinator.isCurrent(context) else { return } + errorMessage = error.localizedDescription + } + } + if let cached = await cache.load(instanceId: instanceId, workspaceId: workspace.id), + let value = cached.documents[entry.id] { + guard coordinator.isCurrent(context) else { return } + document = value + draft = value.content + isOfflineSnapshot = true + } + } + + func save(coordinator: AidenRemoteCoordinator) async -> Bool { + guard coordinator.connectionState == .connected, + !isSaving, + let document, + let context = try? coordinator.requestContext() else { return false } + let instanceId = context.instanceId + isSaving = true + errorMessage = nil + defer { isSaving = false } + do { + let saved = try await coordinator.remoteClient(for: context).writeWorkspaceFile( + workspaceId: workspace.id, + fileId: document.id, + content: draft, + expectedVersion: document.version + ) + guard coordinator.isCurrent(context) else { return false } + self.document = saved + draft = saved.content + try? await cache.store(document: saved, instanceId: instanceId, workspaceId: workspace.id) + coordinator.haptics.play( + .success, + scope: hapticScope, + dedupeKey: "file-save:\(workspace.id):\(saved.id):\(saved.version)" + ) + return true + } catch let error where aidenIsCancellation(error) { + return false + } catch { + 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." + coordinator.haptics.play(.warning, scope: hapticScope) + } else { + errorMessage = error.localizedDescription + coordinator.haptics.play(.error, scope: hapticScope) + } + return false + } + } + + func reloadDocument(coordinator: AidenRemoteCoordinator) async { + guard let document, + let entry = index?.entries.first(where: { $0.id == document.id }) else { return } + await open(entry, coordinator: coordinator) + } +} + +struct AidenWorkspaceFilesView: View { + @Bindable var coordinator: AidenRemoteCoordinator + let workspace: AidenWorkspace + @State private var model: AidenWorkspaceFilesModel + @State private var search = "" + @State private var isShowingDocument = false + + init(coordinator: AidenRemoteCoordinator, workspace: AidenWorkspace) { + self.coordinator = coordinator + self.workspace = workspace + _model = State(initialValue: AidenWorkspaceFilesModel(workspace: workspace)) + } + + private var entries: [AidenWorkspaceFileEntry] { + let values = model.index?.entries ?? [] + guard !search.isEmpty else { return values } + return values.filter { $0.displayPath.localizedCaseInsensitiveContains(search) } + } + + var body: some View { + List { + if model.isOfflineSnapshot { + Section { + Label("Showing the last downloaded snapshot. Editing is disabled.", systemImage: "wifi.slash") + .foregroundStyle(.secondary) + } + } + if let index = model.index, index.truncated { + Section { + Label("This bounded index contains up to 4,000 entries and may be incomplete.", systemImage: "exclamationmark.triangle") + } + } + Section("Files") { + ForEach(entries) { entry in + Button { + guard entry.kind == .file else { return } + Task { + await model.open(entry, coordinator: coordinator) + isShowingDocument = model.document != nil + } + } label: { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(entry.name) + .foregroundStyle(.primary) + Text(entry.displayPath) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } icon: { + Image(systemName: entry.kind == .directory ? "folder" : entry.kind == .symlink ? "link" : "doc.text") + } + } + .disabled(entry.kind != .file) + } + } + } + .overlay { + if model.isLoading && model.index == nil { ProgressView("Loading files…") } + } + .navigationTitle("Files") + .searchable(text: $search, prompt: "Find a file") + .refreshable { await model.load(coordinator: coordinator) } + .task { await model.load(coordinator: coordinator) } + .onAppear { model.setHapticsActive(true, coordinator: coordinator) } + .onDisappear { model.setHapticsActive(false, coordinator: coordinator) } + .sheet(isPresented: $isShowingDocument) { + AidenWorkspaceFileEditorView(coordinator: coordinator, model: model) + } + .alert("Files", isPresented: Binding( + get: { model.errorMessage != nil && !isShowingDocument }, + set: { if !$0 { model.errorMessage = nil } } + )) { + Button("OK", role: .cancel) { model.errorMessage = nil } + } message: { + Text(model.errorMessage ?? "The file operation failed.") + } + } +} + +private struct AidenWorkspaceFileEditorView: View { + @Environment(\.dismiss) private var dismiss + @Bindable var coordinator: AidenRemoteCoordinator + @Bindable var model: AidenWorkspaceFilesModel + @State private var isConfirmingDiscard = false + + private var isDirty: Bool { model.document.map { $0.content != model.draft } ?? false } + + var body: some View { + NavigationStack { + TextEditor(text: $model.draft) + .font(.system(.body, design: .monospaced)) + .padding(.horizontal, 8) + .navigationTitle(model.document?.displayPath ?? "File") + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + 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") { + Task { await model.reloadDocument(coordinator: coordinator) } + } + } + } + .padding() + .frame(maxWidth: .infinity) + .background(.regularMaterial) + } + } + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { + if isDirty && !model.isOfflineSnapshot { + isConfirmingDiscard = true + } else { + dismiss() + } + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { Task { _ = await model.save(coordinator: coordinator) } } + .disabled( + !isDirty || model.isSaving || model.isOfflineSnapshot || + coordinator.connectionState != .connected || model.document?.truncated == true + ) + } + } + } + .interactiveDismissDisabled(isDirty && !model.isOfflineSnapshot) + .confirmationDialog("Discard unsaved changes?", isPresented: $isConfirmingDiscard) { + Button("Discard Changes", role: .destructive) { dismiss() } + Button("Keep Editing", role: .cancel) {} + } + } +} + +private enum AidenPendingGitMutation { + case commit(UUID, snapshot: String, message: String, stagedOnly: Bool) + case checkout(UUID, snapshot: String, branch: String) + case createBranch(UUID, name: String, startPoint: String) + case push(UUID, snapshot: String, remote: String, branch: String) + case createWorktree(UUID, branch: String, name: String) + + var idempotencyKey: UUID { + switch self { + case .commit(let key, _, _, _), .checkout(let key, _, _), + .createBranch(let key, _, _), .push(let key, _, _, _), + .createWorktree(let key, _, _): key + } + } +} + +@MainActor +@Observable +final class AidenWorkspaceGitModel { + var review: AidenGitReview? + var reviewSnapshotId: String? + var branches: AidenGitBranches? + var branchesSnapshotId: String? + var worktrees: [AidenGitWorktree] = [] + var comparison: AidenGitComparison? + var selectedDiff: AidenGitDiff? + var pushCapability: AidenGitPushCapability? + var pushSnapshotId: String? + var isLoading = false + var errorMessage: String? + var lastMessage: String? + private var pendingMutation: AidenPendingGitMutation? + private let haptics: any AidenHapticEmitting + private let hapticScope = UUID() + + init(haptics: (any AidenHapticEmitting)? = nil) { + self.haptics = haptics ?? AidenHapticCenter() + } + + func setHapticsActive(_ active: Bool) { + if active { + haptics.activate(scope: hapticScope) + } else { + haptics.deactivate(scope: hapticScope) + } + } + + var canRetryPendingMutation: Bool { pendingMutation != nil } + + func refresh( + client: AidenRemoteClient, + workspaceId: String, + isCurrent: @MainActor () -> Bool + ) async { + guard !isLoading else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let reviewResult = try await client.gitReview(workspaceId: workspaceId) + guard isCurrent() else { return } + if case .review(let value) = reviewResult.result { + review = value + reviewSnapshotId = reviewResult.snapshotId + } + let branchResult = try await client.gitBranches(workspaceId: workspaceId) + guard isCurrent() else { return } + if case .branches(let value) = branchResult.result { + branches = value + branchesSnapshotId = branchResult.snapshotId + } + let worktreeResult = try await client.gitWorktrees(workspaceId: workspaceId) + guard isCurrent() else { return } + if case .worktrees(let value) = worktreeResult.result { worktrees = value.worktrees } + } catch { + guard isCurrent() else { return } + errorMessage = error.localizedDescription + } + } + + func diff( + client: AidenRemoteClient, + workspaceId: String, + file: AidenGitFile, + comparisonMode: Bool = false, + isCurrent: @MainActor () -> Bool + ) async { + do { + let result = comparisonMode + ? try await client.gitComparisonDiff( + workspaceId: workspaceId, + comparisonId: comparison?.comparisonId ?? "", + fileId: file.id + ) + : try await client.gitDiff( + workspaceId: workspaceId, + snapshotId: reviewSnapshotId ?? "", + fileId: file.id + ) + guard isCurrent() else { return } + if case .diff(let value) = result.result { selectedDiff = value } + } catch { + guard isCurrent() else { return } + errorMessage = error.localizedDescription + } + } + + func commit(client: AidenRemoteClient, workspaceId: String, message: String, stagedOnly: Bool, isCurrent: @escaping @MainActor () -> Bool) async { + let operation = AidenPendingGitMutation.commit( + UUID(), + snapshot: reviewSnapshotId ?? "", + message: message, + stagedOnly: stagedOnly + ) + if await execute(operation, client: client, workspaceId: workspaceId, isCurrent: isCurrent) { + await refresh(client: client, workspaceId: workspaceId, isCurrent: isCurrent) + } + } + + func checkout(client: AidenRemoteClient, workspaceId: String, branch: String, isCurrent: @escaping @MainActor () -> Bool) async { + let operation = AidenPendingGitMutation.checkout( + UUID(), + snapshot: branchesSnapshotId ?? "", + branch: branch + ) + if await execute(operation, client: client, workspaceId: workspaceId, isCurrent: isCurrent) { + await refresh(client: client, workspaceId: workspaceId, isCurrent: isCurrent) + } + } + + func createBranch(client: AidenRemoteClient, workspaceId: String, name: String, isCurrent: @escaping @MainActor () -> Bool) async { + let operation = AidenPendingGitMutation.createBranch( + UUID(), + name: name, + startPoint: branches?.current ?? "HEAD" + ) + if await execute(operation, client: client, workspaceId: workspaceId, isCurrent: isCurrent) { + await refresh(client: client, workspaceId: workspaceId, isCurrent: isCurrent) + } + } + + func preparePush(client: AidenRemoteClient, workspaceId: String, isCurrent: @MainActor () -> Bool) async { + do { + let result = try await client.gitPushCapability(workspaceId: workspaceId) + guard isCurrent() else { return } + if case .pushCapability(let value) = result.result { + pushCapability = value + pushSnapshotId = result.snapshotId + if !value.allowed { + errorMessage = value.reason ?? "Push is unavailable for this workspace state." + haptics.play( + .warning, + scope: hapticScope, + dedupeKey: "git-push-capability:\(result.snapshotId ?? workspaceId)" + ) + } + } + } catch let error where aidenIsCancellation(error) { + return + } catch { + guard isCurrent() else { return } + errorMessage = error.localizedDescription + haptics.play(.error, scope: hapticScope) + } + } + + func push(client: AidenRemoteClient, workspaceId: String, isCurrent: @escaping @MainActor () -> Bool) async { + guard let pushCapability, let remote = pushCapability.remote, let branch = pushCapability.branch else { return } + _ = await execute( + .push(UUID(), snapshot: pushSnapshotId ?? "", remote: remote, branch: branch), + client: client, + workspaceId: workspaceId, + isCurrent: isCurrent + ) + } + + func compare(client: AidenRemoteClient, workspaceId: String, baseRef: String, isCurrent: @MainActor () -> Bool) async { + do { + let result = try await client.compareGit(workspaceId: workspaceId, baseRef: baseRef) + guard isCurrent() else { return } + if case .comparison(let value) = result.result { comparison = value } + } catch { + guard isCurrent() else { return } + errorMessage = error.localizedDescription + } + } + + func createWorktree(client: AidenRemoteClient, workspaceId: String, branch: String, name: String, isCurrent: @escaping @MainActor () -> Bool) async { + if await execute( + .createWorktree(UUID(), branch: branch, name: name), + client: client, + workspaceId: workspaceId, + isCurrent: isCurrent + ) { + await refresh(client: client, workspaceId: workspaceId, isCurrent: isCurrent) + } + } + + func retryPendingMutation(client: AidenRemoteClient, workspaceId: String, isCurrent: @escaping @MainActor () -> Bool) async { + guard let pendingMutation else { return } + if await execute(pendingMutation, client: client, workspaceId: workspaceId, isCurrent: isCurrent) { + await refresh(client: client, workspaceId: workspaceId, isCurrent: isCurrent) + } + } + + private func execute( + _ operation: AidenPendingGitMutation, + client: AidenRemoteClient, + workspaceId: String, + isCurrent: @MainActor () -> Bool + ) async -> Bool { + isLoading = true + errorMessage = nil + pendingMutation = operation + defer { isLoading = false } + do { + let value: AidenGitResult + switch operation { + case .commit(let key, let snapshot, let message, let stagedOnly): + value = try await client.commitGit( + workspaceId: workspaceId, + snapshotId: snapshot, + message: message, + stagedOnly: stagedOnly, + idempotencyKey: key + ) + case .checkout(let key, let snapshot, let branch): + value = try await client.checkoutGitBranch( + workspaceId: workspaceId, + branch: branch, + snapshotId: snapshot, + idempotencyKey: key + ) + case .createBranch(let key, let name, let startPoint): + value = try await client.createGitBranch( + workspaceId: workspaceId, + name: name, + startPoint: startPoint, + idempotencyKey: key + ) + case .push(let key, let snapshot, let remote, let branch): + value = try await client.pushGit( + workspaceId: workspaceId, + snapshotId: snapshot, + remote: remote, + branch: branch, + idempotencyKey: key + ) + case .createWorktree(let key, let branch, let name): + value = try await client.createGitWorktree( + workspaceId: workspaceId, + branch: branch, + name: name, + idempotencyKey: key + ) + } + guard isCurrent() else { return false } + var event: AidenHapticEvent = .success + if case .mutation(let mutation) = value.result { + lastMessage = mutation.warning.map { "\(mutation.message) \($0)" } ?? mutation.message + if mutation.warning != nil { event = .warning } + } + pendingMutation = nil + haptics.play( + event, + scope: hapticScope, + dedupeKey: "git:\(workspaceId):\(operation.idempotencyKey.uuidString)" + ) + return true + } catch let error where aidenIsCancellation(error) { + return false + } catch { + guard isCurrent() else { return false } + let retain = shouldRetainForReconciliation(error) + if !retain { pendingMutation = nil } + errorMessage = retain + ? "\(error.localizedDescription) Reconnect, then use Retry Last Git Operation to reconcile safely." + : error.localizedDescription + haptics.play(retain ? .warning : .error, scope: hapticScope) + return false + } + } + + private func shouldRetainForReconciliation(_ error: Error) -> Bool { + if error is URLError { return true } + guard let clientError = error as? AidenRemoteClientError else { return false } + switch clientError { + case .invalidResponse, .unexpectedStatus: + return true + case .server(_, let body): + return body.code.rawValue == "idempotency_in_flight" || body.code.rawValue == "internal_error" + case .invalidEndpoint, .missingCredential, .missingTrustConfiguration, .installationChanged: + return false + } + } +} + +struct AidenWorkspaceGitView: View { + @Bindable var coordinator: AidenRemoteCoordinator + let workspace: AidenWorkspace + @State private var model: AidenWorkspaceGitModel + @State private var commitMessage = "" + @State private var stagedOnly = false + @State private var isShowingCommit = false + @State private var isConfirmingCommit = false + @State private var isConfirmingPush = false + @State private var compareBase = "" + @State private var isShowingCompare = false + @State private var newBranch = "" + @State private var isShowingNewBranch = false + @State private var checkoutBranch: String? + @State private var worktreeBranch = "" + @State private var worktreeName = "" + @State private var isShowingNewWorktree = false + + init(coordinator: AidenRemoteCoordinator, workspace: AidenWorkspace) { + self.coordinator = coordinator + self.workspace = workspace + _model = State(initialValue: AidenWorkspaceGitModel(haptics: coordinator.haptics)) + } + + var body: some View { + gitConfirmations + .alert("Git", isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } } + )) { + Button("OK", role: .cancel) { model.errorMessage = nil } + } message: { Text(model.errorMessage ?? "The Git operation failed.") } + } + + private var gitList: some View { + List { + if let review = model.review { + Section("Repository") { + LabeledContent("Branch", value: review.branch) + LabeledContent("Changes", value: "\(review.uncommitted)") + } + Section("Working changes") { + if review.files.isEmpty { + Label("Working tree is clean", systemImage: "checkmark.circle") + } + ForEach(review.files) { file in + gitFileButton(file, comparisonMode: false) + } + } + } + + if let comparison = model.comparison { + Section("Compare \(comparison.base) → \(comparison.head)") { + ForEach(comparison.files) { file in gitFileButton(file, comparisonMode: true) } + } + } + + Section("Actions") { + Button("Commit reviewed changes", systemImage: "checkmark.circle") { isShowingCommit = true } + .disabled(model.review?.files.isEmpty != false) + Button("Push reviewed commit", systemImage: "arrow.up.circle") { + Task { + await withRemoteContext { client, context in + await model.preparePush( + client: client, + workspaceId: workspace.id, + isCurrent: { coordinator.isCurrent(context) } + ) + guard coordinator.isCurrent(context) else { return } + isConfirmingPush = model.pushCapability?.allowed == true + } + } + } + Button("Compare branch", systemImage: "arrow.left.arrow.right") { isShowingCompare = true } + } + .disabled(coordinator.connectionState != .connected || model.isLoading) + + if let branches = model.branches { + Section("Branches") { + ForEach(branches.branches, id: \.self) { branch in + Button { + checkoutBranch = branch + } label: { + HStack { + Text(branch) + Spacer() + if branch == branches.current { Image(systemName: "checkmark") } + } + } + .disabled(branch == branches.current) + } + Button("New Branch", systemImage: "plus") { isShowingNewBranch = true } + } + .disabled(coordinator.connectionState != .connected || model.isLoading) + } + + Section("Managed worktrees") { + ForEach(model.worktrees) { worktree in + LabeledContent(worktree.name, value: worktree.branch) + } + Button("New Managed Worktree", systemImage: "hammer") { isShowingNewWorktree = true } + } + .disabled(coordinator.connectionState != .connected || model.isLoading) + + if let message = model.lastMessage { + Section { Label(message, systemImage: "checkmark.circle") } + } + if model.canRetryPendingMutation { + Section { + Button("Retry Last Git Operation", systemImage: "arrow.clockwise") { + Task { + await withRemoteContext { client, context in + await model.retryPendingMutation( + client: client, + workspaceId: workspace.id, + isCurrent: { coordinator.isCurrent(context) } + ) + } + } + } + } footer: { + Text("Reuses the original idempotency key so reconnecting cannot duplicate the mutation.") + } + .disabled(coordinator.connectionState != .connected || model.isLoading) + } + } + .navigationTitle("Git") + .overlay { if model.isLoading && model.review == nil { ProgressView("Loading Git…") } } + .refreshable { await refresh() } + .task { await refresh() } + .onAppear { model.setHapticsActive(true) } + .onDisappear { model.setHapticsActive(false) } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Refresh", systemImage: "arrow.clockwise") { Task { await refresh() } } + } + } + } + + private var gitSheets: some View { + gitList + .sheet(isPresented: $isShowingCommit) { + commitSheet + } + .sheet(item: $model.selectedDiff) { diff in + diffSheet(diff) + } + } + + private var gitAlerts: some View { + gitSheets + .alert("Compare Branch", isPresented: $isShowingCompare) { + TextField("Base branch", text: $compareBase) + Button("Compare") { + let base = compareBase.trimmingCharacters(in: .whitespacesAndNewlines) + Task { + await withRemoteContext { client, context in + await model.compare( + client: client, + workspaceId: workspace.id, + baseRef: base, + isCurrent: { coordinator.isCurrent(context) } + ) + } + } + } + Button("Cancel", role: .cancel) {} + } + .alert("New Branch", isPresented: $isShowingNewBranch) { + TextField("Branch name", text: $newBranch) + Button("Create and Check Out") { + let value = newBranch.trimmingCharacters(in: .whitespacesAndNewlines) + Task { + await withRemoteContext { client, context in + await model.createBranch( + client: client, + workspaceId: workspace.id, + name: value, + isCurrent: { coordinator.isCurrent(context) } + ) + } + } + } + Button("Cancel", role: .cancel) {} + } + .alert("New Managed Worktree", isPresented: $isShowingNewWorktree) { + TextField("Branch", text: $worktreeBranch) + TextField("Workspace name", text: $worktreeName) + Button("Create") { + let branch = worktreeBranch.trimmingCharacters(in: .whitespacesAndNewlines) + let name = worktreeName.trimmingCharacters(in: .whitespacesAndNewlines) + Task { + await withRemoteContext { client, context in + await model.createWorktree( + client: client, + workspaceId: workspace.id, + branch: branch, + name: name, + isCurrent: { coordinator.isCurrent(context) } + ) + guard coordinator.isCurrent(context) else { return } + await coordinator.refreshWorkspaces() + } + } + } + Button("Cancel", role: .cancel) {} + } + } + + private var gitConfirmations: some View { + gitAlerts + .confirmationDialog("Check out \(checkoutBranch ?? "this branch")?", isPresented: Binding( + get: { checkoutBranch != nil }, + set: { if !$0 { checkoutBranch = nil } } + )) { + Button("Check Out Branch") { + let branch = checkoutBranch + checkoutBranch = nil + Task { + guard let branch else { return } + await withRemoteContext { client, context in + await model.checkout( + client: client, + workspaceId: workspace.id, + branch: branch, + isCurrent: { coordinator.isCurrent(context) } + ) + } + } + } + Button("Cancel", role: .cancel) { checkoutBranch = nil } + } message: { Text("Aiden Agent will switch the workspace to this branch.") } + .confirmationDialog("Push the reviewed commit?", isPresented: $isConfirmingPush) { + Button("Push to \(model.pushCapability?.remote ?? "remote")") { + Task { + await withRemoteContext { client, context in + await model.push( + client: client, + workspaceId: workspace.id, + isCurrent: { coordinator.isCurrent(context) } + ) + } + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Destination: \(model.pushCapability?.branch ?? "current branch"). Aiden never force-pushes.") + } + } + + private var commitSheet: some View { + NavigationStack { + Form { + Section("Commit message") { + TextField("Describe the change", text: $commitMessage, axis: .vertical) + } + Section { Toggle("Staged changes only", isOn: $stagedOnly) } + } + .navigationTitle("Commit Changes") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { isShowingCommit = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("Review Commit") { isConfirmingCommit = true } + .disabled(commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .confirmationDialog("Commit the reviewed snapshot?", isPresented: $isConfirmingCommit) { + Button("Commit Changes") { + let message = commitMessage.trimmingCharacters(in: .whitespacesAndNewlines) + isShowingCommit = false + Task { + await withRemoteContext { client, context in + await model.commit( + client: client, + workspaceId: workspace.id, + message: message, + stagedOnly: stagedOnly, + isCurrent: { coordinator.isCurrent(context) } + ) + } + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Aiden Agent will create a Git commit from the exact reviewed snapshot.") + } + } + } + + private func diffSheet(_ diff: AidenGitDiff) -> some View { + NavigationStack { + ScrollView { + Text(diff.diff) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .navigationTitle(diff.displayPath) + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + if diff.truncated { + Text("Diff truncated") + .font(.footnote) + .padding() + .frame(maxWidth: .infinity) + .background(.regularMaterial) + } + } + } + } + + private func gitFileButton(_ file: AidenGitFile, comparisonMode: Bool) -> some View { + Button { + Task { + await withRemoteContext { client, context in + await model.diff( + client: client, + workspaceId: workspace.id, + file: file, + comparisonMode: comparisonMode, + isCurrent: { coordinator.isCurrent(context) } + ) + } + } + } label: { + HStack { + Text(file.status.symbol).font(.system(.body, design: .monospaced)).foregroundStyle(file.status.tint) + VStack(alignment: .leading, spacing: 2) { + Text(file.displayPath).foregroundStyle(.primary).lineLimit(1) + if file.additions != nil || file.deletions != nil { + Text("+\(file.additions ?? 0) −\(file.deletions ?? 0)").font(.caption).foregroundStyle(.secondary) + } + } + } + } + } + + private func refresh() async { + guard coordinator.connectionState == .connected else { + model.errorMessage = "Connect to Aiden Agent to use Git." + return + } + await withRemoteContext { client, context in + await model.refresh( + client: client, + workspaceId: workspace.id, + isCurrent: { coordinator.isCurrent(context) } + ) + } + } + + private func withRemoteContext( + _ operation: @MainActor (AidenRemoteClient, AidenRemoteRequestContext) async -> Void + ) async { + guard let context = try? coordinator.requestContext(), + let client = try? coordinator.remoteClient(for: context) else { return } + await operation(client, context) + } +} + +private extension AidenGitFileStatus { + var symbol: String { + switch self { + case .added: "A" + case .modified: "M" + case .deleted: "D" + case .renamed: "R" + case .untracked: "?" + case .conflicted: "U" + } + } + + var tint: Color { + switch self { + case .added, .untracked: .green + case .deleted, .conflicted: .red + case .modified, .renamed: .orange + } + } +} diff --git a/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceShellView.swift b/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceShellView.swift new file mode 100644 index 00000000..f9bcd3b3 --- /dev/null +++ b/ios/AidenOnTheGo/Features/Remote/AidenWorkspaceShellView.swift @@ -0,0 +1,2817 @@ +import SwiftUI + +enum AidenRelativeTimestamp { + static func text(for date: Date, now: Date = Date()) -> String { + let elapsed = max(0, now.timeIntervalSince(date)) + if elapsed < 60 { return "just now" } + + let minutes = Int(elapsed / 60) + if minutes < 60 { return minutes == 1 ? "1 min ago" : "\(minutes) mins ago" } + + let hours = Int(elapsed / 3_600) + if hours < 24 { return hours == 1 ? "1 hr ago" : "\(hours) hrs ago" } + + let days = Int(elapsed / 86_400) + return days == 1 ? "1 day ago" : "\(days) days ago" + } +} + +struct AidenRelativeTimestampView: View { + let date: Date + + var body: some View { + TimelineView(.periodic(from: .now, by: 30)) { context in + Text(AidenRelativeTimestamp.text(for: date, now: context.date)) + } + } +} + +private struct AidenLiquidGlassCapsuleModifier: ViewModifier { + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + let tint: Color + + @ViewBuilder + func body(content: Content) -> some View { + if #available(iOS 26, *), !reduceTransparency { + content.glassEffect(.regular.tint(tint).interactive(), in: Capsule()) + } else { + content.background(tint, in: Capsule()) + } + } +} + +private struct AidenChromeGlassModifier: ViewModifier { + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.aidenPalette) private var palette + + let isInteractive: Bool + let shape: GlassShape + + @ViewBuilder + func body(content: Content) -> some View { + if #available(iOS 26, *), !reduceTransparency { + if isInteractive { + content.glassEffect(.regular.interactive(), in: shape) + } else { + content.glassEffect(.regular, in: shape) + } + } else if reduceTransparency { + content + .background(palette.raised, in: shape) + .overlay(shape.stroke(palette.foreground.opacity(0.14), lineWidth: 0.5)) + } else { + content + .background(.ultraThinMaterial, in: shape) + .overlay(shape.stroke(palette.foreground.opacity(0.10), lineWidth: 0.5)) + } + } +} + +private struct AidenProminentGlassButtonModifier: ViewModifier { + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + @Environment(\.aidenPalette) private var palette + + @ViewBuilder + func body(content: Content) -> some View { + if #available(iOS 26, *), !reduceTransparency { + content.buttonStyle(.glass) + } else if reduceTransparency { + content + .buttonStyle(.plain) + .background(palette.raised, in: Capsule()) + .overlay(Capsule().stroke(palette.foreground.opacity(0.16), lineWidth: 0.5)) + } else { + content + .buttonStyle(.plain) + .background(.regularMaterial, in: Capsule()) + .overlay(Capsule().stroke(palette.foreground.opacity(0.10), lineWidth: 0.5)) + } + } +} + +private extension View { + func aidenLiquidGlassCapsule(tint: Color) -> some View { + modifier(AidenLiquidGlassCapsuleModifier(tint: tint)) + } + + func aidenChromeGlass( + isInteractive: Bool = false, + in shape: GlassShape + ) -> some View { + modifier(AidenChromeGlassModifier(isInteractive: isInteractive, shape: shape)) + } + + func aidenProminentGlassButton() -> some View { + modifier(AidenProminentGlassButtonModifier()) + } +} + +enum AidenNewAgentChoice: String, CaseIterable, Identifiable { + case existingWorkspace + case newWorkspace + case scratchWorkspace + + var id: String { rawValue } + + var title: String { + switch self { + case .existingWorkspace: "Existing Workspace" + case .newWorkspace: "New Workspace" + case .scratchWorkspace: "Managed Scratch Workspace" + } + } + + var detail: String { + switch self { + case .existingWorkspace: "Start another chat without creating a folder." + case .newWorkspace: "Create a reusable workspace for ongoing work." + case .scratchWorkspace: "Use an isolated workspace Aiden can clean up later." + } + } + + var symbol: String { + switch self { + case .existingWorkspace: "folder" + case .newWorkspace: "folder.badge.plus" + case .scratchWorkspace: "hammer.fill" + } + } +} + +@MainActor +@Observable +private final class AidenHomeModel { + var chats: [AidenChat] = [] + var scheduledTasks: [AidenScheduledTask] = [] + var usage: AidenUsageSummary? + var modelCatalog: AidenModelCatalog? + var isLoading = false + var errorMessage: String? + + func accept(_ chat: AidenChat) { + chats.removeAll { $0.id == chat.id } + chats.append(chat) + chats.sort { $0.updatedAt > $1.updatedAt } + } + + func load(coordinator: AidenRemoteCoordinator) async { + guard coordinator.connectionState == .connected, + let context = try? coordinator.requestContext(), + !isLoading else { return } + isLoading = true + errorMessage = nil + defer { isLoading = false } + do { + let client = try coordinator.remoteClient(for: context) + async let chatsRequest = client.chats() + async let tasksRequest = client.scheduledTasks() + async let catalogRequest: AidenModelCatalog? = try? await client.modelCatalog() + let (chats, tasks, catalog) = try await (chatsRequest, tasksRequest, catalogRequest) + guard coordinator.isCurrent(context) else { return } + self.chats = chats.sorted { $0.updatedAt > $1.updatedAt } + scheduledTasks = tasks.sorted { + ($0.nextRunAt ?? .distantFuture) < ($1.nextRunAt ?? .distantFuture) + } + if let catalog { modelCatalog = catalog } + let loadedUsage = try? await client.usage() + guard coordinator.isCurrent(context) else { return } + usage = loadedUsage + } catch { + if coordinator.isCurrent(context) { + errorMessage = error.localizedDescription + } + } + } +} + +private struct AidenNavigationResolutionID: Equatable { + let request: AidenNavigationRequest? + let connectionState: AidenRemoteConnectionState +} + +enum AidenWorkspaceNavigation { + static func reconciledSelection(current: String?, workspaceIDs: [String]) -> String? { + guard !workspaceIDs.isEmpty else { return nil } + if let current, workspaceIDs.contains(current) { return current } + return workspaceIDs.first + } + + static func reconciledCompactPath(current: [String], workspaceIDs: [String]) -> [String] { + guard let workspaceID = current.last, workspaceIDs.contains(workspaceID) else { return [] } + return [workspaceID] + } + + static func compactPath( + enteringFromSplit: Bool, + current: [String], + selectedWorkspaceID: String?, + workspaceIDs: [String] + ) -> [String] { + let current = reconciledCompactPath(current: current, workspaceIDs: workspaceIDs) + if !current.isEmpty { return current } + guard enteringFromSplit, + let selectedWorkspaceID, + workspaceIDs.contains(selectedWorkspaceID) else { return [] } + return [selectedWorkspaceID] + } +} + +@MainActor +@Observable +final class AidenWorkspaceArchiveStore { + private struct Snapshot: Codable { + var workspaceIDsByInstance: [String: [String]] + } + + private static let snapshotKey = "aiden.deviceArchivedWorkspaces.v1" + private static let disclosureKey = "aiden.deviceArchivedWorkspaces.disclosureSeen.v1" + + @ObservationIgnored private let defaults: UserDefaults + private var workspaceIDsByInstance: [String: Set] + private(set) var hasAcknowledgedDeviceOnlyArchive: Bool + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + hasAcknowledgedDeviceOnlyArchive = defaults.bool(forKey: Self.disclosureKey) + + if let data = defaults.data(forKey: Self.snapshotKey), + let snapshot = try? JSONDecoder().decode(Snapshot.self, from: data) { + workspaceIDsByInstance = snapshot.workspaceIDsByInstance.reduce(into: [:]) { result, entry in + result[entry.key] = Set(entry.value.filter { !$0.isEmpty }) + } + } else { + workspaceIDsByInstance = [:] + } + } + + func archivedWorkspaceIDs(for instanceID: String?) -> Set { + guard let instanceID, !instanceID.isEmpty else { return [] } + return workspaceIDsByInstance[instanceID] ?? [] + } + + func isArchived(workspaceID: String, instanceID: String?) -> Bool { + archivedWorkspaceIDs(for: instanceID).contains(workspaceID) + } + + func acknowledgeDeviceOnlyArchive() { + guard !hasAcknowledgedDeviceOnlyArchive else { return } + hasAcknowledgedDeviceOnlyArchive = true + defaults.set(true, forKey: Self.disclosureKey) + } + + func archive(workspaceID: String, instanceID: String?) { + guard let instanceID, !instanceID.isEmpty, !workspaceID.isEmpty else { return } + var workspaceIDs = workspaceIDsByInstance[instanceID] ?? [] + guard workspaceIDs.insert(workspaceID).inserted else { return } + workspaceIDsByInstance[instanceID] = workspaceIDs + persist() + } + + func unarchive(workspaceID: String, instanceID: String?) { + guard let instanceID, !instanceID.isEmpty, + var workspaceIDs = workspaceIDsByInstance[instanceID], + workspaceIDs.remove(workspaceID) != nil else { return } + if workspaceIDs.isEmpty { + workspaceIDsByInstance.removeValue(forKey: instanceID) + } else { + workspaceIDsByInstance[instanceID] = workspaceIDs + } + persist() + } + + func forget(workspaceID: String, instanceID: String?) { + unarchive(workspaceID: workspaceID, instanceID: instanceID) + } + + func purge(instanceID: String) { + guard workspaceIDsByInstance.removeValue(forKey: instanceID) != nil else { return } + persist() + } + + func prune(instanceID: String?, validWorkspaceIDs: Set) { + guard let instanceID, !instanceID.isEmpty, + let current = workspaceIDsByInstance[instanceID] else { return } + let pruned = current.intersection(validWorkspaceIDs) + guard pruned != current else { return } + if pruned.isEmpty { + workspaceIDsByInstance.removeValue(forKey: instanceID) + } else { + workspaceIDsByInstance[instanceID] = pruned + } + persist() + } + + private func persist() { + let snapshot = Snapshot( + workspaceIDsByInstance: workspaceIDsByInstance.mapValues { $0.sorted() } + ) + guard let data = try? JSONEncoder().encode(snapshot) else { return } + defaults.set(data, forKey: Self.snapshotKey) + } +} + +private struct AidenWorkspaceDefaultApplication { + let workspace: AidenWorkspace + let feedback: AidenHapticEvent +} + +struct AidenWorkspaceShellView: View { + @Bindable var coordinator: AidenRemoteCoordinator + @Binding private var navigationRequest: AidenNavigationRequest? + @Environment(AidenAppearanceStore.self) private var appearance + @Environment(\.aidenPalette) private var palette + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + @State private var selectedWorkspaceId: String? + @State private var compactWorkspacePath: [String] = [] + @State private var isShowingPairing = false + @State private var isShowingAppSettings = false + @State private var isShowingScheduledTasks = false + @State private var isShowingUsage = false + @State private var intentChat: AidenChat? + @State private var intentStartsVoice = false + @State private var homeModel = AidenHomeModel() + @State private var searchText = "" + @State private var isSearching = false + @State private var isShowingNewAgentChoices = false + @State private var isShowingExistingWorkspacePicker = false + @State private var isShowingNewAgentWorkspacePrompt = false + @State private var newAgentWorkspaceName = "" + @State private var isCreatingAgent = false + @State private var agentCreationStatus = "" + @State private var hapticScope = UUID() + @FocusState private var searchFieldIsFocused: Bool + @Namespace private var newAgentTransition + @AppStorage("aiden.defaults.workspacePermission") private var defaultWorkspacePermissionRaw = AidenWorkspacePermission.ask.rawValue + + init( + coordinator: AidenRemoteCoordinator, + navigationRequest: Binding = .constant(nil) + ) { + self.coordinator = coordinator + _navigationRequest = navigationRequest + } + + private var usesSplitNavigation: Bool { + horizontalSizeClass == .regular + } + + private var archivedWorkspaceIDs: Set { + workspaceArchiveStore.archivedWorkspaceIDs(for: coordinator.activeInstanceId) + } + + private var workspaceArchiveStore: AidenWorkspaceArchiveStore { + coordinator.workspaceArchiveStore + } + + private var activeWorkspaces: [AidenWorkspace] { + coordinator.workspaces.filter { !archivedWorkspaceIDs.contains($0.id) } + } + + private var activeWorkspaceIDs: [String] { + activeWorkspaces.map(\.id) + } + + var body: some View { + Group { + if usesSplitNavigation { + NavigationSplitView { + regularWorkspaceSidebar + .navigationSplitViewColumnWidth(min: 240, ideal: 300, max: 400) + } detail: { + NavigationStack { + workspaceDetail(workspaceID: selectedWorkspaceId) + } + } + .navigationSplitViewStyle(.balanced) + } else { + NavigationStack(path: $compactWorkspacePath) { + compactWorkspaceSidebar + .navigationDestination(for: String.self) { workspaceID in + workspaceDetail(workspaceID: workspaceID) + } + } + } + } + .safeAreaInset(edge: .top, spacing: 0) { + if case .offline(let message) = coordinator.connectionState { + AidenOfflineBanner(message: message) { + Task { await coordinator.connectActiveInstallation() } + } + } + } + .overlay { + if coordinator.connectionState == .connecting { + ProgressView("Connecting to Aiden Agent…") + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } else if isCreatingAgent { + HStack(spacing: 10) { + ProgressView().controlSize(.small) + Text(agentCreationStatus) + } + .font(.callout.weight(.medium)) + .padding(.horizontal, 16) + .frame(height: 48) + .foregroundStyle(palette.canvas) + .aidenLiquidGlassCapsule(tint: palette.foreground) + .accessibilityElement(children: .combine) + } + } + .sheet(isPresented: $isShowingPairing) { + AidenPairingView( + coordinator: coordinator, + onPaired: { isShowingPairing = false } + ) + } + .sheet(isPresented: $isShowingAppSettings) { + AidenAppSettingsView( + coordinator: coordinator, + appearance: appearance, + addInstallation: { + isShowingAppSettings = false + isShowingPairing = true + } + ) + } + .sheet(isPresented: $isShowingScheduledTasks) { + AidenScheduledTasksView(coordinator: coordinator) + } + .sheet(isPresented: $isShowingUsage) { + if let usage = homeModel.usage { + AidenUsageView(usage: usage, providers: homeModel.modelCatalog?.providers ?? []) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + } + .sheet(isPresented: $isShowingExistingWorkspacePicker) { + NavigationStack { + List(activeWorkspaces) { workspace in + Button { + isShowingExistingWorkspacePicker = false + Task { await createNewAgent(in: workspace) } + } label: { + AidenWorkspaceRow(workspace: workspace) + } + .buttonStyle(.plain) + } + .navigationTitle("Choose Workspace") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { isShowingExistingWorkspacePicker = false } + } + } + } + } + .sheet(item: $intentChat) { chat in + NavigationStack { + AidenChatDetailView( + coordinator: coordinator, + chat: chat, + autoStartVoice: intentStartsVoice, + onChatUpdated: { homeModel.accept($0) } + ) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { intentChat = nil } + } + } + } + } + .alert( + "Aiden On The Go", + isPresented: Binding( + get: { coordinator.presentedError != nil }, + set: { if !$0 { coordinator.presentedError = nil } } + ) + ) { + Button("OK", role: .cancel) { coordinator.presentedError = nil } + } message: { + Text(coordinator.presentedError ?? "The operation could not be completed.") + } + .alert("New Workspace", isPresented: $isShowingNewAgentWorkspacePrompt) { + TextField("Workspace name", text: $newAgentWorkspaceName) + Button("Cancel", role: .cancel) { newAgentWorkspaceName = "" } + Button("Create") { + let name = newAgentWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines) + newAgentWorkspaceName = "" + Task { await createNewAgent(inNewWorkspaceNamed: name) } + } + .disabled(newAgentWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } message: { + Text("Aiden will create a reusable workspace, then open a new chat in it.") + } + .onChange(of: coordinator.workspaces.map(\.id)) { _, _ in + syncArchivedWorkspaceProjection() + reconcileNavigation(workspaceIDs: activeWorkspaceIDs) + } + .onChange(of: coordinator.workspaceSnapshotRevision) { _, _ in + syncArchivedWorkspaceProjection() + reconcileNavigation(workspaceIDs: activeWorkspaceIDs) + } + .onChange(of: archivedWorkspaceIDs) { _, _ in + syncArchivedWorkspaceProjection() + reconcileNavigation(workspaceIDs: activeWorkspaceIDs) + } + .onChange(of: coordinator.activeInstanceId) { _, _ in + isShowingScheduledTasks = false + syncArchivedWorkspaceProjection() + reconcileNavigation(workspaceIDs: activeWorkspaceIDs) + } + .onChange(of: compactWorkspacePath) { _, path in + guard let workspaceID = path.last, + activeWorkspaceIDs.contains(workspaceID) else { return } + selectedWorkspaceId = workspaceID + } + .onChange(of: usesSplitNavigation) { wasSplit, isSplit in + let workspaceIDs = activeWorkspaceIDs + if isSplit { + if let compactWorkspaceID = compactWorkspacePath.last, + workspaceIDs.contains(compactWorkspaceID) { + selectedWorkspaceId = compactWorkspaceID + } + } else { + compactWorkspacePath = AidenWorkspaceNavigation.compactPath( + enteringFromSplit: wasSplit, + current: compactWorkspacePath, + selectedWorkspaceID: selectedWorkspaceId, + workspaceIDs: workspaceIDs + ) + } + } + .task { + syncArchivedWorkspaceProjection() + reconcileNavigation(workspaceIDs: activeWorkspaceIDs) + } + .task(id: AidenNavigationResolutionID( + request: navigationRequest, + connectionState: coordinator.connectionState + )) { + await resolveNavigationRequest() + } + .task(id: coordinator.connectionState) { + await homeModel.load(coordinator: coordinator) + } + .onAppear { coordinator.haptics.activate(scope: hapticScope) } + .onDisappear { coordinator.haptics.deactivate(scope: hapticScope) } + } + + private var regularWorkspaceSidebar: some View { + ZStack(alignment: .bottomTrailing) { + homeList { chat in + Button { intentChat = chat } label: { homeChatRow(chat) } + .buttonStyle(.plain) + } + if !isSearching { + newAgentButton + .padding(.trailing, 24) + .padding(.bottom, 22) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.smooth(duration: 0.24, extraBounce: 0), value: isSearching) + } + + private var compactWorkspaceSidebar: some View { + ZStack(alignment: .bottomTrailing) { + homeList { chat in + NavigationLink { + AidenChatDetailView( + coordinator: coordinator, + chat: chat, + onChatUpdated: { homeModel.accept($0) } + ) + } label: { + homeChatRow(chat) + } + } + if !isSearching { + newAgentButton + .padding(.trailing, 24) + .padding(.bottom, 22) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.smooth(duration: 0.24, extraBounce: 0), value: isSearching) + } + + private var filteredChats: [AidenChat] { + let activeWorkspaceIDSet = Set(activeWorkspaceIDs) + let visibleChats = homeModel.chats.filter { activeWorkspaceIDSet.contains($0.workspaceId) } + guard !searchText.isEmpty else { return visibleChats } + return visibleChats.filter { $0.title.localizedCaseInsensitiveContains(searchText) } + } + + private func homeList( + @ViewBuilder chatRow: @escaping (AidenChat) -> ChatRow + ) -> some View { + List { + homeHeader + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 18, trailing: 0)) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + + if !isSearching { + homeNavigationRows + .padding(.top, 10) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(palette.canvas) + } + + if !isSearching { + Text("Chats") + .font(.title3.bold()) + .foregroundStyle(palette.foreground) + .padding(.horizontal, 24) + .padding(.top, 26) + .padding(.bottom, 10) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(palette.canvas) + } + + if filteredChats.isEmpty, !homeModel.isLoading { + ContentUnavailableView( + searchText.isEmpty ? "No Chats Yet" : "No Matching Chats", + systemImage: searchText.isEmpty ? "bubble.left" : "magnifyingglass", + description: Text(searchText.isEmpty + ? "Start a new agent to create your first chat." + : "Try a different search term.") + ) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(palette.canvas) + } else { + ForEach(filteredChats) { chat in + chatRow(chat) + .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)) + .listRowSeparator(.hidden) + .listRowBackground(palette.canvas) + } + } + + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(palette.canvas.ignoresSafeArea()) + .contentMargins(.bottom, 104, for: .scrollContent) + .refreshable { + await coordinator.refreshWorkspaces() + await homeModel.load(coordinator: coordinator) + } + .overlay { if homeModel.isLoading && homeModel.chats.isEmpty { ProgressView() } } + } + + private var homeHeader: some View { + HStack(spacing: isSearching ? 0 : 16) { + Image("AidenAppIcon") + .resizable() + .scaledToFit() + .frame(width: isSearching ? 0 : 68, height: 68, alignment: .leading) + .opacity(isSearching ? 0 : 1) + .clipped() + .accessibilityElement(children: .ignore) + .accessibilityLabel("Aiden") + + searchChrome + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(.horizontal, 24) + .padding(.top, 28) + .animation(.smooth(duration: 0.24, extraBounce: 0), value: isSearching) + } + + private var searchChrome: some View { + HStack(spacing: isSearching ? 8 : 2) { + Button { + openSearch() + } label: { + Image(systemName: "magnifyingglass") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(isSearching ? palette.secondary : palette.foreground) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isSearching ? "Focus chat search" : "Search chats") + + if isSearching { + TextField("Search chats", text: $searchText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($searchFieldIsFocused) + .submitLabel(.done) + + Button { + closeSearch() + } label: { + Image(systemName: "xmark") + .font(.system(size: 21, weight: .medium)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Close search") + } else { + Button { isShowingAppSettings = true } label: { + Image(systemName: "person.crop.circle.fill") + .font(.system(size: 28, weight: .medium)) + .foregroundStyle(palette.accent) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Profile and app settings") + } + } + .padding(.vertical, 2) + .frame(maxWidth: isSearching ? .infinity : nil, alignment: .trailing) + .aidenChromeGlass(isInteractive: true, in: Capsule()) + .clipShape(Capsule()) + .contentShape(Capsule()) + } + + private var homeNavigationRows: some View { + VStack(alignment: .leading, spacing: 2) { + Button { isShowingScheduledTasks = true } label: { + homeNavigationRow( + title: "Scheduled Tasks", + systemImage: "calendar.badge.clock" + ) + } + .buttonStyle(.plain) + .disabled(coordinator.connectionState != .connected) + + Button { isShowingUsage = true } label: { + homeNavigationRow(title: "Usage", systemImage: "chart.bar.xaxis") + } + .buttonStyle(.plain) + .disabled(homeModel.usage == nil) + + NavigationLink { + AidenWorkspacesDirectoryView( + coordinator: coordinator, + archiveStore: workspaceArchiveStore + ) + } label: { + homeNavigationRow( + title: "Workspaces", + systemImage: "folder", + showsChevron: false + ) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 24) + } + + private func homeNavigationRow( + title: String, + systemImage: String, + showsChevron: Bool = true + ) -> some View { + HStack(spacing: 18) { + Image(systemName: systemImage) + .font(.system(size: 21, weight: .medium)) + .foregroundStyle(palette.accent) + .frame(width: 28) + + Text(title) + .font(.body.weight(.semibold)) + .foregroundStyle(palette.foreground) + .lineLimit(1) + + Spacer(minLength: 8) + + if showsChevron { + Image(systemName: "chevron.forward") + .font(.caption.weight(.semibold)) + .foregroundStyle(palette.secondary) + .frame(width: 24) + } + } + .frame(maxWidth: .infinity, minHeight: 48, alignment: .leading) + .contentShape(Rectangle()) + } + + private func homeChatRow(_ chat: AidenChat) -> some View { + HStack(alignment: .top, spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text(chat.title) + .font(.headline.weight(.semibold)) + .foregroundStyle(palette.foreground) + .lineLimit(2) + + if let workspace = coordinator.workspaces.first(where: { $0.id == chat.workspaceId }) { + Text(workspace.name) + .font(.caption) + .foregroundStyle(palette.secondary) + .lineLimit(1) + } + } + + Spacer(minLength: 8) + + AidenRelativeTimestampView(date: chat.updatedAt) + .font(.caption) + .foregroundStyle(palette.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(minHeight: 52) + .contentShape(Rectangle()) + } + + private func openSearch() { + if isSearching { + searchFieldIsFocused = true + return + } + + withAnimation(.smooth(duration: 0.24, extraBounce: 0)) { + isSearching = true + } + Task { @MainActor in + await Task.yield() + searchFieldIsFocused = true + } + } + + private func closeSearch() { + searchText = "" + searchFieldIsFocused = false + withAnimation(.smooth(duration: 0.24, extraBounce: 0)) { + isSearching = false + } + } + + private var newAgentButton: some View { + Button { isShowingNewAgentChoices = true } label: { + Image(systemName: "square.and.pencil") + .font(.title2.weight(.semibold)) + .foregroundStyle(palette.foreground) + .frame(width: 42, height: 50) + .contentShape(Rectangle()) + } + .aidenProminentGlassButton() + .disabled(coordinator.connectionState != .connected || coordinator.isMutating || isCreatingAgent) + .accessibilityLabel("New Agent") + .accessibilityHint("Choose where the new agent should work.") + .matchedTransitionSource(id: "AidenNewAgentOptions", in: newAgentTransition) + .popover(isPresented: $isShowingNewAgentChoices, arrowEdge: .bottom) { + AidenNewAgentPopover( + canUseExistingWorkspace: !activeWorkspaces.isEmpty, + onSelect: presentNewAgentChoice + ) + .frame(width: 320) + .presentationCompactAdaptation(.popover) + .navigationTransition(.zoom(sourceID: "AidenNewAgentOptions", in: newAgentTransition)) + } + } + + @MainActor + private func presentNewAgentChoice(_ choice: AidenNewAgentChoice) { + isShowingNewAgentChoices = false + Task { @MainActor in + await Task.yield() + switch choice { + case .existingWorkspace: + isShowingExistingWorkspacePicker = true + case .newWorkspace: + newAgentWorkspaceName = "" + isShowingNewAgentWorkspacePrompt = true + case .scratchWorkspace: + await createNewAgentInScratchWorkspace() + } + } + } + + @MainActor + private func createNewAgent(in workspace: AidenWorkspace) async { + await createNewAgent(workspace: workspace, status: "Opening agent…") + } + + @MainActor + private func createNewAgent(inNewWorkspaceNamed name: String) async { + guard !name.isEmpty else { return } + await createNewAgent( + workspaceCreate: .folderless(name: name), + status: "Creating workspace…" + ) + } + + @MainActor + private func createNewAgentInScratchWorkspace() async { + await createNewAgent(workspaceCreate: .scratch, status: "Preparing scratch workspace…") + } + + @MainActor + private func createNewAgent(workspaceCreate: AidenWorkspaceCreate, status: String) async { + guard !isCreatingAgent else { return } + isCreatingAgent = true + agentCreationStatus = status + defer { + isCreatingAgent = false + agentCreationStatus = "" + } + + let creationOutcome = await coordinator.createWorkspaceOutcome(workspaceCreate) + guard case .success(let created) = creationOutcome else { + if creationOutcome.isDefinitiveFailure { + coordinator.haptics.play(.error, scope: hapticScope) + } + return + } + let application = await applyGlobalDefaults(to: created) + await createNewAgent( + workspace: application.workspace, + status: "Opening agent…", + managesProgress: false, + completionFeedback: application.feedback + ) + } + + @MainActor + private func createNewAgent( + workspace: AidenWorkspace, + status: String, + managesProgress: Bool = true, + completionFeedback: AidenHapticEvent = .success + ) async { + guard !isCreatingAgent || !managesProgress else { return } + if managesProgress { + isCreatingAgent = true + agentCreationStatus = status + } else { + agentCreationStatus = status + } + defer { + if managesProgress { + isCreatingAgent = false + agentCreationStatus = "" + } + } + + let context: AidenRemoteRequestContext + do { + context = try coordinator.requestContext() + } catch { + coordinator.presentedError = error.localizedDescription + return + } + + do { + let chat = try await coordinator.remoteClient(for: context).createChat(workspaceId: workspace.id) + guard coordinator.isCurrent(context) else { return } + await homeModel.load(coordinator: coordinator) + guard coordinator.isCurrent(context) else { return } + navigate(to: workspace.id) + intentChat = chat + coordinator.haptics.play( + completionFeedback, + scope: hapticScope, + dedupeKey: "new-agent:\(workspace.id):\(chat.id):\(chat.revision)" + ) + } catch let error where aidenIsCancellation(error) { + return + } catch { + guard coordinator.isCurrent(context) else { return } + coordinator.presentedError = error.localizedDescription + coordinator.haptics.play(.error, scope: hapticScope) + await coordinator.refreshWorkspaces() + guard coordinator.isCurrent(context) else { return } + await homeModel.load(coordinator: coordinator) + } + } + + @MainActor + private func applyGlobalDefaults(to workspace: AidenWorkspace) async -> AidenWorkspaceDefaultApplication { + let permission = AidenWorkspacePermission(rawValue: defaultWorkspacePermissionRaw) ?? .ask + guard permission != workspace.permission else { + return AidenWorkspaceDefaultApplication(workspace: workspace, feedback: .success) + } + guard let updated = await coordinator.updateWorkspace(workspace, permission: permission) else { + return AidenWorkspaceDefaultApplication(workspace: workspace, feedback: .warning) + } + return AidenWorkspaceDefaultApplication(workspace: updated, feedback: .success) + } + + @ViewBuilder + private func workspaceDetail(workspaceID: String?) -> some View { + if let workspaceID, + !archivedWorkspaceIDs.contains(workspaceID), + let workspace = coordinator.workspaces.first(where: { $0.id == workspaceID }) { + AidenWorkspaceDetailView( + coordinator: coordinator, + workspace: workspace, + onRemoved: { removeWorkspaceFromNavigation(workspaceID) } + ) + .id(workspace.revision) + } else { + ContentUnavailableView( + "Choose a Workspace", + systemImage: "bubble.left.and.bubble.right", + description: Text("Select a workspace to see its chats and settings.") + ) + } + } + + private func navigate(to workspaceID: String) { + guard activeWorkspaceIDs.contains(workspaceID) else { return } + selectedWorkspaceId = workspaceID + if !usesSplitNavigation { + compactWorkspacePath = [workspaceID] + } + } + + private func removeWorkspaceFromNavigation(_ workspaceID: String) { + if selectedWorkspaceId == workspaceID { + selectedWorkspaceId = nil + } + compactWorkspacePath.removeAll { $0 == workspaceID } + } + + private func reconcileNavigation(workspaceIDs: [String]) { + selectedWorkspaceId = AidenWorkspaceNavigation.reconciledSelection( + current: selectedWorkspaceId, + workspaceIDs: workspaceIDs + ) + compactWorkspacePath = AidenWorkspaceNavigation.reconciledCompactPath( + current: compactWorkspacePath, + workspaceIDs: workspaceIDs + ) + } + + private func syncArchivedWorkspaceProjection() { + coordinator.setDeviceArchivedWorkspaceIDs( + archivedWorkspaceIDs, + for: coordinator.activeInstanceId + ) + } + + @MainActor + private func resolveNavigationRequest() async { + guard let request = navigationRequest, + coordinator.connectionState == .connected else { return } + defer { navigationRequest = nil } + guard request.instanceId == nil || request.instanceId == coordinator.activeInstanceId else { + coordinator.presentedError = String(localized: "The requested Aiden installation is not active.") + return + } + + let context: AidenRemoteRequestContext + do { + context = try coordinator.requestContext(for: request.instanceId) + } catch { + coordinator.presentedError = error.localizedDescription + return + } + do { + let chat: AidenChat + switch request.destination { + case .newChat: + let workspaceId = request.workspaceId ?? selectedWorkspaceId ?? activeWorkspaces.first?.id + guard let workspaceId, + activeWorkspaceIDs.contains(workspaceId) else { + if let requestedWorkspaceID = request.workspaceId, + archivedWorkspaceIDs.contains(requestedWorkspaceID) { + coordinator.presentedError = String(localized: "That workspace is archived on this device. Unarchive it from Workspaces to start a chat.") + return + } + coordinator.presentedError = String(localized: "The requested workspace is unavailable. Choose or add a workspace first.") + return + } + chat = try await coordinator.remoteClient(for: context).createChat(workspaceId: workspaceId) + guard coordinator.isCurrent(context) else { return } + navigate(to: workspaceId) + case .chat(let chatId): + chat = try await coordinator.remoteClient(for: context).chat(id: chatId) + guard coordinator.isCurrent(context) else { return } + guard activeWorkspaceIDs.contains(chat.workspaceId) else { + if archivedWorkspaceIDs.contains(chat.workspaceId) { + coordinator.presentedError = String(localized: "That chat belongs to a workspace archived on this device. Unarchive it from Workspaces to open the chat.") + return + } + coordinator.presentedError = String(localized: "The chat's workspace is no longer available.") + return + } + navigate(to: chat.workspaceId) + } + guard coordinator.isCurrent(context) else { return } + intentStartsVoice = request.startsVoice + intentChat = chat + } catch { + guard coordinator.isCurrent(context) else { return } + coordinator.presentedError = error.localizedDescription + } + } +} + +private struct AidenNewAgentPopover: View { + @Environment(\.aidenPalette) private var palette + + let canUseExistingWorkspace: Bool + let onSelect: (AidenNewAgentChoice) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 5) { + Text("New Agent") + .font(.title3.bold()) + .foregroundStyle(palette.foreground) + + Text("Choose where Aiden should work.") + .font(.subheadline) + .foregroundStyle(palette.secondary) + } + .padding(.horizontal, 18) + .padding(.top, 18) + .padding(.bottom, 10) + + ForEach(Array(AidenNewAgentChoice.allCases.enumerated()), id: \.element.id) { index, choice in + Button { + onSelect(choice) + } label: { + HStack(alignment: .center, spacing: 13) { + Image(systemName: choice.symbol) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(palette.accent) + .frame(width: 38, height: 38) + .background(palette.accent.opacity(0.12), in: Circle()) + + VStack(alignment: .leading, spacing: 3) { + Text(choice.title) + .font(.body.weight(.semibold)) + .foregroundStyle(palette.foreground) + .lineLimit(1) + + Text(detail(for: choice)) + .font(.caption) + .foregroundStyle(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 6) + + Image(systemName: "chevron.forward") + .font(.caption.weight(.semibold)) + .foregroundStyle(palette.secondary) + } + .padding(.horizontal, 18) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(choice == .existingWorkspace && !canUseExistingWorkspace) + .opacity(choice == .existingWorkspace && !canUseExistingWorkspace ? 0.45 : 1) + .accessibilityHint(detail(for: choice)) + + if index < AidenNewAgentChoice.allCases.count - 1 { + Divider() + .overlay(palette.secondary.opacity(0.18)) + .padding(.leading, 69) + } + } + } + .padding(.bottom, 8) + .background(palette.canvas.opacity(0.001)) + .accessibilityElement(children: .contain) + } + + private func detail(for choice: AidenNewAgentChoice) -> String { + if choice == .existingWorkspace, !canUseExistingWorkspace { + return String(localized: "Add a workspace before using this option.") + } + return choice.detail + } +} + +private struct AidenWorkspacesDirectoryView: View { + @Bindable var coordinator: AidenRemoteCoordinator + @Bindable var archiveStore: AidenWorkspaceArchiveStore + @State private var searchText = "" + @State private var isShowingFolderBrowser = false + @State private var isShowingNewWorkspace = false + @State private var isConfirmingScratch = false + @State private var newWorkspaceName = "" + @State private var workspacePendingRename: AidenWorkspace? + @State private var workspacePendingFirstArchive: AidenWorkspace? + @State private var workspacePendingRemoval: AidenWorkspace? + @State private var renameText = "" + @State private var retainedRenameDraftWorkspaceID: String? + @State private var hapticScope = UUID() + @AppStorage("aiden.defaults.workspacePermission") private var defaultWorkspacePermissionRaw = AidenWorkspacePermission.ask.rawValue + + private var archivedWorkspaceIDs: Set { + archiveStore.archivedWorkspaceIDs(for: coordinator.activeInstanceId) + } + + private var activeWorkspaces: [AidenWorkspace] { + coordinator.workspaces.filter { !archivedWorkspaceIDs.contains($0.id) } + } + + private var archivedWorkspaces: [AidenWorkspace] { + coordinator.workspaces.filter { archivedWorkspaceIDs.contains($0.id) } + } + + private var filteredWorkspaces: [AidenWorkspace] { + guard !searchText.isEmpty else { return activeWorkspaces } + return activeWorkspaces.filter { workspace in + workspace.name.localizedCaseInsensitiveContains(searchText) + || workspace.repositoryName?.localizedCaseInsensitiveContains(searchText) == true + || workspace.branchName?.localizedCaseInsensitiveContains(searchText) == true + } + } + + var body: some View { + List { + if filteredWorkspaces.isEmpty { + ContentUnavailableView( + 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." + : "Try a different search term.") + ) + .listRowBackground(Color.clear) + } else { + ForEach(filteredWorkspaces) { workspace in + interactiveRow(workspace, isArchived: false) + } + } + + if searchText.isEmpty { + Section { + NavigationLink { + AidenArchivedWorkspacesView( + coordinator: coordinator, + workspaces: archivedWorkspaces, + row: interactiveRow + ) + } label: { + Label("Archived Workspaces", systemImage: "archivebox") + } + } footer: { + Text("Archived workspaces and their chats are hidden only on this device.") + } + } + } + .listStyle(.plain) + .navigationTitle("Workspaces") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: "Search workspaces") + .refreshable { + await coordinator.refreshWorkspaces() + } + .onAppear { coordinator.haptics.activate(scope: hapticScope) } + .onDisappear { coordinator.haptics.deactivate(scope: hapticScope) } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Menu { + Button { + newWorkspaceName = "" + isShowingNewWorkspace = true + } label: { + Label("New Workspace", systemImage: "folder.badge.plus") + } + + Button { isConfirmingScratch = true } label: { + Label("New Managed Scratch", systemImage: "hammer") + } + + Button { isShowingFolderBrowser = true } label: { + Label("Add Mac Folder", systemImage: "folder.badge.plus") + } + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Add workspace") + } + } + .sheet(isPresented: $isShowingFolderBrowser) { + AidenFolderBrowserView(coordinator: coordinator) { workspace in + Task { + let application = await applyGlobalDefault(to: workspace) + coordinator.haptics.play( + application.feedback, + scope: hapticScope, + dedupeKey: "workspace-folder-add:\(workspace.id):\(workspace.revision)" + ) + isShowingFolderBrowser = false + } + } + } + .alert("New Workspace", isPresented: $isShowingNewWorkspace) { + TextField("Workspace name", text: $newWorkspaceName) + Button("Cancel", role: .cancel) {} + Button("Create") { + let name = newWorkspaceName.trimmingCharacters(in: .whitespacesAndNewlines) + Task { + let outcome = await coordinator.createWorkspaceOutcome(.folderless(name: name)) + if case .success(let workspace) = outcome { + let application = await applyGlobalDefault(to: workspace) + coordinator.haptics.play(application.feedback, scope: hapticScope, dedupeKey: "workspace-create:\(workspace.id):\(workspace.revision)") + } else if outcome.isDefinitiveFailure { + coordinator.haptics.play(.error, scope: hapticScope) + } + } + } + .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.") + } + .confirmationDialog( + "Create a managed scratch workspace?", + isPresented: $isConfirmingScratch, + titleVisibility: .visible + ) { + Button("Create Managed Scratch") { + Task { + let outcome = await coordinator.createWorkspaceOutcome(.scratch) + if case .success(let workspace) = outcome { + let application = await applyGlobalDefault(to: workspace) + coordinator.haptics.play(application.feedback, scope: hapticScope, dedupeKey: "workspace-create:\(workspace.id):\(workspace.revision)") + } else if outcome.isDefinitiveFailure { + coordinator.haptics.play(.error, scope: hapticScope) + } + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Aiden Agent will create and manage the worktree on your Mac.") + } + .alert( + "Rename Workspace", + isPresented: Binding( + get: { workspacePendingRename != nil }, + set: { if !$0 { workspacePendingRename = nil } } + ) + ) { + TextField("Workspace name", text: $renameText) + Button("Cancel", role: .cancel) { + retainedRenameDraftWorkspaceID = nil + workspacePendingRename = nil + } + Button("Rename") { + guard let workspace = workspacePendingRename else { return } + let trimmedName = renameText.trimmingCharacters(in: .whitespacesAndNewlines) + Task { + let outcome = await coordinator.updateWorkspaceOutcome(workspace, name: trimmedName) + if case .success = outcome { + retainedRenameDraftWorkspaceID = nil + coordinator.haptics.play(.success, scope: hapticScope, dedupeKey: "workspace-rename:\(workspace.id):\(trimmedName)") + } else if outcome.isDefinitiveFailure { + coordinator.haptics.play(.error, scope: hapticScope) + } + workspacePendingRename = nil + } + } + .disabled( + renameText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || renameText.trimmingCharacters(in: .whitespacesAndNewlines) == workspacePendingRename?.name + || coordinator.connectionState != .connected + || 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.") + } + .alert( + "Archive on This Device?", + isPresented: Binding( + get: { workspacePendingFirstArchive != nil }, + set: { if !$0 { workspacePendingFirstArchive = nil } } + ) + ) { + Button("Cancel", role: .cancel) { workspacePendingFirstArchive = nil } + Button("Archive on This Device") { + guard let workspace = workspacePendingFirstArchive else { return } + archiveStore.acknowledgeDeviceOnlyArchive() + archiveStore.archive( + workspaceID: workspace.id, + instanceID: coordinator.activeInstanceId + ) + 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.") + } + .alert( + "Remove from Aiden Agent?", + isPresented: Binding( + get: { workspacePendingRemoval != nil }, + set: { if !$0 { workspacePendingRemoval = nil } } + ) + ) { + Button("Cancel", role: .cancel) { workspacePendingRemoval = nil } + Button("Remove from Aiden Agent", role: .destructive) { + guard let workspace = workspacePendingRemoval else { return } + workspacePendingRemoval = nil + Task { + let outcome = await coordinator.removeWorkspaceOutcome(workspace) + if case .success = outcome { + archiveStore.forget( + workspaceID: workspace.id, + instanceID: coordinator.activeInstanceId + ) + coordinator.haptics.play(.success, scope: hapticScope, dedupeKey: "workspace-remove:\(workspace.id):\(workspace.revision)") + } else if outcome.isDefinitiveFailure { + coordinator.haptics.play(.error, scope: hapticScope) + } + } + } + .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.") + } + } + + private func interactiveRow(_ workspace: AidenWorkspace, isArchived: Bool) -> some View { + AidenWorkspaceInteractiveRow( + coordinator: coordinator, + workspace: workspace, + isArchived: isArchived, + onRename: { requestRename(workspace) }, + onToggleArchive: { toggleArchive(workspace, isArchived: isArchived) }, + onRemove: workspace.isManagedWorktree || coordinator.workspaces.count <= 1 + ? nil + : { workspacePendingRemoval = workspace } + ) + } + + private func requestRename(_ workspace: AidenWorkspace) { + if retainedRenameDraftWorkspaceID != workspace.id { + renameText = workspace.name + } + retainedRenameDraftWorkspaceID = workspace.id + workspacePendingRename = workspace + } + + private func toggleArchive(_ workspace: AidenWorkspace, isArchived: Bool) { + if isArchived { + archiveStore.unarchive( + workspaceID: workspace.id, + instanceID: coordinator.activeInstanceId + ) + } else if archiveStore.hasAcknowledgedDeviceOnlyArchive { + archiveStore.archive( + workspaceID: workspace.id, + instanceID: coordinator.activeInstanceId + ) + } else { + workspacePendingFirstArchive = workspace + } + } + + @MainActor + private func applyGlobalDefault(to workspace: AidenWorkspace) async -> AidenWorkspaceDefaultApplication { + let permission = AidenWorkspacePermission(rawValue: defaultWorkspacePermissionRaw) ?? .ask + guard permission != workspace.permission else { + return AidenWorkspaceDefaultApplication(workspace: workspace, feedback: .success) + } + guard let updated = await coordinator.updateWorkspace(workspace, permission: permission) else { + return AidenWorkspaceDefaultApplication(workspace: workspace, feedback: .warning) + } + return AidenWorkspaceDefaultApplication(workspace: updated, feedback: .success) + } +} + +private struct AidenArchivedWorkspacesView: View { + @Bindable var coordinator: AidenRemoteCoordinator + let workspaces: [AidenWorkspace] + @ViewBuilder let row: (AidenWorkspace, Bool) -> Row + @State private var searchText = "" + + private var filteredWorkspaces: [AidenWorkspace] { + guard !searchText.isEmpty else { return workspaces } + return workspaces.filter { workspace in + workspace.name.localizedCaseInsensitiveContains(searchText) + || workspace.repositoryName?.localizedCaseInsensitiveContains(searchText) == true + || workspace.branchName?.localizedCaseInsensitiveContains(searchText) == true + } + } + + var body: some View { + List { + if filteredWorkspaces.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "No Archived Workspaces" : "No Matching Workspaces", + systemImage: searchText.isEmpty ? "archivebox" : "magnifyingglass", + description: Text(searchText.isEmpty + ? "Workspaces archived on this device appear here." + : "Try a different search term.") + ) + .listRowBackground(Color.clear) + } else { + ForEach(filteredWorkspaces) { workspace in + row(workspace, true) + } + } + } + .listStyle(.plain) + .navigationTitle("Archived Workspaces") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $searchText, prompt: "Search archived workspaces") + .refreshable { await coordinator.refreshWorkspaces() } + } +} + +private struct AidenWorkspaceInteractiveRow: View { + @Environment(\.aidenPalette) private var palette + @Bindable var coordinator: AidenRemoteCoordinator + let workspace: AidenWorkspace + let isArchived: Bool + let onRename: () -> Void + let onToggleArchive: () -> Void + let onRemove: (() -> Void)? + + private var serverMutationDisabled: Bool { + coordinator.connectionState != .connected || coordinator.isMutating + } + + var body: some View { + Group { + if isArchived { + Button(action: onToggleArchive) { + AidenWorkspaceRow(workspace: workspace) + } + .buttonStyle(.plain) + .accessibilityHint("Unarchives this workspace on this device.") + } else { + NavigationLink { + AidenDirectoryWorkspaceDetail( + coordinator: coordinator, + workspace: workspace + ) + } label: { + AidenWorkspaceRow(workspace: workspace) + } + } + } + .swipeActions(edge: .leading, allowsFullSwipe: false) { + Button(action: onRename) { + Label("Rename", systemImage: "pencil") + } + .disabled(serverMutationDisabled) + .tint(palette.accent) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if let onRemove { + Button(role: .destructive, action: onRemove) { + Label("Remove from Agent", systemImage: "minus.circle") + } + .disabled(serverMutationDisabled) + .tint(palette.danger) + } + + Button(action: onToggleArchive) { + Label( + isArchived ? "Unarchive" : "Archive", + systemImage: isArchived ? "tray.and.arrow.up" : "archivebox" + ) + } + .tint(palette.warning) + } + .contextMenu { + Button(action: onRename) { + Label("Rename", systemImage: "pencil") + } + .disabled(serverMutationDisabled) + + Button(action: onToggleArchive) { + Label( + isArchived ? "Unarchive on This Device" : "Archive on This Device", + systemImage: isArchived ? "tray.and.arrow.up" : "archivebox" + ) + } + + if let onRemove { + Divider() + Button(role: .destructive, action: onRemove) { + Label("Remove from Aiden Agent", systemImage: "minus.circle") + } + .disabled(serverMutationDisabled) + } + } + .accessibilityAction(named: Text("Rename workspace"), onRename) + .accessibilityAction( + named: Text(isArchived ? "Unarchive on this device" : "Archive on this device"), + onToggleArchive + ) + } +} + +private struct AidenDirectoryWorkspaceDetail: View { + @Environment(\.dismiss) private var dismiss + @Bindable var coordinator: AidenRemoteCoordinator + let workspace: AidenWorkspace + + var body: some View { + AidenWorkspaceDetailView( + coordinator: coordinator, + workspace: workspace, + onRemoved: { dismiss() } + ) + .id(workspace.revision) + } +} + +private struct AidenWorkspaceRow: View { + let workspace: AidenWorkspace + + var body: some View { + HStack(spacing: 12) { + Image(systemName: workspace.isManagedWorktree ? "hammer.fill" : workspace.hasFolder ? "folder.fill" : "folder") + .foregroundStyle(.tint) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 3) { + Text(workspace.name) + .lineLimit(1) + HStack(spacing: 5) { + Text(workspace.permission.title) + if let branch = workspace.branchName ?? workspace.git?.branch { + Text("·") + Text(branch) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .accessibilityElement(children: .combine) + } +} + +private struct AidenWorkspaceDetailView: View { + @Bindable var coordinator: AidenRemoteCoordinator + let workspace: AidenWorkspace + let onRemoved: () -> Void + + @State private var isShowingSettings = false + + var body: some View { + AidenWorkspaceChatsView(coordinator: coordinator, workspace: workspace) + .navigationTitle(workspace.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Menu { + Button { + isShowingSettings = true + } label: { + Label("Workspace Settings", systemImage: "gearshape") + } + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("Workspace menu") + } + } + .sheet(isPresented: $isShowingSettings) { + AidenWorkspaceSettingsView( + coordinator: coordinator, + workspace: workspace, + onRemoved: { + isShowingSettings = false + onRemoved() + } + ) + } + } +} + +private struct AidenWorkspaceSettingsView: View { + @Environment(\.dismiss) private var dismiss + @Bindable var coordinator: AidenRemoteCoordinator + let workspace: AidenWorkspace + let onRemoved: () -> Void + + @State private var name: String + @State private var permission: AidenWorkspacePermission + @State private var isConfirmingRemoval = false + @State private var hapticScope = UUID() + + init( + coordinator: AidenRemoteCoordinator, + workspace: AidenWorkspace, + onRemoved: @escaping () -> Void + ) { + self.coordinator = coordinator + self.workspace = workspace + self.onRemoved = onRemoved + _name = State(initialValue: workspace.name) + _permission = State(initialValue: workspace.permission) + } + + private var hasChanges: Bool { + name.trimmingCharacters(in: .whitespacesAndNewlines) != workspace.name || permission != workspace.permission + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Name", text: $name) + LabeledContent("Folder", value: workspace.hasFolder ? "Connected" : "None") + LabeledContent("Managed worktree", value: workspace.isManagedWorktree ? "Yes" : "No") + if let repository = workspace.repositoryName { + LabeledContent("Repository", value: repository) + } + } header: { + Text("Workspace") + } footer: { + Text("Renaming updates Aiden Agent and paired clients. It does not rename the folder on disk.") + } + + Section { + Picker("Permission", selection: $permission) { + ForEach(AidenWorkspacePermission.allCases, id: \.self) { permission in + Text(permission.title).tag(permission) + } + } + } header: { + Text("Permission") + } footer: { + Text("\(permission.detail) This setting overrides the app default for this workspace.") + } + + Section("Workspace tools") { + NavigationLink { + AidenWorkspaceFilesView(coordinator: coordinator, workspace: workspace) + } label: { + Label("Files", systemImage: "doc.text") + } + .disabled(!workspace.hasFolder) + + NavigationLink { + AidenWorkspaceGitView(coordinator: coordinator, workspace: workspace) + } label: { + Label("Git", systemImage: "arrow.triangle.branch") + } + .disabled(!workspace.hasFolder) + } + + if workspace.isManagedWorktree || coordinator.workspaces.count > 1 { + Section { + Button( + workspace.isManagedWorktree ? "Delete Managed Worktree" : "Remove from Aiden Agent", + role: .destructive + ) { + isConfirmingRemoval = true + } + } 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.") + } + } + } + .navigationTitle("Workspace Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + Task { + let outcome = await coordinator.updateWorkspaceOutcome( + workspace, + name: trimmedName == workspace.name ? nil : trimmedName, + permission: permission == workspace.permission ? nil : permission + ) + if case .success = outcome { + coordinator.haptics.play( + .success, + scope: hapticScope, + dedupeKey: "workspace-settings:\(workspace.id):\(workspace.revision)" + ) + dismiss() + } else if outcome.isDefinitiveFailure { + coordinator.haptics.play(.error, scope: hapticScope) + } + } + } + .disabled(!hasChanges || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || coordinator.isMutating) + } + } + .confirmationDialog( + workspace.isManagedWorktree ? "Delete \(workspace.name)?" : "Remove \(workspace.name) from Aiden Agent?", + isPresented: $isConfirmingRemoval, + titleVisibility: .visible + ) { + Button(workspace.isManagedWorktree ? "Delete Managed Worktree" : "Remove from Aiden Agent", role: .destructive) { + Task { + let outcome = workspace.isManagedWorktree + ? await coordinator.removeManagedWorktreeOutcome(workspace) + : await coordinator.removeWorkspaceOutcome(workspace) + if case .success = outcome { + coordinator.haptics.play( + .success, + scope: hapticScope, + dedupeKey: "workspace-remove:\(workspace.id):\(workspace.revision)" + ) + onRemoved() + } else if outcome.isDefinitiveFailure { + coordinator.haptics.play(.error, scope: hapticScope) + } + } + } + Button("Cancel", role: .cancel) {} + } 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.") + } + } + .onAppear { coordinator.haptics.activate(scope: hapticScope) } + .onDisappear { coordinator.haptics.deactivate(scope: hapticScope) } + } +} + +private struct AidenUsageView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.aidenPalette) private var palette + let usage: AidenUsageSummary + let providers: [AidenProvider] + + private var heatmapDays: [AidenUsageHeatmapDay] { + AidenUsagePresentation.heatmapDays(for: usage) + } + + private var maximumDailyTokens: Int { + max(heatmapDays.map(\.tokens).max() ?? 0, 1) + } + + private var completionRate: Double { + AidenUsagePresentation.ratio( + usage.totals.completedRequests, + of: usage.totals.requests + ) + } + + private var localRequestShare: Double { + AidenUsagePresentation.ratio( + usage.totals.localRequests, + of: usage.totals.requests + ) + } + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading, spacing: 28) { + usageHero + overviewGrid + tokenActivitySection + activityInsightsSection + modelSection + privacyNote + } + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 36) + } + .scrollContentBackground(.hidden) + .background(palette.canvas.ignoresSafeArea()) + .navigationTitle("Usage") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } + + private var usageHero: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Your Activity") + .font(.title2.weight(.bold)) + .foregroundStyle(palette.foreground) + + Text(AidenUsagePresentation.dateRangeText(for: usage)) + .font(.subheadline) + .foregroundStyle(palette.secondary) + } + .accessibilityElement(children: .combine) + } + + private var overviewGrid: some View { + VStack(spacing: 12) { + LazyVGrid( + columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], + spacing: 12 + ) { + metricCard( + value: usage.totals.requests.formatted(), + label: "Requests", + symbol: "bolt.fill" + ) + metricCard( + value: usage.totals.activeDays.formatted(), + label: "Active days", + symbol: "calendar" + ) + metricCard( + value: AidenUsagePresentation.dayCount(usage.totals.currentStreak), + label: "Current streak", + symbol: "flame" + ) + metricCard( + value: AidenUsagePresentation.dayCount(usage.totals.longestStreak), + label: "Longest streak", + symbol: "trophy" + ) + } + + HStack(alignment: .center, spacing: 14) { + Image(systemName: "circle.hexagongrid.fill") + .font(.title2) + .foregroundStyle(palette.accent) + .frame(width: 34, height: 34) + + VStack(alignment: .leading, spacing: 3) { + Text(AidenUsagePresentation.tokenCount(usage.totals.tokens.total)) + .font(.title2.weight(.bold).monospacedDigit()) + .foregroundStyle(palette.foreground) + .contentTransition(.numericText()) + .lineLimit(1) + .minimumScaleFactor(0.65) + .allowsTightening(true) + .layoutPriority(1) + Text("Total tokens") + .font(.subheadline) + .foregroundStyle(palette.secondary) + } + Spacer(minLength: 0) + } + .padding(18) + .frame(maxWidth: .infinity, minHeight: 92, alignment: .leading) + .aidenUsageCard(palette: palette) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(usage.totals.tokens.total.formatted()) total tokens") + } + } + + private func metricCard(value: String, label: String, symbol: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Image(systemName: symbol) + .font(.body.weight(.semibold)) + .foregroundStyle(palette.accent) + .frame(width: 30, height: 30) + .background(palette.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + + VStack(alignment: .leading, spacing: 2) { + Text(value) + .font(.title3.weight(.bold)) + .foregroundStyle(palette.foreground) + .contentTransition(.numericText()) + Text(label) + .font(.subheadline) + .foregroundStyle(palette.secondary) + } + } + .padding(16) + .frame(maxWidth: .infinity, minHeight: 118, alignment: .leading) + .aidenUsageCard(palette: palette) + .accessibilityElement(children: .combine) + } + + private var tokenActivitySection: some View { + usageSection(title: "Token activity") { + VStack(alignment: .leading, spacing: 18) { + HStack { + Text("Daily totals") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(palette.foreground) + Spacer() + Text("Last 30 days") + .font(.caption.weight(.medium)) + .foregroundStyle(palette.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(palette.sidebar, in: Capsule()) + } + + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 10), + spacing: 6 + ) { + ForEach(heatmapDays) { day in + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(activityColor(tokens: day.tokens)) + .aspectRatio(1, contentMode: .fit) + .accessibilityElement() + .accessibilityLabel("\(day.date), \(day.tokens.formatted()) tokens") + } + } + + HStack(spacing: 6) { + Text("Less") + ForEach(0..<5, id: \.self) { level in + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(activityColor(level: level)) + .frame(width: 14, height: 14) + } + Text("More") + } + .font(.caption2) + .foregroundStyle(palette.secondary) + + Divider().overlay(palette.secondary.opacity(0.18)) + + tokenBreakdown + } + .padding(18) + .aidenUsageCard(palette: palette) + } + } + + private var tokenBreakdown: some View { + VStack(spacing: 14) { + usageValueRow("Input", value: usage.totals.tokens.input.formatted(), color: palette.accent) + usageValueRow("Output", value: usage.totals.tokens.output.formatted(), color: palette.success) + usageValueRow("Reasoning", value: usage.totals.tokens.reasoning.formatted(), color: palette.warning) + usageValueRow("Cache read", value: usage.totals.tokens.cacheRead.formatted(), color: palette.secondary) + } + } + + private var activityInsightsSection: some View { + usageSection(title: "Activity insights") { + VStack(spacing: 0) { + insightRow("Completed requests", value: completionRate.formatted(.percent.precision(.fractionLength(0)))) + insightDivider + insightRow("Local model share", value: localRequestShare.formatted(.percent.precision(.fractionLength(0)))) + insightDivider + insightRow("Failed requests", value: usage.totals.failedRequests.formatted()) + insightDivider + insightRow( + "Hosted cost", + value: usage.totals.hostedCostUsd.formatted(.currency(code: "USD")) + ) + } + .padding(.horizontal, 18) + .aidenUsageCard(palette: palette) + } + } + + @ViewBuilder + private var modelSection: some View { + if !usage.models.isEmpty { + usageSection(title: "Most used models") { + VStack(spacing: 0) { + ForEach(Array(usage.models.prefix(5).enumerated()), id: \.element.id) { index, model in + HStack(spacing: 12) { + AidenProviderIcon( + providerID: model.providerId, + providerLabel: model.providerLabel, + modelID: model.modelId, + artwork: providers.first { $0.id == model.providerId }?.artwork, + size: 20, + color: palette.accent + ) + .frame(width: 34, height: 34) + .background(palette.sidebar, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + + VStack(alignment: .leading, spacing: 2) { + Text(model.modelLabel) + .font(.body.weight(.semibold)) + .foregroundStyle(palette.foreground) + .lineLimit(1) + Text(model.local ? "\(model.providerLabel) · Local" : model.providerLabel) + .font(.caption) + .foregroundStyle(palette.secondary) + .lineLimit(1) + } + + Spacer(minLength: 8) + + Text("\(model.requests.formatted()) runs") + .font(.subheadline) + .foregroundStyle(palette.secondary) + .fixedSize(horizontal: true, vertical: false) + } + .padding(.vertical, 14) + + if index < min(usage.models.count, 5) - 1 { + insightDivider + } + } + } + .padding(.horizontal, 18) + .aidenUsageCard(palette: palette) + } + } + } + + private var privacyNote: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "lock.shield") + .font(.body.weight(.semibold)) + .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.") + .font(.footnote) + .foregroundStyle(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(palette.accent.opacity(0.08), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .accessibilityElement(children: .combine) + } + + private func usageSection( + title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(palette.secondary) + .padding(.leading, 4) + content() + } + } + + private func usageValueRow(_ label: String, value: String, color: Color) -> some View { + HStack(spacing: 10) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + Text(label) + .foregroundStyle(palette.foreground) + Spacer() + Text(value) + .foregroundStyle(palette.secondary) + .monospacedDigit() + } + .font(.subheadline) + .accessibilityElement(children: .combine) + } + + private func insightRow(_ label: String, value: String) -> some View { + HStack(spacing: 12) { + Text(label) + .foregroundStyle(palette.foreground) + Spacer(minLength: 12) + Text(value) + .foregroundStyle(palette.secondary) + .monospacedDigit() + } + .font(.body) + .padding(.vertical, 16) + .accessibilityElement(children: .combine) + } + + private var insightDivider: some View { + Divider().overlay(palette.secondary.opacity(0.18)) + } + + private func activityColor(tokens: Int) -> Color { + guard tokens > 0 else { return palette.sidebar } + let normalized = min(Double(tokens) / Double(maximumDailyTokens), 1) + return palette.accent.opacity(0.22 + (0.78 * normalized.squareRoot())) + } + + private func activityColor(level: Int) -> Color { + guard level > 0 else { return palette.sidebar } + return palette.accent.opacity(0.18 + (Double(level) * 0.205)) + } +} + +struct AidenUsageHeatmapDay: Identifiable, Equatable { + let date: String + let tokens: Int + + var id: String { date } +} + +enum AidenUsagePresentation { + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private static let displayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = .current + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.setLocalizedDateFormatFromTemplate("MMM d") + return formatter + }() + + static func ratio(_ value: Int, of total: Int) -> Double { + guard total > 0 else { return 0 } + return min(max(Double(value) / Double(total), 0), 1) + } + + static func dayCount(_ value: Int) -> String { + value == 1 ? "1 day" : "\(value) days" + } + + static func tokenCount(_ value: Int, locale: Locale = .current) -> String { + value.formatted(.number.locale(locale).grouping(.automatic)) + } + + static func dateRangeText(for usage: AidenUsageSummary) -> String { + guard let start = dateFormatter.date(from: usage.startDate), + let end = dateFormatter.date(from: usage.endDate) else { + return String(localized: "Last 30 days") + } + return "\(displayFormatter.string(from: start))–\(displayFormatter.string(from: end))" + } + + static func heatmapDays(for usage: AidenUsageSummary) -> [AidenUsageHeatmapDay] { + let totalsByDate = Dictionary(uniqueKeysWithValues: usage.days.map { ($0.date, $0.tokens.total) }) + guard let start = dateFormatter.date(from: usage.startDate), + let end = dateFormatter.date(from: usage.endDate), + start <= end else { + return usage.days.map { AidenUsageHeatmapDay(date: $0.date, tokens: $0.tokens.total) } + } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + var date = start + var result: [AidenUsageHeatmapDay] = [] + while date <= end, result.count < 366 { + let key = dateFormatter.string(from: date) + result.append(AidenUsageHeatmapDay(date: key, tokens: totalsByDate[key] ?? 0)) + guard let next = calendar.date(byAdding: .day, value: 1, to: date) else { break } + date = next + } + return result + } +} + +private extension View { + func aidenUsageCard(palette: AidenPalette) -> some View { + background( + palette.raised, + in: RoundedRectangle(cornerRadius: 24, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(palette.foreground.opacity(0.06), lineWidth: 0.5) + } + } +} + +private struct AidenAppSettingsView: View { + @Environment(\.dismiss) private var dismiss + @Environment(AidenHapticCenter.self) private var haptics + @Bindable var coordinator: AidenRemoteCoordinator + @Bindable var appearance: AidenAppearanceStore + let addInstallation: () -> Void + + @State private var isShowingInstallations = false + @State private var isShowingAppearance = false + @AppStorage("aiden.defaults.workspacePermission") private var defaultWorkspacePermissionRaw = AidenWorkspacePermission.ask.rawValue + + var body: some View { + NavigationStack { + Form { + Section("Aiden Agent") { + LabeledContent( + "Connected Mac", + value: coordinator.installationStore.activeInstallation?.name ?? "Not connected" + ) + Button { + isShowingInstallations = true + } label: { + Label("Paired Installations", systemImage: "desktopcomputer") + } + } + + Section { + Button { + isShowingAppearance = true + } label: { + Label("Appearance", systemImage: "circle.lefthalf.filled") + } + Picker("New workspace permission", selection: $defaultWorkspacePermissionRaw) { + ForEach(AidenWorkspacePermission.allCases, id: \.self) { permission in + Text(permission.title).tag(permission.rawValue) + } + } + } header: { + Text("Global Defaults") + } footer: { + Text("These are app-wide defaults. Permission, files, Git, and other workspace-specific options remain in each workspace’s ••• menu.") + } + + Section { + Toggle("Interaction haptics", isOn: Binding( + get: { haptics.isEnabled }, + set: { haptics.isEnabled = $0 } + )) + .disabled(!haptics.supportsHaptics) + } header: { + Text("Feedback") + } footer: { + Text(haptics.supportsHaptics + ? "Adds subtle feedback to important actions and confirmed outcomes. Standard controls keep their system feedback." + : "Interaction haptics are not available on this device.") + } + + Section("About") { + Link(destination: AppConfig.privacyPolicyURL) { + Label("Privacy Policy", systemImage: "hand.raised") + } + Link(destination: AppConfig.supportURL) { + Label("Support", systemImage: "questionmark.circle") + } + LabeledContent("License", value: "MIT") + } + } + .navigationTitle("App Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + .sheet(isPresented: $isShowingInstallations) { + AidenInstallationsView( + coordinator: coordinator, + addInstallation: { + isShowingInstallations = false + addInstallation() + } + ) + } + .sheet(isPresented: $isShowingAppearance) { + AidenAppearanceSettingsView(appearance: appearance) + } + } +} + +enum AidenInstallationPresentation { + static func endpointType(_ endpoint: URL) -> String { + guard let host = endpoint.host?.lowercased() else { return String(localized: "Remote") } + return host.hasSuffix(".ts.net") + ? String(localized: "Tailscale") + : String(localized: "Local Network") + } + + static func reachability( + installationID: String, + activeInstallationID: String?, + connectionState: AidenRemoteConnectionState + ) -> String { + guard installationID == activeInstallationID else { + return String(localized: "Not checked") + } + switch connectionState { + case .connected: return String(localized: "Connected") + case .connecting: return String(localized: "Connecting") + case .offline: return String(localized: "Offline") + case .needsPairing: return String(localized: "Needs pairing") + } + } + + static func lastConnection(_ date: Date?, now: Date = Date()) -> String { + guard let date else { return String(localized: "Never connected") } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return String(localized: "Last connected \(formatter.localizedString(for: date, relativeTo: now))") + } + + static func identitySuffix(_ instanceID: String) -> String { + String(instanceID.suffix(6)).lowercased() + } + + static func accessibilityValue( + installationID: String, + activeInstallationID: String?, + connectionState: AidenRemoteConnectionState, + endpoint: URL, + lastConnectedAt: Date? + ) -> String { + let selected = installationID == activeInstallationID + ? String(localized: "Selected") + : String(localized: "Not selected") + return [ + selected, + reachability( + installationID: installationID, + activeInstallationID: activeInstallationID, + connectionState: connectionState + ), + endpointType(endpoint), + lastConnection(lastConnectedAt), + ].joined(separator: ", ") + } +} + +private struct AidenInstallationsView: View { + @Environment(\.dismiss) private var dismiss + @Bindable var coordinator: AidenRemoteCoordinator + let addInstallation: () -> Void + + @State private var installationToRemove: AidenInstallation? + @State private var hapticScope = UUID() + + var body: some View { + NavigationStack { + List { + Section("Paired installations") { + ForEach(coordinator.installationStore.installations) { installation in + let duplicateName = coordinator.installationStore.installations.filter { + $0.name.localizedCaseInsensitiveCompare(installation.name) == .orderedSame + }.count > 1 + Button { + Task { + guard coordinator.activeInstanceId != installation.id else { + dismiss() + return + } + let operationID = UUID() + let outcome = await coordinator.switchInstallationOutcome(to: installation.id) + switch outcome { + case .success: + coordinator.haptics.play( + .success, + scope: hapticScope, + dedupeKey: "installation-switch:\(operationID.uuidString)" + ) + dismiss() + case .failure: + coordinator.haptics.play( + .error, + scope: hapticScope, + dedupeKey: "installation-switch:\(operationID.uuidString)" + ) + case .cancelled, .stale, .busy: + break + } + } + } label: { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(installation.name) + .foregroundStyle(.primary) + Text("\(AidenInstallationPresentation.reachability(installationID: installation.id, activeInstallationID: coordinator.activeInstanceId, connectionState: coordinator.connectionState)) · \(AidenInstallationPresentation.endpointType(installation.endpoint))") + .font(.caption) + .foregroundStyle(.secondary) + Text(AidenInstallationPresentation.lastConnection(installation.lastConnectedAt)) + .font(.caption2) + .foregroundStyle(.tertiary) + if duplicateName { + Text("Identity \(AidenInstallationPresentation.identitySuffix(installation.instanceId))") + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + } + } + Spacer() + if coordinator.installationStore.activeInstallationId == installation.id { + Image(systemName: "checkmark") + } + } + } + .accessibilityLabel(duplicateName + ? "\(installation.name), identity \(AidenInstallationPresentation.identitySuffix(installation.instanceId))" + : installation.name) + .accessibilityValue(AidenInstallationPresentation.accessibilityValue( + installationID: installation.id, + activeInstallationID: coordinator.activeInstanceId, + connectionState: coordinator.connectionState, + endpoint: installation.endpoint, + lastConnectedAt: installation.lastConnectedAt + )) + .disabled(coordinator.isMutating || coordinator.connectionState == .connecting) + .swipeActions { + Button("Forget", role: .destructive) { installationToRemove = installation } + .disabled(coordinator.isMutating) + } + } + } + + Section { + Button(action: addInstallation) { + Label("Pair Another Aiden Agent", systemImage: "plus") + } + } + } + .navigationTitle("Aiden Installations") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .confirmationDialog( + "Forget this Aiden Agent?", + isPresented: Binding( + get: { installationToRemove != nil }, + set: { if !$0 { installationToRemove = nil } } + ), + titleVisibility: .visible + ) { + Button("Forget Installation", role: .destructive) { + guard let installationToRemove else { return } + Task { + let operationID = UUID() + let outcome = await coordinator.removeInstallationOutcome(installationToRemove.id) + switch outcome { + case .success: + coordinator.haptics.play( + .success, + scope: hapticScope, + dedupeKey: "installation-forget:\(operationID.uuidString)" + ) + case .failure: + coordinator.haptics.play( + .error, + scope: hapticScope, + dedupeKey: "installation-forget:\(operationID.uuidString)" + ) + case .cancelled, .stale, .busy: + break + } + self.installationToRemove = nil + } + } + Button("Cancel", role: .cancel) { installationToRemove = nil } + } message: { + Text("Its credential will be removed from this device. You can pair again from Aiden Agent settings.") + } + } + .onAppear { coordinator.haptics.activate(scope: hapticScope) } + .onDisappear { coordinator.haptics.deactivate(scope: hapticScope) } + } +} + +private struct AidenOfflineBanner: View { + let message: String + let retry: () -> Void + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "wifi.slash") + Text(message) + .font(.footnote) + .lineLimit(2) + Spacer(minLength: 4) + Button("Retry", action: retry) + .font(.footnote.bold()) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.regularMaterial) + .accessibilityElement(children: .combine) + } +} + +extension AidenWorkspacePermission { + var title: String { + switch self { + case .full: "Full Access" + case .ask: "Ask Before Actions" + case .none: "No Access" + } + } + + var detail: String { + switch self { + case .full: + "Aiden can use this workspace's approved tools without asking for each ordinary action. Consequential Git actions still require confirmation." + case .ask: + "Aiden asks before actions that need approval in this workspace." + case .none: + "Aiden can show existing chats but cannot use workspace tools." + } + } +} + +private struct AidenFolderLocation: Hashable { + let label: String + let location: String + let context: AidenRemoteRequestContext +} + +private struct AidenFolderBrowserView: View { + @Environment(\.dismiss) private var dismiss + @Bindable var coordinator: AidenRemoteCoordinator + let onCreated: (AidenWorkspace) -> Void + + @State private var roots: [AidenBrowserRoot] = [] + @State private var context: AidenRemoteRequestContext? + @State private var path: [AidenFolderLocation] = [] + @State private var errorMessage: String? + @State private var isLoading = true + + var body: some View { + NavigationStack(path: $path) { + Group { + if isLoading { + ProgressView("Loading approved folders…") + } else if roots.isEmpty { + ContentUnavailableView( + "No Approved Folders", + systemImage: "folder.badge.questionmark", + description: Text("Add an approved root in Aiden Agent → Settings → Remote Access, then try again.") + ) + } else { + List(roots) { root in + if let context { + NavigationLink(value: AidenFolderLocation( + label: root.label, + location: root.location, + context: context + )) { + Label(root.label, systemImage: "folder.fill") + } + } + } + } + } + .navigationTitle("Add Mac Folder") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .navigationDestination(for: AidenFolderLocation.self) { location in + AidenFolderPageView( + coordinator: coordinator, + location: location, + onCreated: onCreated + ) + } + .task { await loadRoots() } + .alert("Couldn’t Browse Folders", isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Button("Retry") { Task { await loadRoots() } } + Button("Cancel", role: .cancel) { errorMessage = nil } + } message: { + Text(errorMessage ?? "Try again.") + } + } + } + + private func loadRoots() async { + isLoading = true + defer { isLoading = false } + let requestContext: AidenRemoteRequestContext + do { + requestContext = try coordinator.requestContext() + } catch { + errorMessage = error.localizedDescription + return + } + do { + let loadedRoots = try await coordinator.browserRoots(context: requestContext) + guard coordinator.isCurrent(requestContext) else { return } + context = requestContext + path = [] + roots = loadedRoots + } catch { + guard coordinator.isCurrent(requestContext) else { return } + errorMessage = error.localizedDescription + } + } +} + +private struct AidenFolderPageView: View { + @Bindable var coordinator: AidenRemoteCoordinator + let location: AidenFolderLocation + let onCreated: (AidenWorkspace) -> Void + + @State private var page: AidenBrowserPage? + @State private var isLoading = true + @State private var isLoadingMore = false + @State private var errorMessage: String? + @State private var hapticScope = UUID() + + var body: some View { + Group { + if isLoading { + ProgressView("Loading folders…") + } else if let page { + List { + if !page.breadcrumbs.isEmpty { + Section { + Text(page.breadcrumbs.map(\.label).joined(separator: " › ")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Section("Folders") { + ForEach(page.entries) { entry in + NavigationLink(value: AidenFolderLocation( + label: entry.name, + location: entry.location, + context: location.context + )) { + Label(entry.name, systemImage: "folder") + } + } + if let nextCursor = page.nextCursor { + Button { + Task { await loadMore(cursor: nextCursor) } + } label: { + if isLoadingMore { ProgressView() } else { Text("Load More") } + } + .disabled(isLoadingMore) + } + } + } + } else { + ContentUnavailableView("Folder Unavailable", systemImage: "exclamationmark.folder") + } + } + .navigationTitle(location.label) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Add This Folder") { + Task { + let outcome = await coordinator.createSelectedFolderWorkspaceOutcome( + context: location.context, + location: location.location, + name: nil + ) + if case .success(let workspace) = outcome { + onCreated(workspace) + } else if outcome.isDefinitiveFailure, coordinator.isCurrent(location.context) { + errorMessage = coordinator.presentedError + coordinator.haptics.play(.error, scope: hapticScope) + } + } + } + .disabled(coordinator.isMutating || !coordinator.isCurrent(location.context)) + } + } + .onAppear { coordinator.haptics.activate(scope: hapticScope) } + .onDisappear { coordinator.haptics.deactivate(scope: hapticScope) } + .task(id: location.location) { await load() } + .alert("Couldn’t Add Folder", isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Button("Retry") { Task { await load() } } + Button("Cancel", role: .cancel) { errorMessage = nil } + } message: { + Text(errorMessage ?? "The selection may have expired. Browse the folder again and retry.") + } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + let loadedPage = try await coordinator.browserChildren( + context: location.context, + location: location.location + ) + guard coordinator.isCurrent(location.context) else { return } + page = loadedPage + } catch { + guard coordinator.isCurrent(location.context) else { return } + errorMessage = error.localizedDescription + } + } + + private func loadMore(cursor: String) async { + guard let page else { return } + isLoadingMore = true + defer { isLoadingMore = false } + do { + let next = try await coordinator.browserChildren( + context: location.context, + location: location.location, + cursor: cursor + ) + guard coordinator.isCurrent(location.context) else { return } + self.page = AidenBrowserPage( + rootId: page.rootId, + label: page.label, + breadcrumbs: page.breadcrumbs, + entries: page.entries + next.entries, + nextCursor: next.nextCursor + ) + } catch { + guard coordinator.isCurrent(location.context) else { return } + errorMessage = error.localizedDescription + } + } +} diff --git a/ios/AidenOnTheGo/Features/Shared/AidenProviderIcon.swift b/ios/AidenOnTheGo/Features/Shared/AidenProviderIcon.swift new file mode 100644 index 00000000..da99049f --- /dev/null +++ b/ios/AidenOnTheGo/Features/Shared/AidenProviderIcon.swift @@ -0,0 +1,142 @@ +import SwiftUI +import UIKit + +enum AidenProviderIconResolver { + static let supportedSlugs: Set = [ + "amazon-bedrock", + "ant-ling", + "anthropic", + "apple-foundation-models", + "azure-openai-responses", + "cerebras", + "claude", + "cloudflare-ai-gateway", + "cloudflare-workers-ai", + "concentrate", + "deepseek", + "fireworks", + "github-copilot", + "google", + "google-vertex", + "grok", + "groq", + "huggingface", + "kimi-coding", + "lmstudio", + "minimax", + "minimax-cn", + "mistral", + "moonshotai", + "moonshotai-cn", + "nvidia", + "ollama", + "openai", + "openai-codex", + "opencode", + "opencode-go", + "openrouter", + "together", + "vercel-ai-gateway", + "xai", + "xiaomi", + "xiaomi-token-plan-ams", + "xiaomi-token-plan-cn", + "xiaomi-token-plan-sgp", + "zai", + "zai-coding-cn", + ] + + static let multicolorSlugs: Set = [ + "fireworks", + "groq", + "opencode", + "opencode-go", + "together", + "zai", + "zai-coding-cn", + ] + + private static let aliases = [ + "gemini": "google", + "lm-studio": "lmstudio", + "moonshot": "moonshotai", + ] + + static func slug(providerID: String, modelID: String? = nil) -> String? { + let provider = providerID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let model = modelID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + + if provider == "anthropic", model.contains("claude") { return "claude" } + if provider == "xai", model.contains("grok") { return "grok" } + if matchesNumberedCustomProvider(provider, base: "custom:lmstudio") { return "lmstudio" } + if matchesNumberedCustomProvider(provider, base: "custom:ollama") { return "ollama" } + if let alias = aliases[provider] { return alias } + return supportedSlugs.contains(provider) ? provider : nil + } + + private static func matchesNumberedCustomProvider(_ provider: String, base: String) -> Bool { + if provider == base { return true } + guard provider.hasPrefix(base + "-") else { return false } + let suffix = provider.dropFirst(base.count + 1) + guard !suffix.isEmpty, + suffix.first != "0", + let number = Int(suffix) + else { return false } + return number >= 2 + } +} + +struct AidenProviderIcon: View { + @Environment(\.aidenPalette) private var palette + + let providerID: String + let providerLabel: String + var modelID: String? = nil + var artwork: AidenProviderArtwork? = nil + var size: CGFloat = 20 + var color: Color? = nil + + private var slug: String? { + AidenProviderIconResolver.slug(providerID: providerID, modelID: modelID) + } + + var body: some View { + Group { + if let customImage { + Image(uiImage: customImage) + .resizable() + .renderingMode(.original) + .scaledToFit() + } else if let slug { + Image("ProviderLogo-\(slug)") + .resizable() + .renderingMode( + AidenProviderIconResolver.multicolorSlugs.contains(slug) + ? .original + : .template + ) + .foregroundStyle(color ?? palette.foreground) + .scaledToFit() + } else { + Text(providerInitial) + .font(.system(size: size * 0.48, weight: .semibold, design: .rounded)) + .foregroundStyle(color ?? palette.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(palette.sidebar) + .clipShape(RoundedRectangle(cornerRadius: size * 0.28, style: .continuous)) + } + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } + + private var providerInitial: String { + providerLabel.trimmingCharacters(in: .whitespacesAndNewlines).first + .map { String($0).uppercased() } ?? "?" + } + + private var customImage: UIImage? { + guard let data = artwork?.boundedPNGData else { return nil } + return UIImage(data: data) + } +} diff --git a/ios/AidenOnTheGo/Features/Shared/ThinkingOrbsKit/Core.swift b/ios/AidenOnTheGo/Features/Shared/ThinkingOrbsKit/Core.swift new file mode 100644 index 00000000..d74f149c --- /dev/null +++ b/ios/AidenOnTheGo/Features/Shared/ThinkingOrbsKit/Core.swift @@ -0,0 +1,142 @@ +// Shared primitives for the dotted 3D thought-orbs. +// +// Hand-transcribed from src/engine/core.ts. Every formula here must match +// the TypeScript exactly — OrbGoldenTests compares this package's output +// against spec/orbs-golden.json dot by dot, so a mistyped constant fails a +// test rather than shipping as a subtly wrong animation. +// +// Doubles throughout, deliberately: the golden vectors come from JavaScript +// numbers, which are IEEE-754 doubles. Using Float here would drift past the +// 1e-4 tolerance on the trig-heavy modes. + +import Foundation + +public struct Dot: Sendable { + public var x: Double + public var y: Double + public var z: Double + public var r: Double + /// Ink value: 0 = darkest ink on paper. Mirrored on dark themes. + public var white: Double + public var a: Double + + public init(x: Double, y: Double, z: Double, r: Double, white: Double, a: Double = 1) { + self.x = x; self.y = y; self.z = z; self.r = r; self.white = white; self.a = a + } +} + +/// A stroked edge between two projected points (the `connecting` web). +public struct Line: Sendable { + public var x1: Double + public var y1: Double + public var x2: Double + public var y2: Double + public var white: Double + public var a: Double + public var w: Double +} + +/// One rendered instant: a complete, final set of draw instructions. +/// `dots` is already z-sorted into draw order and radius-clamped; `lines` +/// are drawn first. +public struct OrbFrame: Sendable { + public var dots: [Dot] + public var lines: [Line] +} + +/// Deterministic hash in [0, 1). +@inlinable +func hashD(_ a: Double, _ b: Double) -> Double { + let h = sin(a * 12.9898 + b * 78.233) * 43758.5453 + return h - floor(h) +} + +/// Value noise on a 2D lattice — smooth, deterministic, cheap. +@inlinable +func vnoise(_ x: Double, _ y: Double) -> Double { + let xi = floor(x) + let yi = floor(y) + var fx = x - xi + var fy = y - yi + fx = fx * fx * (3 - 2 * fx) + fy = fy * fy * (3 - 2 * fy) + let a = hashD(xi, yi) + let b = hashD(xi + 1, yi) + let c = hashD(xi, yi + 1) + let d = hashD(xi + 1, yi + 1) + return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy +} + +/// Stable directions on a unit sphere (Fibonacci lattice). +@inlinable +func fibDir(_ i: Int, _ n: Int) -> (Double, Double, Double) { + let golden = Double.pi * (3 - (5.0).squareRoot()) + let y = 1 - (2 * (Double(i) + 0.5)) / Double(n) + let rad = (1 - y * y).squareRoot() + let a = Double(i) * golden + return (rad * cos(a), y, rad * sin(a)) +} + +/// Shortest signed angular distance, wrapped to (-π, π]. +@inlinable +func angleDelta(_ a: Double, _ b: Double) -> Double { + atan2(sin(a - b), cos(a - b)) +} + +@inlinable +func lerp(_ a: Double, _ b: Double, _ f: Double) -> Double { a + (b - a) * f } + +@inlinable +func frac(_ x: Double) -> Double { x - floor(x) } + +/// Shared spin + tilt + orthographic projection. +/// +/// The TS version returns a closure; a struct is the Swift equivalent and +/// avoids an allocation per frame in the inner loops. +struct Projector { + let st: Double, ct: Double, sy: Double, cyw: Double + let cx: Double, cy: Double, scale: Double + + init(yaw: Double, tilt: Double, cx: Double, cy: Double, scale: Double) { + self.st = sin(tilt); self.ct = cos(tilt) + self.sy = sin(yaw); self.cyw = cos(yaw) + self.cx = cx; self.cy = cy; self.scale = scale + } + + @inlinable + func callAsFunction(_ x: Double, _ y: Double, _ z: Double) -> (Double, Double, Double) { + let x1 = x * cyw + z * sy + let z1 = -x * sy + z * cyw + let y1 = y * ct - z1 * st + let z2 = y * st + z1 * ct + return (cx + x1 * scale, cy - y1 * scale, z2) + } +} + +/// Dot radii were tuned for a 300pt frame; sub-linear scaling keeps small +/// spinners legible. Lower pow = radii shrink less with size. +@inlinable +func radiusScale(_ size: Double, pow p: Double) -> Double { + Foundation.pow(size / 300, p) +} + +/// Turn raw mode output into a finished frame: drop invisible marks, clamp +/// radii to the mode's floor, and z-sort far→near into draw order. +/// +/// Sort note: JavaScript's Array.prototype.sort is stable (required since +/// ES2019) and Swift's `sort` is NOT, so ties would be free to reorder and +/// the golden comparison would fail on modes that emit co-planar dots +/// (`shaping` puts every dot at z = 0). Sorting by index as a tiebreaker +/// reproduces JS's stability exactly. +func finalizeFrame(_ dots: [Dot], _ lines: [Line], rMin: Double = 0.3) -> OrbFrame { + var visible: [Dot] = [] + visible.reserveCapacity(dots.count) + for var d in dots where d.a >= 0.02 { + d.r = Swift.max(rMin, d.r) + visible.append(d) + } + let sorted = visible.enumerated() + .sorted { $0.element.z != $1.element.z ? $0.element.z < $1.element.z : $0.offset < $1.offset } + .map(\.element) + return OrbFrame(dots: sorted, lines: lines.filter { $0.a >= 0.02 }) +} diff --git a/ios/AidenOnTheGo/Features/Shared/ThinkingOrbsKit/Lattice.swift b/ios/AidenOnTheGo/Features/Shared/ThinkingOrbsKit/Lattice.swift new file mode 100644 index 00000000..fab4186a --- /dev/null +++ b/ios/AidenOnTheGo/Features/Shared/ThinkingOrbsKit/Lattice.swift @@ -0,0 +1,201 @@ +// The sphere-lattice modes: globe (searching), rubik (solving) and +// wave (listening). Transcribed from src/engine/lattice.ts. + +import Foundation + +// --- the shared solver heartbeat (rubik) ------------------------------ +// Rapid eased moves scramble, then replay in reverse (palindrome) so +// everything clicks back to solved, rests, repeats. + +private struct Move { + let axis: Int + let lo: Double + let hi: Double + let ang: Double +} + +private struct SolveCycle { + var amount: [Double] + var active: Int +} + +private func solveCycle(_ time: Double, _ count: Int, _ slotDur: Double, _ rest: Double) -> SolveCycle { + let cyc = 2 * Double(count) * slotDur + rest + let tc = time.truncatingRemainder(dividingBy: cyc) + var amount = [Double](repeating: 0, count: count) + var active = -1 + if tc < 2 * Double(count) * slotDur { + let slot = Int(floor(tc / slotDur)) + let p = (tc - Double(slot) * slotDur) / slotDur + let cl = Swift.min(1, p / 0.7) + let ep = 1 - pow(1 - cl, 3) // machine ease-out + if slot < count { + for i in 0.. (Double, Double, Double, Bool) { + var (x, y, z) = pt3 + var inActive = false + for i in 0..= mv.hi { continue } + if i == sc.active { inActive = true } + let a = mv.ang * sc.amount[i] + let ca = cos(a) + let sa = sin(a) + if mv.axis == 0 { + let y2 = y * ca - z * sa + z = y * sa + z * ca + y = y2 + } else if mv.axis == 1 { + let x2 = x * ca + z * sa + z = -x * sa + z * ca + x = x2 + } else { + let x2 = x * ca - y * sa + y = x * sa + y * ca + x = x2 + } + } + return (x, y, z, inActive) +} + +private func makeMoves(_ count: Int) -> [Move] { + var moves: [Move] = [] + for i in 0.. OrbFrame { + let spin = 0.5 + let cx = size / 2 + let cy = size / 2 + let radius = (size / 2) * 0.82 + let tilt = 0.4 + 0.06 * sin(t * 0.35) + let pt = Projector(yaw: t * spin, tilt: tilt, cx: cx, cy: cy, scale: radius) + // scan sweeps relative to the spin; scanMul scales that relative rate + let scan = t * (spin + (1.7 - spin) * (o["scanMul"] ?? 1)) + let rs = radiusScale(size, pow: o["rsPow"] ?? 0.6) + let dimBase = o["dimBase"] ?? 1 + + var dots: [Dot] = [] + let latRings = Int(o["latRings"] ?? 17) + let lonDensity = o["lonDensity"] ?? 44 + for li in 0...latRings { + let lat = -Double.pi / 2 + (Double(li) / Double(latRings)) * Double.pi + let cosLat = cos(lat) + let sinLat = sin(lat) + let lonCount = Swift.max(1, Int((abs(cosLat) * lonDensity).rounded(.toNearestOrAwayFromZero))) + for lj in 0.. OrbFrame { + let cx = size / 2 + let cy = size / 2 + let R = (size / 2) * 0.82 + let pt = Projector(yaw: t * 0.55, tilt: 0.35 + 0.1 * sin(t * 0.9), cx: cx, cy: cy, scale: R) + let rs = radiusScale(size, pow: o["rsPow"] ?? 0.6) + let moveCount = Int(o["moveCount"] ?? 14) + let moves = makeMoves(moveCount) + let sc = solveCycle(t, moveCount, 0.42, 1.2) + + var dots: [Dot] = [] + let latRings = Int(o["latRings"] ?? 15) + let lonDensity = o["lonDensity"] ?? 40 + for li in 0...latRings { + let lat = -Double.pi / 2 + (Double(li) / Double(latRings)) * Double.pi + let cosLat = cos(lat) + let sinLat = sin(lat) + let lonCount = Swift.max(1, Int((abs(cosLat) * lonDensity).rounded(.toNearestOrAwayFromZero))) + for lj in 0.. OrbFrame { + let cx = size / 2 + let cy = size / 2 + // 0.76 base × 1.15 — the undulation pulls the sphere inward, so wave read + // ~15% smaller than the other lattice modes; scaled up to match them + let R = (size / 2) * 0.874 + let pt = Projector(yaw: t * 0.18, tilt: 0.38, cx: cx, cy: cy, scale: 1) + let rs = radiusScale(size, pow: o["rsPow"] ?? 0.6) + + var dots: [Dot] = [] + let rings = Int(o["rings"] ?? 15) + let lonDensity = o["lonDensity"] ?? 40 + for ri in 0...rings { + let lat = -Double.pi / 2 + (Double(ri) / Double(rings)) * Double.pi + let cosLat = cos(lat) + let sinLat = sin(lat) + // two waves, different tempi — organic, never quite repeating + let w = 0.62 * sin(t * 2.1 - Double(ri) * 0.52) + 0.38 * sin(t * 1.27 + Double(ri) * 0.83) + let rr = R * (0.88 + 0.105 * w) + let lonCount = Swift.max(1, Int((abs(cosLat) * lonDensity).rounded(.toNearestOrAwayFromZero))) + for lj in 0.. Double { x * x * (3 - 2 * x) } + +private struct PolyPath { + let verts: [(Double, Double)] + let segLengths: [Double] + let total: Double + + init(_ verts: [(Double, Double)]) { + self.verts = verts + var L: [Double] = [] + var sum = 0.0 + for i in 0.. (Double, Double) { + var target = f * total + var i = 0 + while target > segLengths[i] && i < verts.count - 1 { + target -= segLengths[i] + i += 1 + } + let a = verts[i] + let b = verts[(i + 1) % verts.count] + let ff = segLengths[i] != 0 ? Swift.min(1, target / segLengths[i]) : 0 + return (a.0 + (b.0 - a.0) * ff, a.1 + (b.1 - a.1) * ff) + } +} + +private enum Shape { + case circle + case poly(PolyPath) + + func point(_ f: Double) -> (Double, Double) { + switch self { + case .circle: + let a = -Double.pi / 2 + f * 2 * Double.pi + return (cos(a) * 0.24, sin(a) * 0.24) + case .poly(let p): + return p.point(f) + } + } +} + +private let TRIANGLE = PolyPath([(0.0, -0.26), (0.24, 0.16), (-0.24, 0.16)]) +// 5-vertex walk so the path STARTS at top-centre like the other shapes +private let SQUARE = PolyPath([(0, -0.2), (0.2, -0.2), (0.2, 0.2), (-0.2, 0.2), (-0.2, -0.2)]) +private let CYCLE: [Shape] = [.circle, .poly(TRIANGLE), .poly(SQUARE)] + +// low floor keeps sparse outlines possible while never degenerating +private func morphN(_ d: Double) -> Int { + Swift.max(6, Int((34 * d).rounded(.toNearestOrAwayFromZero))) +} + +private let HOLD = 1.4 +private let MORPH = 0.9 +private let SEG = HOLD + MORPH + +func frameMorph(_ size: Double, _ t: Double, _ o: [String: Double]) -> OrbFrame { + let K = CYCLE.count + let tc = t.truncatingRemainder(dividingBy: SEG * Double(K)) + let k = Int(floor(tc / SEG)) + let local = tc - Double(k) * SEG + let m = local > HOLD ? smoothE((local - HOLD) / MORPH) : 0 + let sprd = o["spread"] ?? 1 + + // blend the two shape PATHS at m, then measure the blended outline + let pA = CYCLE[k] + let pB = CYCLE[(k + 1) % K] + let M = 160 + var pts: [(Double, Double)] = [] + pts.reserveCapacity(M) + for i in 0.. OrbFrame { + let cx = size / 2 + let cy = size / 2 + let R = (size / 2) * 0.82 + let pt = Projector(yaw: t * 0.12, tilt: 0.3, cx: cx, cy: cy, scale: 1) + let rs = radiusScale(size, pow: o["rsPow"] ?? 0.6) + + var dots: [Dot] = [] + let orbitN = Int(o["orbitN"] ?? 12) + let ghostN = Int(o["ghostN"] ?? 40) + let particles = Int(o["particles"] ?? 3) + + // orbits: each a tilted circle — a ghost path + running particles + for orb in 0.. 0.5 ? 1 : -1) + + // ghost path + for k in 0.. [String: Double] { + var out = opts + var done = Set() + let rt = scale.squareRoot() + for (a, b) in OrbSpec.countPairs { + if let va = out[a], let vb = out[b], !done.contains(a), !done.contains(b) { + out[a] = Swift.max(2, (va * rt).rounded(.toNearestOrAwayFromZero)) + out[b] = Swift.max(2, (vb * rt).rounded(.toNearestOrAwayFromZero)) + done.insert(a); done.insert(b) + } + } + for k in OrbSpec.countKeys { + // 0 means the mode opted out of that layer entirely (ring has no + // ghost sphere) — scaling must not resurrect it as a single stray dot + if let v = out[k], v != 0, !done.contains(k) { + out[k] = Swift.max(1, (v * scale).rounded(.toNearestOrAwayFromZero)) + } + } + for k in OrbSpec.iconDensityKeys { + if let v = out[k] { out[k] = Swift.max(0.02, v * scale) } + } + return out +} + +private func scaleRadii(_ opts: [String: Double], _ scale: Double) -> [String: Double] { + var out = opts + for k in OrbSpec.radiusKeys { + if let v = out[k] { out[k] = v * scale } + } + out["rSizeMul"] = (out["rSizeMul"] ?? 1) * scale + return out +} + +private let cacheLock = NSLock() +nonisolated(unsafe) private var cache: [String: ResolvedPreset] = [:] + +/// Resolve a (state, size) pair. Cached: the result is identical for the +/// lifetime of the process, and the render loop should never do this work. +public func resolvePreset(_ state: OrbState, _ size: OrbSize) -> ResolvedPreset { + let key = "\(state.rawValue)-\(size.rawValue)" + cacheLock.lock() + defer { cacheLock.unlock() } + if let hit = cache[key] { return hit } + + let mode = state.mode + let preset = OrbSpec.presets[mode]![size]! + var opts = OrbSpec.baseProfiles[mode]! + if preset.count != 1 { opts = scaleCounts(opts, preset.count) } + if preset.size != 1 { opts = scaleRadii(opts, preset.size) } + for (k, v) in preset.extra { opts[k] = v } + + let resolved = ResolvedPreset(mode: mode, speed: preset.speed, opts: opts) + cache[key] = resolved + return resolved +} + +/// Geometry for one instant. Mirrors MODE_FRAMES in the TS engine. +public func orbFrame(_ preset: ResolvedPreset, size: Double, t: Double) -> OrbFrame { + let o = preset.opts + switch preset.mode { + case .orbits: return frameOrbits(size, t, o) + case .globe: return frameGlobe(size, t, o) + case .rubik: return frameRubik(size, t, o) + case .wave: return frameWave(size, t, o) + case .web: return frameWeb(size, t, o) + case .braid: return frameBraid(size, t, o) + // ring shares ribbon's geometry — the `faceOn` profile flag switches it + case .ribbon, .ring: return frameRibbon(size, t, o) + case .morph: return frameMorph(size, t, o) + } +} + +/// Convenience: resolve and build in one call. +public func orbFrame(state: OrbState, size: OrbSize, t: Double) -> OrbFrame { + orbFrame(resolvePreset(state, size), size: size.value, t: t) +} diff --git a/ios/AidenOnTheGo/Features/Shared/ThinkingOrbsKit/Snapshot.swift b/ios/AidenOnTheGo/Features/Shared/ThinkingOrbsKit/Snapshot.swift new file mode 100644 index 00000000..b6ca3134 --- /dev/null +++ b/ios/AidenOnTheGo/Features/Shared/ThinkingOrbsKit/Snapshot.swift @@ -0,0 +1,51 @@ +// Headless frame capture for parity testing. +// +// ImageRenderer runs the real SwiftUI Canvas pipeline without a simulator, +// window or screen-recording permission — but it never fires onAppear and +// never advances a TimelineView, so every capture would otherwise render the +// same t=0 frame. `.orbFrozenTime(_:)` is what makes a capture deterministic +// and comparable against the web at the same instant. + +#if canImport(SwiftUI) && !os(watchOS) +import SwiftUI + +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif + +@available(iOS 16.0, macOS 13.0, *) +public enum OrbSnapshot { + /// Render one orb at a fixed instant. `scale` mirrors the web harness's + /// device pixel ratio, so the two rasters are the same size. + @MainActor + public static func png( + state: OrbState, + size: OrbSize, + dark: Bool, + t: Double, + scale: Double = 2 + ) -> Data? { + let view = ThinkingOrb(state: state, size: size, theme: dark ? .dark : .light) + .orbFrozenTime(t) + .frame(width: size.value, height: size.value) + + let renderer = ImageRenderer(content: view) + renderer.scale = scale + // transparent background, exactly like the web canvas + renderer.isOpaque = false + + #if canImport(AppKit) + guard let image = renderer.nsImage, + let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff) else { return nil } + return rep.representation(using: .png, properties: [:]) + #elseif canImport(UIKit) + return renderer.uiImage?.pngData() + #else + return nil + #endif + } +} +#endif diff --git a/ios/AidenOnTheGo/Features/Shared/ThinkingOrbsKit/Strands.swift b/ios/AidenOnTheGo/Features/Shared/ThinkingOrbsKit/Strands.swift new file mode 100644 index 00000000..be26dfa0 --- /dev/null +++ b/ios/AidenOnTheGo/Features/Shared/ThinkingOrbsKit/Strands.swift @@ -0,0 +1,138 @@ +// The strand modes: braid (weaving), ribbon (composing) and ring +// (breathing). Transcribed from src/engine/braid.ts and ribbon.ts. + +import Foundation + +// --- Braid: three strands plait around the sphere — weaving ----------- + +func frameBraid(_ size: Double, _ t: Double, _ o: [String: Double]) -> OrbFrame { + let cx = size / 2 + let cy = size / 2 + let R = (size / 2) * 0.76 + let pt = Projector(yaw: t * 0.4, tilt: 0.3, cx: cx, cy: cy, scale: 1) + let rs = radiusScale(size, pow: o["rsPow"] ?? 0.6) + + var dots: [Dot] = [] + let ghostN = Int(o["ghostN"] ?? 150) + for i in 0.. OrbFrame { + let cx = size / 2 + let cy = size / 2 + let R = (size / 2) * 0.78 + // spin scales the 3D tumble; spin=0 freezes the band's orientation, + // leaving only the traveling undulation + let spin = o["spin"] ?? 1 + let camTilt = 0.3 + let faceOn = (o["faceOn"] ?? 0) != 0 + let pt = Projector(yaw: t * 0.1 * spin, tilt: camTilt, cx: cx, cy: cy, scale: 1) + let rs = radiusScale(size, pow: o["rsPow"] ?? 0.6) + + var dots: [Dot] = [] + let ghostN = Int(o["ghostN"] ?? 150) + if ghostN > 0 { + for i in 0.. some View { + Canvas(rendersAsynchronously: false) { context, _ in + var context = context + let zoom = (displaySize ?? size.value) / size.value + if zoom != 1 { context.scaleBy(x: zoom, y: zoom) } + let frame = orbFrame(preset, size: size.value, t: t) + // lines first, so nodes sit on top of their edges + for l in frame.lines { + var path = Path() + path.move(to: CGPoint(x: l.x1, y: l.y1)) + path.addLine(to: CGPoint(x: l.x2, y: l.y2)) + context.stroke( + path, + with: .color(ink(l.white, l.a)), + lineWidth: l.w + ) + } + // dots are already z-sorted into draw order by the engine + for d in frame.dots { + let rect = CGRect(x: d.x - d.r, y: d.y - d.r, width: d.r * 2, height: d.r * 2) + context.fill(Path(ellipseIn: rect), with: .color(ink(d.white, d.a))) + } + } + } + + /// Quantise to 8-bit exactly as the canvas painter does, so the platforms + /// land on identical greys rather than merely close ones. + private func ink(_ white: Double, _ alpha: Double) -> Color { + let w = Swift.min(1, Swift.max(0, white)) + let g = ((isDark ? 1 - w : w) * 255).rounded(.toNearestOrAwayFromZero) / 255 + return Color(.sRGB, red: g, green: g, blue: g, opacity: alpha) + } +} + +// MARK: - Frozen time (snapshot testing) + +private struct OrbFrozenTimeKey: EnvironmentKey { + static let defaultValue: Double? = nil +} + +extension EnvironmentValues { + /// Pins the animation to a fixed instant. Used by the snapshot harness; + /// `ImageRenderer` does not fire `onAppear` or advance `TimelineView`, + /// so without this every capture would render the same t=0 frame. + var orbFrozenTime: Double? { + get { self[OrbFrozenTimeKey.self] } + set { self[OrbFrozenTimeKey.self] = newValue } + } +} + +@available(iOS 15.0, macOS 12.0, *) +extension View { + /// Freeze every ThinkingOrb below this view at `t` seconds. + public func orbFrozenTime(_ t: Double?) -> some View { + environment(\.orbFrozenTime, t) + } +} diff --git a/ios/AidenOnTheGo/Features/Shared/ThinkingOrbsKit/Web.swift b/ios/AidenOnTheGo/Features/Shared/ThinkingOrbsKit/Web.swift new file mode 100644 index 00000000..f3926db0 --- /dev/null +++ b/ios/AidenOnTheGo/Features/Shared/ThinkingOrbsKit/Web.swift @@ -0,0 +1,91 @@ +// Web: a constellation wires itself — the "connecting" state. Nodes drift +// on the sphere under slow value noise; any pair closer than `thr` grows an +// edge, and bright packets run along randomly re-picked node pairs. +// Transcribed from src/engine/web.ts. + +import Foundation + +func frameWeb(_ size: Double, _ t: Double, _ o: [String: Double]) -> OrbFrame { + let cx = size / 2 + let cy = size / 2 + let R = (size / 2) * 0.8 * (o["spread"] ?? 1) + // note the projector carries the radius as its scale, so node vectors stay + // unit-length and distances below are in unit-sphere space + let pt = Projector(yaw: t * 0.12, tilt: 0.32, cx: cx, cy: cy, scale: R) + let rs = radiusScale(size, pow: o["rsPow"] ?? 0.6) + + let nodeN = Int(o["nodeN"] ?? 30) + let thr = o["thr"] ?? 0.72 + let nodeR = o["nodeR"] ?? 1.4 + let nodeRDepth = o["nodeRDepth"] ?? 1.8 + + // nodes: fib lattice + slow noise wander, renormalised to the surface + var nodes: [(Double, Double, Double)] = [] + nodes.reserveCapacity(nodeN) + for i in 0..= thr { continue } + let (x1, y1, z1) = pt(nodes[i].0, nodes[i].1, nodes[i].2) + let (x2, y2, z2) = pt(nodes[j].0, nodes[j].1, nodes[j].2) + let depth = ((z1 + z2) / 2 + 1) / 2 + lines.append(Line( + x1: x1, y1: y1, x2: x2, y2: y2, + white: 0.42, + a: (1 - dist / thr) * (0.3 + 0.55 * depth), + w: Swift.max(0.6, (o["lineW"] ?? 0.8) * rs) + )) + } + } + + for i in 0.. String { + let normalized = normalizedSingleLine(rawValue) + return trimmed(normalized.isEmpty ? String(localized: "Aiden chat") : normalized, limit: maximumSessionTitleCharacters) + } + + static func activityLine(_ rawValue: String) -> String { + trimmed(normalizedSingleLine(rawValue), limit: maximumActivityCharacters) + } + + static func responseExcerpt(_ rawValue: String) -> String { + let normalized = normalizedSingleLine(rawValue) + return trimmed(normalized, limit: maximumExcerptCharacters) + } + + static func toolKind(name: String?) -> AgentRunActivityToolKind { + let label = toolLabel(name) + let lowercasedName = (name ?? "").lowercased() + let lowercasedLabel = label.lowercased() + let haystack = "\(lowercasedName) \(lowercasedLabel)" + + if haystack.contains("shell") + || haystack.contains("bash") + || haystack.contains("terminal") + || haystack.contains("exec") + || haystack.contains("command") + || haystack.contains("xcodebuild") + || haystack.contains("simctl") { + return .command + } + + if haystack.contains("search") + || haystack.contains("grep") + || haystack.contains("ripgrep") + || haystack.contains("rg") + || haystack.contains("find") { + return .search + } + + if haystack.contains("read") + || haystack.contains("file") + || haystack.contains("list") + || haystack.contains("glob") + || haystack.contains("workspace") { + return .files + } + + return .generic(label) + } + + static func toolLabel(_ rawValue: String?) -> String { + let fallback = String(localized: "tool") + guard let rawValue else { return fallback } + + let noPathSeparators = rawValue + .replacingOccurrences(of: "\\", with: "/") + .split(separator: "/") + .last + .map(String.init) ?? rawValue + let words = noPathSeparators + .replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + let normalized = normalizedSingleLine(words) + return trimmed(normalized.isEmpty ? fallback : normalized, limit: maximumToolLabelCharacters) + } + + private static func normalizedSingleLine(_ rawValue: String) -> String { + rawValue + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func trimmed(_ value: String, limit: Int) -> String { + guard value.count > limit else { return value } + guard limit > 3 else { + return String(value.prefix(limit)) + } + + let endIndex = value.index(value.startIndex, offsetBy: limit - 3) + return String(value[.. String { + let elapsedSeconds = max(0, Int(updatedAt.timeIntervalSince(startedAt).rounded(.down))) + let hours = elapsedSeconds / 3_600 + let minutes = (elapsedSeconds % 3_600) / 60 + let seconds = elapsedSeconds % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } + + return String(format: "%02d:%02d", minutes, seconds) + } +} + +enum AgentLiveActivityReusePolicy { + static func normalizedStreamID(_ streamID: String?) -> String? { + guard let streamID else { return nil } + + let normalized = streamID.trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty ? nil : normalized + } + + static func canReuseActivity( + existingSessionID: String, + existingStreamID: String?, + requestedSessionID: String, + requestedStreamID: String? + ) -> Bool { + existingSessionID == requestedSessionID + && normalizedStreamID(existingStreamID) == normalizedStreamID(requestedStreamID) + } +} + +enum AgentRunActivityStateReducer { + static func updatingSessionTitle( + _ title: String, + state: AgentRunActivityAttributes.ContentState, + now: Date = Date() + ) -> AgentRunActivityAttributes.ContentState { + AgentRunActivityAttributes.ContentState( + sessionID: state.sessionID, + sessionTitle: title, + status: state.status, + currentActivity: state.currentActivity, + responseExcerpt: state.responseExcerpt, + startedAt: state.startedAt, + updatedAt: now, + isStale: state.isStale, + isFinal: state.isFinal, + errorSummary: state.errorSummary + ) + } + + static func initialState( + sessionID: String, + sessionTitle: String, + startedAt: Date = Date() + ) -> AgentRunActivityAttributes.ContentState { + AgentRunActivityAttributes.ContentState( + sessionID: sessionID, + sessionTitle: sessionTitle, + status: .starting, + currentActivity: String(localized: "Starting response"), + startedAt: startedAt, + updatedAt: startedAt + ) + } + + static func appendingToken( + _ text: String, + to state: AgentRunActivityAttributes.ContentState, + now: Date = Date() + ) -> AgentRunActivityAttributes.ContentState { + guard !text.isEmpty else { return state } + return AgentRunActivityAttributes.ContentState( + sessionID: state.sessionID, + sessionTitle: state.sessionTitle, + status: .responding, + currentActivity: String(localized: "Writing response"), + responseExcerpt: state.responseExcerpt + text, + startedAt: state.startedAt, + updatedAt: now + ) + } + + static func responding( + state: AgentRunActivityAttributes.ContentState, + now: Date = Date() + ) -> AgentRunActivityAttributes.ContentState { + statusState( + .responding, + activity: String(localized: "Writing response"), + state: state, + now: now + ) + } + + static func settingInterimAssistant( + _ text: String, + on state: AgentRunActivityAttributes.ContentState, + now: Date = Date() + ) -> AgentRunActivityAttributes.ContentState { + let excerpt = AgentRunActivitySanitizer.responseExcerpt(text) + guard !excerpt.isEmpty else { return state } + return AgentRunActivityAttributes.ContentState( + sessionID: state.sessionID, + sessionTitle: state.sessionTitle, + status: .responding, + currentActivity: String(localized: "Writing response"), + responseExcerpt: excerpt, + startedAt: state.startedAt, + updatedAt: now + ) + } + + static func clearingResponseExcerpt( + state: AgentRunActivityAttributes.ContentState, + now: Date = Date() + ) -> AgentRunActivityAttributes.ContentState { + AgentRunActivityAttributes.ContentState( + sessionID: state.sessionID, + sessionTitle: state.sessionTitle, + status: state.status, + currentActivity: state.currentActivity, + responseExcerpt: "", + startedAt: state.startedAt, + updatedAt: now, + isStale: state.isStale, + isFinal: state.isFinal, + errorSummary: state.errorSummary + ) + } + + static func reasoning( + _ text: String, + state: AgentRunActivityAttributes.ContentState, + now: Date = Date() + ) -> AgentRunActivityAttributes.ContentState { + let activity = String(localized: "Thinking") + return statusState(.thinking, activity: activity, state: state, now: now) + } + + static func toolStarted( + name: String?, + state: AgentRunActivityAttributes.ContentState, + now: Date = Date() + ) -> AgentRunActivityAttributes.ContentState { + switch AgentRunActivitySanitizer.toolKind(name: name) { + case .command: + return statusState(.runningCommand, activity: String(localized: "Running command"), state: state, now: now) + case .search: + return statusState(.searchingFiles, activity: String(localized: "Searching files"), state: state, now: now) + case .files: + return statusState(.readingFiles, activity: String(localized: "Reading files"), state: state, now: now) + case .generic(let label): + return statusState(.usingTool, activity: String(localized: "Using \(label)"), state: state, now: now) + } + } + + static func toolCompleted( + state: AgentRunActivityAttributes.ContentState, + now: Date = Date() + ) -> AgentRunActivityAttributes.ContentState { + statusState(.responding, activity: String(localized: "Processing result"), state: state, now: now) + } + + static func waitingForApproval( + state: AgentRunActivityAttributes.ContentState, + now: Date = Date() + ) -> AgentRunActivityAttributes.ContentState { + statusState(.waitingForApproval, activity: String(localized: "Waiting for approval"), state: state, now: now) + } + + static func stale( + state: AgentRunActivityAttributes.ContentState, + now: Date = Date() + ) -> AgentRunActivityAttributes.ContentState { + AgentRunActivityAttributes.ContentState( + sessionID: state.sessionID, + sessionTitle: state.sessionTitle, + status: state.status, + currentActivity: state.currentActivity.isEmpty ? String(localized: "Latest status shown") : state.currentActivity, + responseExcerpt: state.responseExcerpt, + startedAt: state.startedAt, + updatedAt: now, + isStale: true, + isFinal: state.isFinal, + errorSummary: state.errorSummary + ) + } + + static func final( + status: AgentRunActivityStatus, + activity: String, + state: AgentRunActivityAttributes.ContentState, + errorSummary: String? = nil, + now: Date = Date() + ) -> AgentRunActivityAttributes.ContentState { + AgentRunActivityAttributes.ContentState( + sessionID: state.sessionID, + sessionTitle: state.sessionTitle, + status: status, + currentActivity: activity, + responseExcerpt: state.responseExcerpt, + startedAt: state.startedAt, + updatedAt: now, + isStale: false, + isFinal: true, + errorSummary: errorSummary + ) + } + + private static func statusState( + _ status: AgentRunActivityStatus, + activity: String, + state: AgentRunActivityAttributes.ContentState, + now: Date + ) -> AgentRunActivityAttributes.ContentState { + AgentRunActivityAttributes.ContentState( + sessionID: state.sessionID, + sessionTitle: state.sessionTitle, + status: status, + currentActivity: activity, + responseExcerpt: state.responseExcerpt, + startedAt: state.startedAt, + updatedAt: now + ) + } +} diff --git a/ios/AidenOnTheGo/LiveActivities/AidenDeepLink.swift b/ios/AidenOnTheGo/LiveActivities/AidenDeepLink.swift new file mode 100644 index 00000000..97b3ee42 --- /dev/null +++ b/ios/AidenOnTheGo/LiveActivities/AidenDeepLink.swift @@ -0,0 +1,82 @@ +import Foundation + +struct AidenNavigationRequest: Equatable, Sendable { + enum Destination: Equatable, Sendable { case newChat, chat(String) } + let destination: Destination + let instanceId: String? + let workspaceId: String? + let startsVoice: Bool +} + +enum AidenDeepLink { + static var scheme: String { + Bundle.main.object(forInfoDictionaryKey: "AidenURLScheme") as? String ?? "aiden-otg" + } + + static var newChatURL: URL? { newChatURL(instanceId: nil, workspaceId: nil, startsVoice: false) } + static var newChatVoiceURL: URL? { newChatURL(instanceId: nil, workspaceId: nil, startsVoice: true) } + + static func newChatURL(instanceId: String?, workspaceId: String?, startsVoice: Bool) -> URL? { + guard instanceId.map(safeID) ?? true, workspaceId.map(safeID) ?? true else { return nil } + var components = URLComponents() + components.scheme = scheme + components.host = startsVoice ? "new-chat-voice" : "new-chat" + components.queryItems = [ + instanceId.map { URLQueryItem(name: "instance", value: $0) }, + workspaceId.map { URLQueryItem(name: "workspace", value: $0) }, + ].compactMap { $0 } + return components.url + } + + static func chatURL(instanceId: String, chatId: String) -> URL? { + guard safeID(instanceId), safeID(chatId) else { return nil } + var components = URLComponents() + components.scheme = scheme + components.host = "chat" + components.queryItems = [ + URLQueryItem(name: "instance", value: instanceId), + URLQueryItem(name: "chat", value: chatId), + ] + return components.url + } + + static func request(from url: URL) -> AidenNavigationRequest? { + guard url.scheme?.lowercased() == scheme.lowercased(), + url.user == nil, url.password == nil, url.port == nil, + url.fragment == nil, url.path.isEmpty else { return nil } + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + guard Set(items.map(\.name)).count == items.count, + items.allSatisfy({ $0.value != nil }), + items.allSatisfy({ $0.name == "instance" || $0.name == "workspace" || $0.name == "chat" }) else { + return nil + } + func value(_ name: String) -> String? { items.first(where: { $0.name == name })?.value } + let instance = value("instance") + let workspace = value("workspace") + guard instance.map(safeID) ?? true, workspace.map(safeID) ?? true else { return nil } + switch url.host?.lowercased() { + case "new-chat", "new-chat-voice": + guard value("chat") == nil else { return nil } + return AidenNavigationRequest( + destination: .newChat, + instanceId: instance, + workspaceId: workspace, + startsVoice: url.host?.lowercased() == "new-chat-voice" + ) + case "chat": + guard let instance, let chat = value("chat"), safeID(chat), workspace == nil else { return nil } + return AidenNavigationRequest( + destination: .chat(chat), instanceId: instance, workspaceId: nil, startsVoice: false + ) + default: + return nil + } + } + + private static func safeID(_ value: String) -> Bool { + !value.isEmpty && value.count <= 160 + && value.unicodeScalars.allSatisfy { scalar in + CharacterSet.alphanumerics.contains(scalar) || "._:-".unicodeScalars.contains(scalar) + } + } +} diff --git a/ios/AidenOnTheGo/LiveActivities/AidenRemoteLiveActivityManager.swift b/ios/AidenOnTheGo/LiveActivities/AidenRemoteLiveActivityManager.swift new file mode 100644 index 00000000..8f5624e1 --- /dev/null +++ b/ios/AidenOnTheGo/LiveActivities/AidenRemoteLiveActivityManager.swift @@ -0,0 +1,258 @@ +import ActivityKit +import Foundation + +/// Owns the app-side Live Activity lifecycle. The widget renders only the +/// bounded state written here and never receives network credentials. +@MainActor +final class AidenRemoteLiveActivityManager { + static let shared = AidenRemoteLiveActivityManager() + + static let responseExcerptPreferenceKey = "aiden.live-activities.response-excerpts" + + private let defaults: UserDefaults + private var currentActivity: Activity? + private var stateByActivityID: [String: AgentRunActivityAttributes.ContentState] = [:] + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + var includesResponseExcerpts: Bool { + defaults.bool(forKey: Self.responseExcerptPreferenceKey) + } + + static func matches( + _ attributes: AgentRunActivityAttributes, + instanceID: String, + streamID: String + ) -> Bool { + attributes.instanceID == instanceID && attributes.streamID == streamID + } + + func start(instanceID: String, chatID: String, title: String, streamID: String) async { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + let startedAt = Date() + let attributes = AgentRunActivityAttributes( + instanceID: instanceID, + sessionID: chatID, + sessionTitle: title, + streamID: streamID, + startedAt: startedAt + ) + let state = AgentRunActivityStateReducer.initialState( + sessionID: chatID, + sessionTitle: title, + startedAt: startedAt + ) + + if let existing = activity(instanceID: instanceID, streamID: streamID) { + currentActivity = existing + stateByActivityID[existing.id] = state + await existing.update(content(for: state)) + return + } + + do { + let activity = try Activity.request( + attributes: attributes, + content: content(for: state), + pushType: nil + ) + currentActivity = activity + stateByActivityID[activity.id] = state + } catch { + currentActivity = nil + } + } + + func updateStatus(instanceID: String, streamID: String, state: AidenStreamState) async { + switch state { + case .queued, .reconciling: + await update(instanceID: instanceID, streamID: streamID) { + AgentRunActivityStateReducer.initialState( + sessionID: $0.sessionID, + sessionTitle: $0.sessionTitle, + startedAt: $0.startedAt + ) + } + case .running: + await update(instanceID: instanceID, streamID: streamID) { + AgentRunActivityStateReducer.responding(state: $0) + } + case .waitingForApproval: + await approvalRequired(instanceID: instanceID, streamID: streamID) + case .done: + await finish(instanceID: instanceID, streamID: streamID, status: .complete, message: String(localized: "Response complete")) + case .error, .interrupted: + await finish(instanceID: instanceID, streamID: streamID, status: .failed, message: String(localized: "Response failed")) + case .cancelled: + await finish(instanceID: instanceID, streamID: streamID, status: .cancelled, message: String(localized: "Response cancelled")) + } + } + + func appendResponse(_ text: String, instanceID: String, streamID: String) async { + await update(instanceID: instanceID, streamID: streamID) { state in + let updated = AgentRunActivityStateReducer.appendingToken( + includesResponseExcerpts ? text : " ", + to: state + ) + return includesResponseExcerpts + ? updated + : AgentRunActivityStateReducer.clearingResponseExcerpt(state: updated) + } + } + + func reasoning(instanceID: String, streamID: String) async { + await update(instanceID: instanceID, streamID: streamID) { + AgentRunActivityStateReducer.reasoning("", state: $0) + } + } + + func toolStarted(name: String?, instanceID: String, streamID: String) async { + await update(instanceID: instanceID, streamID: streamID) { + AgentRunActivityStateReducer.toolStarted(name: name, state: $0) + } + } + + func toolFinished(instanceID: String, streamID: String) async { + await update(instanceID: instanceID, streamID: streamID) { + AgentRunActivityStateReducer.toolCompleted(state: $0) + } + } + + func approvalRequired(instanceID: String, streamID: String) async { + await update(instanceID: instanceID, streamID: streamID) { + AgentRunActivityStateReducer.waitingForApproval(state: $0) + } + } + + func markStale(instanceID: String, streamID: String) async { + await update(instanceID: instanceID, streamID: streamID) { + AgentRunActivityStateReducer.stale(state: $0) + } + } + + func markAllStale() async { + for activity in Activity.activities where isLive(activity) { + let state = AgentRunActivityStateReducer.stale(state: state(for: activity)) + stateByActivityID[activity.id] = state + await activity.update(content(for: state)) + } + } + + func endAll(forInstanceID instanceID: String) async { + for activity in Activity.activities + where activity.attributes.instanceID == instanceID && isLive(activity) { + let state = AgentRunActivityStateReducer.final( + status: .failed, + activity: String(localized: "Connection revoked"), + state: state(for: activity) + ) + stateByActivityID[activity.id] = state + await activity.end(content(for: state, staleDate: nil), dismissalPolicy: .immediate) + stateByActivityID[activity.id] = nil + if currentActivity?.id == activity.id { currentActivity = nil } + } + } + + func finish( + instanceID: String, + streamID: String, + status: AgentRunActivityStatus, + message: String, + errorSummary: String? = nil + ) async { + guard let activity = activity(instanceID: instanceID, streamID: streamID) else { return } + let state = AgentRunActivityStateReducer.final( + status: status, + activity: message, + state: state(for: activity), + errorSummary: errorSummary + ) + stateByActivityID[activity.id] = state + let policy: ActivityUIDismissalPolicy = status == .complete + ? .after(Date().addingTimeInterval(300)) + : .after(Date().addingTimeInterval(30)) + await activity.end(content(for: state, staleDate: nil), dismissalPolicy: policy) + stateByActivityID[activity.id] = nil + if currentActivity?.id == activity.id { currentActivity = nil } + } + + /// Reconciles activities persisted by iOS after relaunch against the pinned, + /// authenticated Aiden client. An unreachable server leaves state stale. + func reconcile( + instanceID: String, + client: AidenRemoteClient, + isCurrent: @MainActor () -> Bool + ) async { + for activity in Activity.activities + where activity.attributes.instanceID == instanceID && isLive(activity) { + guard isCurrent() else { return } + guard let streamID = activity.attributes.streamID else { + await activity.end(nil, dismissalPolicy: .immediate) + stateByActivityID[activity.id] = nil + continue + } + if stateByActivityID[activity.id] == nil { + stateByActivityID[activity.id] = activity.content.state + } + do { + let status = try await client.streamStatus(id: streamID) + guard isCurrent() else { return } + currentActivity = activity + await updateStatus(instanceID: instanceID, streamID: streamID, state: status.state) + } catch { + guard isCurrent() else { return } + let state = AgentRunActivityStateReducer.stale(state: state(for: activity)) + stateByActivityID[activity.id] = state + await activity.update(content(for: state)) + } + } + } + + private func update( + instanceID: String, + streamID: String, + transform: (AgentRunActivityAttributes.ContentState) -> AgentRunActivityAttributes.ContentState + ) async { + guard let activity = activity(instanceID: instanceID, streamID: streamID), isLive(activity) else { return } + currentActivity = activity + var state = transform(state(for: activity)) + if !includesResponseExcerpts, !state.responseExcerpt.isEmpty { + state = AgentRunActivityStateReducer.clearingResponseExcerpt(state: state) + } + stateByActivityID[activity.id] = state + await activity.update(content(for: state)) + } + + private func state( + for activity: Activity + ) -> AgentRunActivityAttributes.ContentState { + if let state = stateByActivityID[activity.id] { return state } + let state = activity.content.state + stateByActivityID[activity.id] = state + return state + } + + private func activity(instanceID: String, streamID: String) -> Activity? { + if let currentActivity, + Self.matches(currentActivity.attributes, instanceID: instanceID, streamID: streamID), + isLive(currentActivity) { + return currentActivity + } + return Activity.activities.first { + Self.matches($0.attributes, instanceID: instanceID, streamID: streamID) && isLive($0) + } + } + + private func isLive(_ activity: Activity) -> Bool { + activity.activityState == .active || activity.activityState == .stale + } + + private func content( + for state: AgentRunActivityAttributes.ContentState, + staleDate: Date? = Date().addingTimeInterval(300) + ) -> ActivityContent { + ActivityContent(state: state, staleDate: state.isFinal ? nil : staleDate) + } +} diff --git a/ios/AidenOnTheGo/Models/AidenChat.swift b/ios/AidenOnTheGo/Models/AidenChat.swift new file mode 100644 index 00000000..01c67cba --- /dev/null +++ b/ios/AidenOnTheGo/Models/AidenChat.swift @@ -0,0 +1,687 @@ +import Foundation +import ImageIO + +enum AidenChatRole: String, Codable, Sendable { + case user + case assistant +} + +struct AidenChatMessage: Codable, Identifiable, Equatable, Sendable { + let id: String + let role: AidenChatRole + let text: String + let attachments: [AidenMessageAttachment]? + let outcome: AidenMessageOutcome? + let timeline: AidenGenerationTimeline? + let createdAt: Date + + init( + id: String, + role: AidenChatRole, + text: String, + attachments: [AidenMessageAttachment]? = nil, + outcome: AidenMessageOutcome? = nil, + timeline: AidenGenerationTimeline? = nil, + createdAt: Date + ) { + self.id = id + self.role = role + self.text = text + self.attachments = attachments + self.outcome = outcome + self.timeline = timeline + self.createdAt = createdAt + } +} + +enum AidenAgentStepStatus: String, Codable, Sendable { + case pending + case awaitingApproval = "awaiting_approval" + case running + case completed + case failed + case blocked + case cancelled + + var isActive: Bool { + self == .pending || self == .awaitingApproval || self == .running + } + + var isIssue: Bool { + self == .failed || self == .blocked || self == .cancelled + } +} + +enum AidenGenerationTimelineStatus: String, Codable, Sendable { + case running + case completed + case failed + case cancelled +} + +struct AidenAgentLineChanges: Codable, Equatable, Sendable { + let additions: Int + let deletions: Int +} + +struct AidenAgentStep: Codable, Identifiable, Equatable, Sendable { + enum Kind: String, Codable, Sendable { + case tool + case thinking + } + + let id: String + let order: Int + let kind: Kind + let toolName: String? + let label: String? + let status: AidenAgentStepStatus? + let startedAt: Double + let updatedAt: Double + let finishedAt: Double? + let contentOffset: Int? + let durationMs: Double? + let target: String? + let detail: String? + let lineChanges: AidenAgentLineChanges? + + var isActive: Bool { + kind == .thinking ? finishedAt == nil : status?.isActive == true + } +} + +struct AidenGenerationTimeline: Codable, Equatable, Sendable { + let version: Int + let generationId: String + let status: AidenGenerationTimelineStatus + let startedAt: Double + let finishedAt: Double? + let steps: [AidenAgentStep] + + var issueCount: Int { + steps.filter { $0.kind == .tool && $0.status?.isIssue == true }.count + } + + var isRendererSafe: Bool { + guard [1, 2, 3].contains(version), + !generationId.isEmpty, + generationId.count <= 128, + steps.count <= 200, + startedAt.isFinite, + startedAt >= 0, + finishedAt.map({ $0.isFinite && $0 >= 0 }) ?? true + else { return false } + return steps.enumerated().allSatisfy { index, step in + guard step.order == index, + step.id.count <= 128, + step.startedAt.isFinite, + step.updatedAt.isFinite, + step.startedAt >= 0, + step.updatedAt >= 0, + step.finishedAt.map({ $0.isFinite && $0 >= 0 }) ?? true, + step.contentOffset.map({ $0 >= 0 }) ?? true, + step.durationMs.map({ $0.isFinite && $0 >= 0 }) ?? true, + step.label.map({ !$0.isEmpty && $0.count <= 120 }) ?? true, + step.toolName.map({ !$0.isEmpty && $0.count <= 80 }) ?? true, + step.detail.map({ value in + !value.isEmpty && value.count <= 120 && !value.contains(where: { $0.isNewline }) + }) ?? true, + step.target.map({ value in + let normalized = value.replacingOccurrences(of: "\\", with: "/") + let hasDrivePrefix = normalized.count >= 3 + && normalized[normalized.index(after: normalized.startIndex)] == ":" + && normalized.first?.isLetter == true + && normalized[normalized.index(normalized.startIndex, offsetBy: 2)] == "/" + return !value.isEmpty && value.count <= 240 + && !normalized.hasPrefix("/") && !normalized.hasPrefix("~") + && !hasDrivePrefix && !normalized.split(separator: "/").contains("..") + }) ?? true + else { return false } + return step.kind == .thinking || (step.toolName != nil && step.label != nil && step.status != nil) + } + } +} + +enum AidenAgentActivityPresentation { + private static let verbs: [String: (active: String, complete: String)] = [ + "read_file": ("Reading", "Read"), + "list_dir": ("Listing", "Listed"), + "glob": ("Searching files", "Searched files"), + "grep": ("Grepping", "Grepped"), + "write_file": ("Writing", "Wrote"), + "edit_file": ("Editing", "Edited"), + "run_command": ("Running", "Ran"), + "web_search": ("Searching the web", "Searched the web"), + "schedule_task": ("Scheduling", "Scheduled"), + "edit_automation": ("Editing automation", "Edited automation"), + "computer_use": ("Using Mac", "Used Mac"), + "compact_context": ("Compacting context", "Compacted context"), + ] + + static func duration(_ milliseconds: Double?) -> String { + guard let milliseconds, milliseconds >= 2_000 else { return "briefly" } + let seconds = Int((milliseconds / 1_000).rounded()) + guard seconds >= 60 else { return "for \(seconds)s" } + let minutes = seconds / 60 + let remainder = seconds % 60 + return remainder == 0 ? "for \(minutes)m" : "for \(minutes)m \(remainder)s" + } + + static func line(for step: AidenAgentStep) -> String { + if step.kind == .thinking { + return step.isActive ? "Thinking" : "Thought \(duration(step.durationMs))" + } + let label = step.label ?? "Tool" + let pair = verbs[step.toolName ?? ""] + let verb: String + switch step.status { + case .pending, .running: + verb = pair?.active ?? label + case .completed: + verb = pair?.complete ?? label + case .awaitingApproval: + verb = "\(label) needs approval" + case .failed: + verb = "\(label) failed" + case .blocked: + verb = "\(label) denied" + case .cancelled: + verb = "\(label) cancelled" + case nil: + verb = label + } + let object: String? + if step.toolName == "grep", let detail = step.detail, let target = step.target { + object = "\(detail) in \(target)" + } else { + object = step.detail ?? step.target + } + return object.map { "\(verb) \($0)" } ?? verb + } + + static func summary(_ timeline: AidenGenerationTimeline) -> String { + let tools = timeline.steps.filter { $0.kind == .tool } + guard !tools.isEmpty else { + return timeline.status == .running ? "Thinking" : "Thought \(duration(timeline.steps.compactMap(\.durationMs).reduce(0, +)))" + } + let running = timeline.status == .running + let files = tools.filter { $0.toolName == "read_file" }.count + let searches = tools.filter { $0.toolName == "grep" || $0.toolName == "glob" }.count + let directories = tools.filter { $0.toolName == "list_dir" }.count + let commands = tools.filter { $0.toolName == "run_command" }.count + let changes = tools.filter { $0.toolName == "write_file" || $0.toolName == "edit_file" }.count + let web = tools.filter { $0.toolName == "web_search" }.count + let mac = tools.filter { $0.toolName == "computer_use" }.count + let compactions = tools.filter { $0.toolName == "compact_context" }.count + let tallied = Set([ + "read_file", "grep", "glob", "list_dir", "run_command", "write_file", "edit_file", + "web_search", "computer_use", "compact_context", + ]) + let other = tools.filter { step in + guard let toolName = step.toolName else { return true } + return !tallied.contains(toolName) + }.count + var clauses: [String] = [] + let explored = [ + files > 0 ? "\(files) file\(files == 1 ? "" : "s")" : nil, + searches > 0 ? "\(searches) search\(searches == 1 ? "" : "es")" : nil, + directories > 0 ? "\(directories) director\(directories == 1 ? "y" : "ies")" : nil, + ].compactMap { $0 } + if !explored.isEmpty { clauses.append("\(running ? "Exploring" : "Explored") \(explored.joined(separator: ", "))") } + 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 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")" } + let sentence = clauses.joined(separator: ", ") + guard explored.isEmpty, let first = sentence.first else { return sentence } + return first.uppercased() + sentence.dropFirst() + } +} + +enum AidenMessageOutcomeStatus: String, Codable, Sendable { + case failed + case cancelled +} + +struct AidenMessageOutcome: Codable, Equatable, Sendable { + let status: AidenMessageOutcomeStatus + let category: String? + let attempts: Int? + let retryExhausted: Bool? +} + +enum AidenAttachmentKind: String, Codable, Sendable { + case image + case text +} + +struct AidenMessageAttachment: Codable, Identifiable, Equatable, Sendable { + let id: String + let name: String + let mimeType: String + let kind: AidenAttachmentKind + let size: Int +} + +struct AidenAttachmentContent: Equatable, Sendable { + let data: Data + let mimeType: String +} + +enum AidenAttachmentImageValidation { + static let maximumBytes = 8 * 1_048_576 + static let maximumDimension = 16_384 + static let maximumPixels = 40_000_000 + + static func validatedData( + _ data: Data, + mimeType: String, + declaredSize: Int? = nil + ) -> Data? { + guard !data.isEmpty, + data.count <= maximumBytes, + declaredSize.map({ $0 == data.count }) ?? true, + hasMatchingSignature(data, mimeType: mimeType), + let source = CGImageSourceCreateWithData(data as CFData, nil), + CGImageSourceGetCount(source) == 1, + CGImageSourceGetStatus(source) == .statusComplete, + CGImageSourceGetStatusAtIndex(source, 0) == .statusComplete, + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int, + width > 0, + height > 0, + width <= maximumDimension, + height <= maximumDimension, + width <= maximumPixels / height, + CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 2, + kCGImageSourceShouldCacheImmediately: true, + ] as CFDictionary) != nil + else { return nil } + return data + } + + private static func hasMatchingSignature(_ data: Data, mimeType: String) -> Bool { + let header = [UInt8](data.prefix(8)) + switch mimeType.lowercased() { + case "image/png": + return header == [137, 80, 78, 71, 13, 10, 26, 10] + && [UInt8](data.suffix(12)) == [0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130] + case "image/jpeg": + let trailer = [UInt8](data.suffix(2)) + return header.count >= 3 + && header[0] == 0xff + && header[1] == 0xd8 + && header[2] == 0xff + && trailer == [0xff, 0xd9] + default: + return false + } + } +} + +struct AidenAttachmentReference: Codable, Identifiable, Equatable, Sendable { + let id: String + let name: String + let mimeType: String + let kind: AidenAttachmentKind + let size: Int + let expiresAt: Date + + func isValid(now: Date = Date()) -> Bool { + guard id.range( + of: #"^att_[A-Za-z0-9_-]{43}$"#, + options: .regularExpression + ) != nil, + expiresAt > now, + size >= 0, + size <= 8 * 1_048_576, + !name.isEmpty, + name.unicodeScalars.count <= 255, + name.unicodeScalars.allSatisfy({ scalar in + scalar.value > 0x1f && scalar.value != 0x7f && scalar != "/" && scalar != "\\" + }), + !mimeType.isEmpty, + mimeType.unicodeScalars.count <= 120 + else { return false } + + switch kind { + case .image: + return mimeType == "image/jpeg" || mimeType == "image/png" + case .text: + return size <= 400_000 && Self.allowedTextMimeTypes.contains(mimeType) + } + } + + private static let allowedTextMimeTypes: Set = [ + "text/plain", "text/markdown", "text/csv", "application/json", "application/xml", + "application/yaml", "application/x-yaml", "application/javascript", "application/typescript", + ] +} + +enum AidenAttachmentUpload: Encodable, Equatable, Sendable { + case image(name: String, mimeType: String, data: Data) + case text(name: String, mimeType: String, text: String) + + private enum CodingKeys: String, CodingKey { + case name, mimeType, kind, data, text + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .image(let name, let mimeType, let data): + try container.encode(name, forKey: .name) + try container.encode(mimeType, forKey: .mimeType) + try container.encode(AidenAttachmentKind.image.rawValue, forKey: .kind) + try container.encode(data.base64EncodedString(), forKey: .data) + case .text(let name, let mimeType, let text): + try container.encode(name, forKey: .name) + try container.encode(mimeType, forKey: .mimeType) + try container.encode(AidenAttachmentKind.text.rawValue, forKey: .kind) + try container.encode(text, forKey: .text) + } + } +} + +struct AidenChat: Codable, Identifiable, Equatable, Sendable { + let id: String + var workspaceId: String + var title: String + var providerId: String? + var modelId: String? + var messages: [AidenChatMessage] + let createdAt: Date + var updatedAt: Date + var revision: String + var titlePending: Bool? = nil + + var isTitlePending: Bool { titlePending == true } +} + +struct AidenModel: Codable, Identifiable, Equatable, Sendable { + let id: String + let label: String + let thinkingLevels: [String]? + let defaultThinkingLevel: String? + let thinkingCanDisable: Bool? + let hidden: Bool? + + var isHidden: Bool { hidden == true } + + var effectiveThinkingLevel: String? { + guard let thinkingLevels, !thinkingLevels.isEmpty else { return nil } + if let defaultThinkingLevel, thinkingLevels.contains(defaultThinkingLevel) { + return defaultThinkingLevel + } + if thinkingLevels.contains("medium") { return "medium" } + if thinkingLevels.contains("high") { return "high" } + if thinkingLevels.contains("low") { return "low" } + if thinkingLevels.contains("off") { return "off" } + return thinkingLevels.first + } + + func thinkingLabel(for level: String) -> String { + level == "off" && thinkingCanDisable == false ? "Hide" : level.capitalized + } +} + +struct AidenProvider: Codable, Identifiable, Equatable, Sendable { + let id: String + let label: String + let artwork: AidenProviderArtwork? + let models: [AidenModel] + + init( + id: String, + label: String, + artwork: AidenProviderArtwork? = nil, + models: [AidenModel] + ) { + self.id = id + self.label = label + self.artwork = artwork + self.models = models + } +} + +struct AidenProviderArtwork: Codable, Equatable, Sendable { + let mimeType: String + let dataBase64: String + + var boundedPNGData: Data? { + guard mimeType == "image/png", + dataBase64.count <= 44_000, + let data = Data(base64Encoded: dataBase64), + data.count <= 32 * 1024 + else { return nil } + let header = [UInt8](data.prefix(24)) + guard header.count == 24, + Array(header[0..<8]) == [137, 80, 78, 71, 13, 10, 26, 10], + Array(header[12..<16]) == [73, 72, 68, 82] + else { return nil } + let dimension: (Int) -> UInt32 = { offset in + (UInt32(header[offset]) << 24) + | (UInt32(header[offset + 1]) << 16) + | (UInt32(header[offset + 2]) << 8) + | UInt32(header[offset + 3]) + } + let width = dimension(16) + let height = dimension(20) + guard width > 0, height > 0, width <= 64, height <= 64 else { return nil } + return data + } +} + +extension AidenProvider { + var visibleModels: [AidenModel] { models.filter { !$0.isHidden } } +} + +struct AidenModelCatalog: Codable, Equatable, Sendable { + let providers: [AidenProvider] + let defaults: [String: String] +} + +extension AidenModelCatalog { + var visibleProviders: [AidenProvider] { + providers.compactMap { provider in + let models = provider.visibleModels + return models.isEmpty ? nil : AidenProvider( + id: provider.id, + label: provider.label, + artwork: provider.artwork, + models: models + ) + } + } +} + +struct AidenUsageTokens: Codable, Equatable, Sendable { + let input: Int + let output: Int + let cacheRead: Int + let cacheWrite: Int + let cacheWrite1h: Int? + let reasoning: Int + let total: Int +} + +struct AidenUsageTotals: Codable, Equatable, Sendable { + let requests: Int + let completedRequests: Int + let failedRequests: Int + let cancelledRequests: Int + let reportedTokenRequests: Int + let unmeteredRequests: Int + let localRequests: Int + let costedRequests: Int + let unpricedHostedRequests: Int + let hostedCostUsd: Double + let activeDays: Int + let currentStreak: Int + let longestStreak: Int + let tokens: AidenUsageTokens +} + +struct AidenUsageDay: Codable, Equatable, Sendable { + let date: String + let requests: Int + let reportedTokenRequests: Int + let unmeteredRequests: Int + let tokens: AidenUsageTokens + let hostedCostUsd: Double +} + +struct AidenUsageModel: Codable, Equatable, Identifiable, Sendable { + let providerId: String + let providerLabel: String + let modelId: String + let modelLabel: String + let local: Bool + let requests: Int + let reportedTokenRequests: Int + let unmeteredRequests: Int + let tokens: AidenUsageTokens + let hostedCostUsd: Double + + var id: String { "\(providerId):\(modelId):\(local)" } +} + +struct AidenUsageSummary: Codable, Equatable, Sendable { + let range: String + let startDate: String + let endDate: String + let totals: AidenUsageTotals + let days: [AidenUsageDay] + let models: [AidenUsageModel] +} + +struct AidenTurnStart: Encodable, Equatable, Sendable { + let text: String + let providerId: String? + let modelId: String? + let thinkingLevel: String? + let attachmentIds: [String]? + + init( + text: String, + providerId: String? = nil, + modelId: String? = nil, + thinkingLevel: String? = nil, + attachmentIds: [String]? = nil + ) { + self.text = text + self.providerId = providerId + self.modelId = modelId + self.thinkingLevel = thinkingLevel + self.attachmentIds = attachmentIds + } +} + +struct AidenTurnStartResponse: Decodable, Equatable, Sendable { + let turnId: String + let streamId: String + let status: String + let message: AidenChatMessage +} + +enum AidenStreamState: String, Codable, Sendable { + case queued + case running + case waitingForApproval = "waiting_for_approval" + case reconciling + case done + case error + case cancelled + case interrupted + + var isTerminal: Bool { + switch self { + case .done, .error, .cancelled, .interrupted: true + default: false + } + } +} + +struct AidenStreamStatus: Codable, Equatable, Sendable { + let streamId: String + let chatId: String + let turnId: String + let state: AidenStreamState + let lastSequence: Int + let updatedAt: Date +} + +struct AidenStreamPendingApproval: Codable, Equatable, Sendable { + let approvalId: String + let streamId: String + let chatId: String + let summary: String + let toolCallId: String + let toolName: String + let expiresAt: Date + let canAllow: Bool +} + +struct AidenStreamApprovalSnapshot: Codable, Equatable, Sendable { + let approval: AidenStreamPendingApproval? +} + +enum AidenApprovalDecision: String, Codable, Sendable { + case allow + case deny +} + +struct AidenApprovalResponse: Codable, Equatable, Sendable { + let approvalId: String + let decision: AidenApprovalDecision + let resolvedAt: Date +} + +struct AidenPendingApproval: Identifiable, Equatable, Sendable { + let id: String + let summary: String + let expiresAt: Date + let canAllow: Bool +} + +enum AidenApprovalPresentation { + static func oneLineSummary(_ summary: String) -> String { + let collapsed = summary + .split(whereSeparator: \.isWhitespace) + .joined(separator: " ") + return collapsed.isEmpty ? String(localized: "Review requested action") : collapsed + } +} + +enum AidenPendingApprovalResolution { + static func resolve( + _ approval: AidenStreamPendingApproval?, + streamId: String, + chatId: String, + now: Date = Date() + ) -> AidenPendingApproval? { + guard let approval, + approval.streamId == streamId, + approval.chatId == chatId, + approval.expiresAt > now else { return nil } + return AidenPendingApproval( + id: approval.approvalId, + summary: approval.summary, + expiresAt: approval.expiresAt, + canAllow: approval.canAllow + ) + } +} + +struct AidenLiveTool: Identifiable, Equatable, Sendable { + let id: String + let name: String + var status: String? +} diff --git a/ios/AidenOnTheGo/Models/AidenInstallation.swift b/ios/AidenOnTheGo/Models/AidenInstallation.swift new file mode 100644 index 00000000..33ca9e4c --- /dev/null +++ b/ios/AidenOnTheGo/Models/AidenInstallation.swift @@ -0,0 +1,230 @@ +import Foundation +import Observation + +struct AidenInstallation: Codable, Identifiable, Equatable, Sendable { + let instanceId: String + let deviceId: String + var name: String + let endpoint: URL + let serverSpkiSha256: String + let pairingTrust: AidenRemoteContractFixture.PairingTrust? + let credentialScope: String + var capabilities: [AidenRemoteCapability] + let createdAt: Date + var lastConnectedAt: Date? + + var id: String { instanceId } + + init( + exchange: AidenRemoteContractFixture.PairingExchange, + pairingTrust: AidenRemoteContractFixture.PairingTrust, + name: String, + createdAt: Date = Date(), + lastConnectedAt: Date? = nil + ) { + instanceId = exchange.instanceId + deviceId = exchange.deviceId + self.name = name + endpoint = exchange.endpoint + serverSpkiSha256 = exchange.serverSpkiSha256 + self.pairingTrust = pairingTrust + credentialScope = Self.makeCredentialScope( + instanceId: exchange.instanceId, + deviceId: exchange.deviceId + ) + capabilities = exchange.capabilities + self.createdAt = createdAt + self.lastConnectedAt = lastConnectedAt + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + instanceId = try values.decode(String.self, forKey: .instanceId) + deviceId = try values.decode(String.self, forKey: .deviceId) + name = try values.decode(String.self, forKey: .name) + endpoint = try values.decode(URL.self, forKey: .endpoint) + serverSpkiSha256 = try values.decode(String.self, forKey: .serverSpkiSha256) + pairingTrust = try values.decodeIfPresent( + AidenRemoteContractFixture.PairingTrust.self, + forKey: .pairingTrust + ) + credentialScope = try values.decodeIfPresent(String.self, forKey: .credentialScope) + ?? instanceId + capabilities = try values.decode([AidenRemoteCapability].self, forKey: .capabilities) + createdAt = try values.decode(Date.self, forKey: .createdAt) + lastConnectedAt = try values.decodeIfPresent(Date.self, forKey: .lastConnectedAt) + } + + private enum CodingKeys: String, CodingKey { + case instanceId, deviceId, name, endpoint, serverSpkiSha256, pairingTrust, credentialScope + case capabilities, createdAt, lastConnectedAt + } + + private static func makeCredentialScope(instanceId: String, deviceId: String) -> String { + "\(instanceId):\(deviceId)" + } +} + +@MainActor +@Observable +final class AidenInstallationStore { + private struct Snapshot: Codable { + var installations: [AidenInstallation] + var activeInstallationId: String? + } + + private let keychain: any KeychainStoring + private(set) var installations: [AidenInstallation] + private(set) var activeInstallationId: String? + + var activeInstallation: AidenInstallation? { + guard let activeInstallationId else { return nil } + return installations.first { $0.id == activeInstallationId } + } + + init(keychain: any KeychainStoring = KeychainStore()) { + self.keychain = keychain + let snapshot = Self.loadSnapshot(from: keychain) + installations = snapshot.installations + activeInstallationId = snapshot.activeInstallationId.flatMap { candidate in + snapshot.installations.contains(where: { $0.id == candidate }) ? candidate : nil + } + } + + func credential(for installation: AidenInstallation) throws -> String? { + try keychain.load(.remoteCredential, scope: installation.credentialScope) + } + + func savePairing( + _ exchange: AidenRemoteContractFixture.PairingExchange, + trust: AidenRemoteContractFixture.PairingTrust, + name: String, + now: Date = Date(), + connectedAt: Date? = nil + ) throws -> AidenInstallation { + let existing = installations.first { $0.id == exchange.instanceId } + let installation = AidenInstallation( + exchange: exchange, + pairingTrust: trust, + name: name, + createdAt: existing?.createdAt ?? now, + lastConnectedAt: connectedAt ?? existing?.lastConnectedAt + ) + + // The scoped credential write must succeed before the installation is + // advertised as usable in the registry. + try keychain.save( + exchange.credential, + forKey: .remoteCredential, + scope: installation.credentialScope + ) + + var updated = installations.filter { $0.id != installation.id } + updated.append(installation) + updated.sort(by: Self.sortInstallations) + + let previousInstallations = installations + let previousActiveId = activeInstallationId + installations = updated + activeInstallationId = installation.id + do { + try persist() + } catch { + installations = previousInstallations + activeInstallationId = previousActiveId + try? keychain.delete(.remoteCredential, scope: installation.credentialScope) + throw error + } + if let existing, existing.credentialScope != installation.credentialScope { + try? keychain.delete(.remoteCredential, scope: existing.credentialScope) + } + return installation + } + + func setActive(_ installationId: String) throws { + guard installations.contains(where: { $0.id == installationId }) else { return } + guard activeInstallationId != installationId else { return } + let previous = activeInstallationId + activeInstallationId = installationId + do { + try persist() + } catch { + activeInstallationId = previous + throw error + } + } + + func updateServer(_ server: AidenServer, connectedAt: Date = Date()) throws { + guard let index = installations.firstIndex(where: { $0.id == server.instanceId }) else { return } + let previousInstallations = installations + installations[index].name = server.name + installations[index].capabilities = server.capabilities + installations[index].lastConnectedAt = connectedAt + installations.sort(by: Self.sortInstallations) + do { + try persist() + } catch { + installations = previousInstallations + throw error + } + } + + private static func sortInstallations( + _ lhs: AidenInstallation, + _ rhs: AidenInstallation + ) -> Bool { + if lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedSame { + return lhs.id < rhs.id + } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + + func remove(_ installationId: String) throws { + guard installations.contains(where: { $0.id == installationId }) else { return } + let previousInstallations = installations + let previousActiveId = activeInstallationId + installations.removeAll { $0.id == installationId } + if activeInstallationId == installationId { + activeInstallationId = installations.first?.id + } + do { + try persist() + } catch { + installations = previousInstallations + activeInstallationId = previousActiveId + throw error + } + if let removed = previousInstallations.first(where: { $0.id == installationId }) { + try? keychain.delete(.remoteCredential, scope: removed.credentialScope) + } + } + + private func persist() throws { + let snapshot = Snapshot( + installations: installations, + activeInstallationId: activeInstallationId + ) + let data = try JSONEncoder().encode(snapshot) + guard let value = String(data: data, encoding: .utf8) else { + throw CocoaError(.fileWriteInapplicableStringEncoding) + } + try keychain.save(value, forKey: .remoteInstallations) + } + + private static func loadSnapshot(from keychain: any KeychainStoring) -> Snapshot { + guard let value = try? keychain.load(.remoteInstallations), + let data = value.data(using: .utf8), + let snapshot = try? JSONDecoder().decode(Snapshot.self, from: data) else { + return Snapshot(installations: [], activeInstallationId: nil) + } + + var seen = Set() + let installations = snapshot.installations.filter { installation in + !installation.instanceId.isEmpty && seen.insert(installation.instanceId).inserted + } + return Snapshot( + installations: installations, + activeInstallationId: snapshot.activeInstallationId + ) + } +} diff --git a/ios/AidenOnTheGo/Models/AidenScheduledTask.swift b/ios/AidenOnTheGo/Models/AidenScheduledTask.swift new file mode 100644 index 00000000..313c3219 --- /dev/null +++ b/ios/AidenOnTheGo/Models/AidenScheduledTask.swift @@ -0,0 +1,195 @@ +import Foundation + +enum AidenScheduledTaskMode: String, Codable, CaseIterable, Sendable { + case llm + case script + + var title: String { self == .llm ? String(localized: "Ask Aiden") : String(localized: "Run Script") } +} + +enum AidenScheduledTaskPermission: String, Codable, CaseIterable, Sendable { + case readOnly = "read-only" + case full + + var title: String { self == .readOnly ? String(localized: "Read Only") : String(localized: "Full") } +} + +enum AidenScheduledTaskResult: String, Codable, Sendable { + case success, error, silent, blocked +} + +struct AidenScheduledTask: Codable, Identifiable, Equatable, Sendable { + let id: String + let revision: String + let name: String + let enabled: Bool + let schedule: String + let timezone: String + let mode: AidenScheduledTaskMode + let permission: AidenScheduledTaskPermission + let workspaceId: String? + let providerId: String? + let modelId: String? + let mcpServerIds: [String]? + let scriptId: String? + let prompt: String? + let notify: Bool + let running: Bool + let nextRunAt: Date? + let lastRunAt: Date? + let lastResult: AidenScheduledTaskResult? + let createdAt: Date + let updatedAt: Date +} + +struct AidenScheduledTaskMutation: Encodable, Equatable, Sendable { + let name: String + let schedule: String + let timezone: String + let mode: AidenScheduledTaskMode + let permission: AidenScheduledTaskPermission + let workspaceId: String? + let providerId: String? + let modelId: String? + let mcpServerIds: [String]? + let scriptId: String? + let prompt: String? + let notify: Bool + let confirmedForeground = true +} + +struct AidenScheduledRunAccepted: Codable, Equatable, Sendable { + let taskId: String + let runId: String + let status: String + let acceptedAt: Date +} + +struct AidenScheduledRun: Codable, Identifiable, Equatable, Sendable { + let id: String + let taskId: String + let status: String + let startedAt: Date + let finishedAt: Date? + let summary: String? + let errorCode: String? +} + +struct AidenScheduledScript: Codable, Identifiable, Equatable, Sendable { + let id: String + let name: String +} + +struct AidenScheduledMcpServer: Codable, Identifiable, Equatable, Sendable { + let id: String + let name: String +} + +struct AidenScheduledPreview: Codable, Equatable, Sendable { + let dates: [Date] +} + +struct AidenScheduledSettings: Codable, Equatable, Sendable { + let revision: String + let enabled: Bool + let defaultMode: AidenScheduledTaskMode + let defaultPermission: AidenScheduledTaskPermission + let defaultMcpEnabled: Bool + let defaultNotify: Bool + let defaultTimezone: String +} + +struct AidenScheduledSettingsMutation: Encodable, Equatable, Sendable { + let enabled: Bool? + let defaultMode: AidenScheduledTaskMode? + let defaultPermission: AidenScheduledTaskPermission? + let defaultMcpEnabled: Bool? + let defaultNotify: Bool? + let defaultTimezone: String? + let confirmedForeground = true +} + +struct AidenScheduledTaskDraft: Equatable, Sendable { + var name = "" + var schedule = "" + var timezone = TimeZone.current.identifier + var mode: AidenScheduledTaskMode = .llm + var permission: AidenScheduledTaskPermission = .readOnly + var workspaceId: String? + var providerId: String? + var modelId: String? + var mcpServerIds = Set() + var scriptId: String? + var prompt = "" + var notify = true + + init() {} + + init(task: AidenScheduledTask) { + name = task.name + schedule = task.schedule + timezone = task.timezone + mode = task.mode + permission = task.permission + workspaceId = task.workspaceId + providerId = task.providerId + modelId = task.modelId + mcpServerIds = Set(task.mcpServerIds ?? []) + scriptId = task.scriptId + prompt = task.prompt ?? "" + notify = task.notify + } + + var validationMessage: String? { + if name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return String(localized: "Name is required.") } + if schedule.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return String(localized: "Schedule is required.") } + if timezone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return String(localized: "Timezone is required.") } + if mode == .llm && prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return String(localized: "Prompt is required.") } + if mode == .script && scriptId == nil { return String(localized: "Choose a script from Aiden Agent.") } + if mode == .script && permission != .full { return String(localized: "Script tasks require Full permission.") } + return nil + } + + var mutation: AidenScheduledTaskMutation { + AidenScheduledTaskMutation( + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + schedule: schedule.trimmingCharacters(in: .whitespacesAndNewlines), + timezone: timezone.trimmingCharacters(in: .whitespacesAndNewlines), + mode: mode, + permission: permission, + workspaceId: workspaceId, + providerId: mode == .llm ? providerId : nil, + modelId: mode == .llm ? modelId : nil, + mcpServerIds: mode == .llm && !mcpServerIds.isEmpty ? mcpServerIds.sorted() : nil, + scriptId: mode == .script ? scriptId : nil, + prompt: mode == .llm ? prompt.trimmingCharacters(in: .whitespacesAndNewlines) : nil, + notify: notify + ) + } +} + +enum AidenScheduledTaskValidation { + static func tasks(_ tasks: [AidenScheduledTask]) throws -> [AidenScheduledTask] { + guard tasks.count <= 10_000 else { throw AidenRemoteClientError.invalidResponse } + var ids = Set() + for task in tasks { + guard !task.id.isEmpty, task.id.count <= 160, ids.insert(task.id).inserted, + task.revision.hasPrefix("rev_"), !task.name.isEmpty, task.name.count <= 120, + !task.schedule.isEmpty, task.schedule.count <= 500, + !task.timezone.isEmpty, task.timezone.count <= 120, + task.prompt.map({ $0.count <= 32_768 }) ?? true, + task.scriptId.map({ $0.hasPrefix("script_") && $0.count == 50 }) ?? true else { + throw AidenRemoteClientError.invalidResponse + } + } + return tasks + } + + static func runs(_ runs: [AidenScheduledRun], taskId: String) throws -> [AidenScheduledRun] { + guard runs.count <= 50, + runs.allSatisfy({ $0.taskId == taskId && $0.summary.map({ $0.count <= 20_000 }) ?? true }) else { + throw AidenRemoteClientError.invalidResponse + } + return runs + } +} diff --git a/ios/AidenOnTheGo/Models/AidenWorkspaceEnvironment.swift b/ios/AidenOnTheGo/Models/AidenWorkspaceEnvironment.swift new file mode 100644 index 00000000..51fbb9e0 --- /dev/null +++ b/ios/AidenOnTheGo/Models/AidenWorkspaceEnvironment.swift @@ -0,0 +1,250 @@ +import Foundation + +enum AidenWorkspaceFileKind: String, Codable, Sendable { + case file + case directory + case symlink +} + +struct AidenWorkspaceFileEntry: Codable, Identifiable, Equatable, Sendable { + let id: String + let displayPath: String + let name: String + let kind: AidenWorkspaceFileKind + let size: Int? + let language: String? +} + +struct AidenWorkspaceFileIndex: Codable, Equatable, Sendable { + let snapshotId: String + let entries: [AidenWorkspaceFileEntry] + let truncated: Bool + let maxEntries: Int + let maxDepth: Int +} + +struct AidenWorkspaceFileDocument: Codable, Equatable, Sendable { + let id: String + let displayPath: String + let content: String + let version: String + let truncated: Bool + let warning: String? +} + +enum AidenGitFileStatus: String, Codable, Sendable { + case added, modified, deleted, renamed, untracked, conflicted +} + +struct AidenGitFile: Codable, Identifiable, Equatable, Sendable { + let id: String + let displayPath: String + let status: AidenGitFileStatus + let staged: Bool? + let additions: Int? + let deletions: Int? +} + +struct AidenGitCapability: Codable, Equatable, Sendable { + let allowed: Bool + let reason: String? +} + +struct AidenGitReview: Codable, Equatable, Sendable { + let kind: String + let branch: String + let uncommitted: Int + let files: [AidenGitFile] +} + +struct AidenGitDiff: Codable, Identifiable, Equatable, Sendable { + let kind: String + let displayPath: String + let diff: String + let truncated: Bool + + var id: String { displayPath } +} + +struct AidenGitBranches: Codable, Equatable, Sendable { + let kind: String + let current: String + let branches: [String] +} + +struct AidenGitComparison: Codable, Equatable, Sendable { + let kind: String + let comparisonId: String + let base: String + let head: String + let files: [AidenGitFile] +} + +struct AidenGitPushCapability: Codable, Equatable, Sendable { + let kind: String + let allowed: Bool + let reason: String? + let remote: String? + let branch: String? +} + +struct AidenGitWorktree: Codable, Identifiable, Equatable, Sendable { + let id: String + let name: String + let branch: String + let managed: Bool +} + +struct AidenGitWorktrees: Codable, Equatable, Sendable { + let kind: String + let worktrees: [AidenGitWorktree] +} + +struct AidenGitMutation: Codable, Equatable, Sendable { + let kind: String + let message: String + let branch: String? + let commitId: String? + let workspaceId: String? + let warning: String? +} + +enum AidenGitProjection: Decodable, Equatable, Sendable { + case review(AidenGitReview) + case diff(AidenGitDiff) + case branches(AidenGitBranches) + case comparison(AidenGitComparison) + case pushCapability(AidenGitPushCapability) + case worktrees(AidenGitWorktrees) + case mutation(AidenGitMutation) + + private enum CodingKeys: String, CodingKey { case kind } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + switch try values.decode(String.self, forKey: .kind) { + case "review": self = .review(try AidenGitReview(from: decoder)) + case "diff": self = .diff(try AidenGitDiff(from: decoder)) + case "branches": self = .branches(try AidenGitBranches(from: decoder)) + case "comparison": self = .comparison(try AidenGitComparison(from: decoder)) + case "push-capability": self = .pushCapability(try AidenGitPushCapability(from: decoder)) + case "worktrees": self = .worktrees(try AidenGitWorktrees(from: decoder)) + case "mutation": self = .mutation(try AidenGitMutation(from: decoder)) + default: throw AidenRemoteClientError.invalidResponse + } + } +} + +enum AidenGitOperationStatus: String, Codable, Sendable { + case snapshot, accepted, running, succeeded, failed, conflict +} + +struct AidenGitResult: Decodable, Equatable, Sendable { + let operationId: String + let status: AidenGitOperationStatus + let snapshotId: String? + let capability: AidenGitCapability? + let result: AidenGitProjection? +} + +struct AidenGitDiffRequest: Encodable { let snapshotId: String; let fileId: String } +struct AidenGitCreateBranchRequest: Encodable { + let name: String + let startPoint: String + let confirmedForeground = true +} +struct AidenGitCheckoutRequest: Encodable { + let branch: String + let snapshotId: String + let confirmedForeground = true +} +struct AidenGitCommitRequest: Encodable { + let snapshotId: String + let message: String + let scope: String + let confirmedForeground = true +} +struct AidenGitPushRequest: Encodable { + let snapshotId: String + let remote: String + let branch: String + let confirmedForeground = true +} +struct AidenGitCompareRequest: Encodable { let baseRef: String } +struct AidenGitComparisonDiffRequest: Encodable { let comparisonId: String; let fileId: String } +struct AidenGitCreateWorktreeRequest: Encodable { + let branch: String + let name: String + let confirmedForeground = true +} +struct AidenForegroundConfirmation: Encodable { let confirmedForeground = true } +struct AidenWorkspaceFileWriteRequest: Encodable { let content: String; let expectedVersion: String } + +enum AidenWorkspaceEnvironmentValidation { + static func opaqueFileID(_ value: String) -> Bool { + value.range(of: #"^file_[A-Za-z0-9_-]{43}$"#, options: .regularExpression) != nil + } + + static func safeDisplayPath(_ value: String) -> Bool { + !value.isEmpty && !value.hasPrefix("/") && !value.split(separator: "/", omittingEmptySubsequences: false).contains("..") + } + + static func validated(_ index: AidenWorkspaceFileIndex) throws -> AidenWorkspaceFileIndex { + guard index.maxEntries == 4_000, + index.maxDepth == 20, + index.entries.count <= index.maxEntries, + index.entries.allSatisfy({ entry in + opaqueFileID(entry.id) && safeDisplayPath(entry.displayPath) && !entry.name.isEmpty + }) else { + throw AidenRemoteClientError.invalidResponse + } + return index + } + + static func validated(_ document: AidenWorkspaceFileDocument, expectedID: String) throws -> AidenWorkspaceFileDocument { + guard document.id == expectedID, + opaqueFileID(document.id), + safeDisplayPath(document.displayPath), + !document.version.isEmpty, + !document.truncated else { + throw AidenRemoteClientError.invalidResponse + } + return document + } + + static func validated(_ git: AidenGitResult) throws -> AidenGitResult { + guard git.operationId.hasPrefix("op_"), git.operationId.count <= 128 else { + throw AidenRemoteClientError.invalidResponse + } + let validFiles: ([AidenGitFile]) -> Bool = { files in + files.count <= 4_000 && files.allSatisfy { + opaqueFileID($0.id) && safeDisplayPath($0.displayPath) + } + } + switch git.result { + case .review(let review): + guard git.snapshotId != nil, validFiles(review.files), review.uncommitted >= 0 else { + throw AidenRemoteClientError.invalidResponse + } + case .diff(let diff): + guard git.snapshotId != nil, safeDisplayPath(diff.displayPath), diff.diff.count <= 2_000_000 else { + throw AidenRemoteClientError.invalidResponse + } + case .branches: + guard git.snapshotId != nil else { throw AidenRemoteClientError.invalidResponse } + case .comparison(let comparison): + guard git.snapshotId == comparison.comparisonId, validFiles(comparison.files) else { + throw AidenRemoteClientError.invalidResponse + } + case .pushCapability: + guard git.snapshotId != nil else { throw AidenRemoteClientError.invalidResponse } + case .worktrees(let worktrees): + guard worktrees.worktrees.allSatisfy({ !$0.id.isEmpty && !$0.name.isEmpty && !$0.branch.isEmpty }) else { + throw AidenRemoteClientError.invalidResponse + } + case .mutation, .none: + break + } + return git + } +} diff --git a/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift new file mode 100644 index 00000000..4ed3fcf6 --- /dev/null +++ b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift @@ -0,0 +1,1247 @@ +import CryptoKit +import Foundation + +enum AidenDeviceType: String, Codable, Sendable { + case iphone + case ipad +} + +enum AidenConnectionMode: String, Codable, Sendable { + case lan + case tailscale + case both +} + +struct AidenServer: Codable, Equatable, Sendable { + let protocolVersion: Int + let instanceId: String + let name: String + let appVersion: String + let capabilities: [AidenRemoteCapability] + let connectionMode: AidenConnectionMode + let minimumClientVersion: String? + let serverTime: Date +} + +enum AidenWorkspacePermission: String, Codable, CaseIterable, Sendable { + case full + case ask + case none +} + +struct AidenWorkspaceGitSummary: Codable, Equatable, Sendable { + let isRepo: Bool + let branch: String? + let uncommitted: Int? +} + +struct AidenWorkspace: Codable, Identifiable, Equatable, Sendable { + let id: String + var name: String + var permission: AidenWorkspacePermission + let hasFolder: Bool + let isManagedWorktree: Bool + let branchName: String? + let repositoryName: String? + let git: AidenWorkspaceGitSummary? + let createdAt: Date + let updatedAt: Date + let revision: String +} + +enum AidenWorkspaceCreate: Encodable, Equatable, Sendable { + case folderless(name: String) + case scratch + case selectedFolder(selection: String, name: String?) + + private enum CodingKeys: String, CodingKey { + case mode, name, selection + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .folderless(let name): + try container.encode("folderless", forKey: .mode) + try container.encode(name, forKey: .name) + case .scratch: + try container.encode("scratch", forKey: .mode) + case .selectedFolder(let selection, let name): + try container.encode("selected-folder", forKey: .mode) + try container.encode(selection, forKey: .selection) + try container.encodeIfPresent(name, forKey: .name) + } + } +} + +struct AidenWorkspacePatch: Encodable, Equatable, Sendable { + let name: String? + let permission: AidenWorkspacePermission? + let confirmedForeground = true + + init(name: String? = nil, permission: AidenWorkspacePermission? = nil) { + self.name = name + self.permission = permission + } +} + +struct AidenBrowserRoot: Codable, Identifiable, Equatable, Sendable { + let id: String + let label: String + let location: String + let policyRevision: String +} + +struct AidenBrowserBreadcrumb: Codable, Equatable, Sendable { + let label: String + let location: String +} + +struct AidenBrowserEntry: Codable, Identifiable, Equatable, Sendable { + let id: String + let name: String + let location: String +} + +struct AidenBrowserPage: Codable, Equatable, Sendable { + let rootId: String + let label: String + let breadcrumbs: [AidenBrowserBreadcrumb] + let entries: [AidenBrowserEntry] + let nextCursor: String? +} + +struct AidenWorkspaceSelection: Codable, Equatable, Sendable { + let selection: String + let displayName: String + let expiresAt: Date +} + +enum AidenRemoteClientError: Error, LocalizedError { + case invalidEndpoint + case invalidResponse + case unexpectedStatus(Int) + case server(statusCode: Int, body: AidenRemoteErrorEnvelope.Body) + case missingCredential + case missingTrustConfiguration + case installationChanged + + var isCredentialRevoked: Bool { + guard case .server(_, let body) = self else { return false } + return body.code.rawValue == "credential_revoked" + } + + var errorDescription: String? { + switch self { + case .invalidEndpoint: + return "The Aiden Agent address is invalid." + case .invalidResponse: + return "Aiden Agent returned an invalid response." + case .unexpectedStatus(let status): + return "Aiden Agent returned HTTP status \(status)." + case .server(_, let body): + return body.message + case .missingCredential: + return "This Aiden installation needs to be paired again." + 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." + } + } +} + +struct AidenManualPairingResult { + let payload: AidenRemoteContractFixture.PairingPayload + let exchange: AidenRemoteContractFixture.PairingExchange +} + +final class AidenRemoteClient: @unchecked Sendable { + private struct EmptyRequest: Encodable {} + + private struct PairingExchangeRequest: Encodable { + let secret: String + let deviceName: String + let deviceType: AidenDeviceType + let clientVersion: String + let acceptsDisplayName: Bool? + } + + private struct WorkspaceList: Decodable { + let workspaces: [AidenWorkspace] + } + + private struct BrowserRootList: Decodable { + let roots: [AidenBrowserRoot] + } + + private struct BrowserSelectionRequest: Encodable { + let location: String + } + + private struct ChatList: Decodable { + let chats: [AidenChat] + } + + private struct ChatCreateRequest: Encodable { + let workspaceId: String + let providerId: String? + let modelId: String? + } + + private struct ChatUpdateRequest: Encodable { + let title: String + } + + private struct ChatMoveRequest: Encodable { + let workspaceId: String + let confirmedForeground = true + } + + private struct ApprovalRequest: Encodable { + let decision: AidenApprovalDecision + } + + private struct ScheduledTaskList: Decodable { let tasks: [AidenScheduledTask] } + private struct ScheduledRunList: Decodable { let runs: [AidenScheduledRun] } + private struct ScheduledScriptList: Decodable { let scripts: [AidenScheduledScript] } + private struct ScheduledMcpServerList: Decodable { let servers: [AidenScheduledMcpServer] } + private struct ScheduledPreviewRequest: Encodable { + let cron: String + let timezone: String + let count: Int + } + + private let endpoint: URL + private let credential: String? + private let session: URLSession + + init( + installation: AidenInstallation, + credential: String, + waitsForConnectivity: Bool = true, + requestTimeout: TimeInterval = 30 + ) throws { + guard let pairingTrust = installation.pairingTrust else { + throw AidenRemoteClientError.missingTrustConfiguration + } + endpoint = installation.endpoint + self.credential = credential + session = Self.makePinnedSession( + endpoint: installation.endpoint, + fingerprint: installation.serverSpkiSha256, + trustPolicy: try AidenServerTrustPolicy(pairingTrust: pairingTrust), + waitsForConnectivity: waitsForConnectivity, + requestTimeout: requestTimeout + ) + } + + init(endpoint: URL, credential: String?, session: URLSession) { + self.endpoint = endpoint + self.credential = credential + self.session = session + } + + static func pair( + payload: AidenRemoteContractFixture.PairingPayload, + deviceName: String, + deviceType: AidenDeviceType, + clientVersion: String, + session injectedSession: URLSession? = nil, + now: Date = Date() + ) async throws -> AidenRemoteContractFixture.PairingExchange { + let payload = try payload.validated(at: now) + let bootstrap = payload.bootstrap + let session: URLSession + if let injectedSession { + session = injectedSession + } else { + session = makePinnedSession( + endpoint: bootstrap.endpoint, + fingerprint: bootstrap.serverSpkiSha256, + trustPolicy: try AidenServerTrustPolicy(pairingTrust: payload.trust) + ) + } + let client = AidenRemoteClient(endpoint: bootstrap.endpoint, credential: nil, session: session) + let request = PairingExchangeRequest( + secret: bootstrap.secret, + deviceName: deviceName, + deviceType: deviceType, + clientVersion: clientVersion, + acceptsDisplayName: true + ) + let exchange: AidenRemoteContractFixture.PairingExchange + do { + exchange = try await client.send( + method: "POST", + path: ["pairing", "exchange"], + body: request, + authenticated: false, + acceptedStatus: [200] + ) + } catch let AidenRemoteClientError.server(statusCode, body) + where statusCode == 400 && body.code.rawValue == "invalid_request" { + // Strict early-v1 Macs 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", + path: ["pairing", "exchange"], + body: PairingExchangeRequest( + secret: bootstrap.secret, + deviceName: deviceName, + deviceType: deviceType, + clientVersion: clientVersion, + acceptsDisplayName: nil + ), + authenticated: false, + acceptedStatus: [200] + ) + } + return try exchange.validated(against: bootstrap) + } + + static func pair( + manualCode: String, + endpoint: URL, + deviceName: String, + deviceType: AidenDeviceType, + clientVersion: String, + bootstrapSession injectedBootstrapSession: URLSession? = nil, + pairingSession injectedPairingSession: URLSession? = nil, + now: Date = Date() + ) async throws -> AidenManualPairingResult { + let payload = try await manualPairingPayload( + code: manualCode, + endpoint: endpoint, + session: injectedBootstrapSession, + now: now + ) + let exchange = try await pair( + payload: payload, + deviceName: deviceName, + deviceType: deviceType, + clientVersion: clientVersion, + session: injectedPairingSession, + now: now + ) + return AidenManualPairingResult(payload: payload, exchange: exchange) + } + + static func manualPairingPayload( + code: String, + endpoint: URL, + session injectedSession: URLSession? = nil, + now: Date = Date() + ) async throws -> AidenRemoteContractFixture.PairingPayload { + let normalizedCode = try normalizeManualPairingCode(code) + guard isCanonicalAidenEndpoint(endpoint) else { + throw AidenRemoteClientError.invalidEndpoint + } + let session = injectedSession ?? makeSealedBootstrapSession(endpoint: endpoint) + let client = AidenRemoteClient(endpoint: endpoint, credential: nil, session: session) + let sealed: AidenRemoteContractFixture.ManualPairingBootstrap = try await client.send( + method: "POST", + path: ["pairing", "manual-bootstrap"], + body: EmptyRequest(), + authenticated: false, + acceptedStatus: [200], + maximumResponseBytes: AidenRemoteProtocol.maxPairingPayloadBytes * 2 + ) + _ = try sealed.validated(at: now) + + let inputKey = SymmetricKey(data: Data(normalizedCode.utf8)) + let key = HKDF.deriveKey( + inputKeyMaterial: inputKey, + salt: sealed.salt, + info: sealed.keyDerivationInfo, + outputByteCount: 32 + ) + let nonce: AES.GCM.Nonce + let sealedBox: AES.GCM.SealedBox + do { + nonce = try AES.GCM.Nonce(data: sealed.nonce) + sealedBox = try AES.GCM.SealedBox( + nonce: nonce, + ciphertext: sealed.ciphertext, + tag: sealed.tag + ) + } catch { + throw AidenManualPairingError.invalidBootstrap + } + let plaintext: Data + do { + plaintext = try AES.GCM.open( + sealedBox, + using: key, + authenticating: sealed.additionalAuthenticatedData + ) + } catch { + throw AidenManualPairingError.decryptionFailed + } + guard plaintext.count <= AidenRemoteProtocol.maxPairingPayloadBytes else { + throw AidenRemoteContractError.payloadTooLarge + } + let payload = try AidenRemoteJSONDecoder.decodePairingPayload(from: plaintext) + _ = try payload.validated(at: now) + guard payload.bootstrap.endpoint.absoluteString == endpoint.absoluteString, + payload.bootstrap.expiresAt == sealed.expiresAt else { + throw AidenManualPairingError.endpointMismatch + } + return payload + } + + static func normalizeManualPairingCode(_ value: String) throws -> String { + guard value.unicodeScalars.allSatisfy({ scalar in + scalar.isASCII && (scalar.value == 32 || scalar.value == 45 + || (48...57).contains(scalar.value) + || (65...90).contains(scalar.value) + || (97...122).contains(scalar.value)) + }) else { + throw AidenManualPairingError.invalidCode + } + let normalized = value + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: " ", with: "") + .uppercased() + let alphabet = Set("0123456789ABCDEFGHJKMNPQRSTVWXYZ".utf8) + guard normalized.utf8.count == 20, + normalized.unicodeScalars.allSatisfy({ $0.isASCII }), + normalized.utf8.allSatisfy(alphabet.contains) else { + throw AidenManualPairingError.invalidCode + } + return normalized + } + + func server() async throws -> AidenServer { + let value: AidenServer = try await send(method: "GET", path: ["server"]) + guard value.protocolVersion == AidenRemoteProtocol.version else { + throw AidenRemoteContractError.invalidProtocolVersion + } + return value + } + + func workspaces() async throws -> [AidenWorkspace] { + let value: WorkspaceList = try await send(method: "GET", path: ["workspaces"]) + return value.workspaces + } + + func workspace(id: String) async throws -> AidenWorkspace { + try await send(method: "GET", path: ["workspaces", id]) + } + + func createWorkspace( + _ create: AidenWorkspaceCreate, + idempotencyKey: UUID = UUID() + ) async throws -> AidenWorkspace { + try await send( + method: "POST", + path: ["workspaces"], + body: create, + headers: ["Idempotency-Key": idempotencyKey.uuidString.lowercased()], + acceptedStatus: [201] + ) + } + + func updateWorkspace( + id: String, + revision: String, + patch: AidenWorkspacePatch + ) async throws -> AidenWorkspace { + guard patch.name != nil || patch.permission != nil else { + throw AidenRemoteClientError.invalidResponse + } + return try await send( + method: "PATCH", + path: ["workspaces", id], + body: patch, + headers: ["If-Match": revision] + ) + } + + func removeWorkspace(id: String, revision: String) async throws { + try await sendWithoutResponse( + method: "DELETE", + path: ["workspaces", id], + headers: ["If-Match": revision], + acceptedStatus: [204] + ) + } + + func browserRoots() async throws -> [AidenBrowserRoot] { + let value: BrowserRootList = try await send( + method: "GET", + path: ["workspace-browser", "roots"] + ) + return value.roots + } + + func browserChildren(location: String, cursor: String? = nil) async throws -> AidenBrowserPage { + var query = [URLQueryItem(name: "location", value: location)] + if let cursor { query.append(URLQueryItem(name: "cursor", value: cursor)) } + return try await send( + method: "GET", + path: ["workspace-browser", "children"], + query: query + ) + } + + func createWorkspaceSelection(location: String) async throws -> AidenWorkspaceSelection { + try await send( + method: "POST", + path: ["workspace-browser", "selections"], + body: BrowserSelectionRequest(location: location), + acceptedStatus: [201] + ) + } + + func chats(workspaceId: String? = nil) async throws -> [AidenChat] { + let query = workspaceId.map { [URLQueryItem(name: "workspaceId", value: $0)] } ?? [] + let value: ChatList = try await send(method: "GET", path: ["chats"], query: query) + return value.chats + } + + func usage(range: String = "30d") async throws -> AidenUsageSummary { + guard ["7d", "30d", "90d", "1y", "all"].contains(range) else { + throw AidenRemoteClientError.invalidResponse + } + return try await send( + method: "GET", + path: ["usage"], + query: [URLQueryItem(name: "range", value: range)] + ) + } + + func chat(id: String) async throws -> AidenChat { + try await send(method: "GET", path: ["chats", id]) + } + + func createChat( + workspaceId: String, + providerId: String? = nil, + modelId: String? = nil, + idempotencyKey: UUID = UUID() + ) async throws -> AidenChat { + try await send( + method: "POST", + path: ["chats"], + body: ChatCreateRequest( + workspaceId: workspaceId, + providerId: providerId, + modelId: modelId + ), + headers: ["Idempotency-Key": idempotencyKey.uuidString.lowercased()], + acceptedStatus: [201] + ) + } + + func updateChat(id: String, revision: String, title: String) async throws -> AidenChat { + try await send( + method: "PATCH", + path: ["chats", id], + body: ChatUpdateRequest(title: title), + headers: ["If-Match": revision] + ) + } + + func removeChat(id: String, revision: String) async throws { + try await sendWithoutResponse( + method: "DELETE", + path: ["chats", id], + headers: ["If-Match": revision], + acceptedStatus: [204] + ) + } + + func uploadAttachment( + chatId: String, + upload: AidenAttachmentUpload + ) async throws -> AidenAttachmentReference { + try await send( + method: "POST", + path: ["chats", chatId, "attachments"], + body: upload, + acceptedStatus: [201] + ) + } + + func removeAttachment(chatId: String, attachmentId: String) async throws { + try await sendWithoutResponse( + method: "DELETE", + path: ["chats", chatId, "attachments", attachmentId], + headers: [:], + acceptedStatus: [204] + ) + } + + func attachmentContent(chatId: String, attachmentId: String) async throws -> AidenAttachmentContent { + var request = try makeRequest( + method: "GET", + path: ["chats", chatId, "attachments", attachmentId, "content"], + query: [], + body: nil, + headers: [:], + authenticated: true + ) + request.setValue("image/jpeg, image/png", forHTTPHeaderField: "Accept") + let (data, response) = try await boundedData( + for: request, + maximumBytes: AidenAttachmentImageValidation.maximumBytes + ) + try validate(response: response, data: data, acceptedStatus: [200]) + guard let response = response as? HTTPURLResponse, + let rawContentType = response.value(forHTTPHeaderField: "Content-Type"), + let contentType = rawContentType.split(separator: ";", maxSplits: 1).first? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + contentType == "image/jpeg" || contentType == "image/png" + else { throw AidenRemoteClientError.invalidResponse } + return AidenAttachmentContent(data: data, mimeType: contentType) + } + + func moveChat( + id: String, + revision: String, + workspaceId: String, + idempotencyKey: UUID = UUID() + ) async throws -> AidenChat { + try await send( + method: "POST", + path: ["chats", id, "move"], + body: ChatMoveRequest(workspaceId: workspaceId), + headers: [ + "If-Match": revision, + "Idempotency-Key": idempotencyKey.uuidString.lowercased(), + ] + ) + } + + func modelCatalog() async throws -> AidenModelCatalog { + try await send(method: "GET", path: ["models"]) + } + + func scheduledTasks() async throws -> [AidenScheduledTask] { + let value: ScheduledTaskList = try await send(method: "GET", path: ["scheduled-tasks"]) + return try AidenScheduledTaskValidation.tasks(value.tasks) + } + + func scheduledTask(id: String) async throws -> AidenScheduledTask { + let value: AidenScheduledTask = try await send(method: "GET", path: ["scheduled-tasks", id]) + return try AidenScheduledTaskValidation.tasks([value])[0] + } + + func createScheduledTask( + _ mutation: AidenScheduledTaskMutation, + idempotencyKey: UUID = UUID() + ) async throws -> AidenScheduledTask { + try await send( + method: "POST", path: ["scheduled-tasks"], body: mutation, + headers: idempotencyHeaders(idempotencyKey), acceptedStatus: [201] + ) + } + + func updateScheduledTask( + id: String, + revision: String, + mutation: AidenScheduledTaskMutation + ) async throws -> AidenScheduledTask { + try await send( + method: "PATCH", path: ["scheduled-tasks", id], body: mutation, + headers: ["If-Match": revision] + ) + } + + func removeScheduledTask(id: String, revision: String) async throws { + try await sendWithoutResponse( + method: "DELETE", path: ["scheduled-tasks", id], + headers: ["If-Match": revision], acceptedStatus: [204] + ) + } + + func pauseScheduledTask( + id: String, + revision: String, + idempotencyKey: UUID = UUID() + ) async throws -> AidenScheduledTask { + try await send( + method: "POST", path: ["scheduled-tasks", id, "pause"], + headers: ["If-Match": revision, "Idempotency-Key": idempotencyKey.uuidString.lowercased()], + acceptedStatus: [202] + ) + } + + func resumeScheduledTask( + id: String, + revision: String, + idempotencyKey: UUID = UUID() + ) async throws -> AidenScheduledTask { + try await send( + method: "POST", path: ["scheduled-tasks", id, "resume"], + headers: ["If-Match": revision, "Idempotency-Key": idempotencyKey.uuidString.lowercased()], + acceptedStatus: [202] + ) + } + + func runScheduledTask( + id: String, + idempotencyKey: UUID = UUID() + ) async throws -> AidenScheduledRunAccepted { + try await send( + method: "POST", path: ["scheduled-tasks", id, "run"], + headers: idempotencyHeaders(idempotencyKey), acceptedStatus: [202] + ) + } + + func scheduledRuns(taskId: String) async throws -> [AidenScheduledRun] { + let value: ScheduledRunList = try await send(method: "GET", path: ["scheduled-tasks", taskId, "runs"]) + return try AidenScheduledTaskValidation.runs(value.runs, taskId: taskId) + } + + func previewSchedule(cron: String, timezone: String, count: Int = 3) async throws -> [Date] { + let value: AidenScheduledPreview = try await send( + method: "POST", path: ["scheduled-tasks", "preview"], + body: ScheduledPreviewRequest(cron: cron, timezone: timezone, count: min(max(count, 1), 20)) + ) + guard value.dates.count <= 20 else { throw AidenRemoteClientError.invalidResponse } + return value.dates + } + + func scheduledScripts(workspaceId: String?) async throws -> [AidenScheduledScript] { + let query = workspaceId.map { [URLQueryItem(name: "workspaceId", value: $0)] } ?? [] + let value: ScheduledScriptList = try await send( + method: "GET", path: ["scheduled-tasks", "scripts"], query: query + ) + guard value.scripts.count <= 4_000, + value.scripts.allSatisfy({ $0.id.hasPrefix("script_") && $0.id.count == 50 && !$0.name.isEmpty }) else { + throw AidenRemoteClientError.invalidResponse + } + return value.scripts + } + + func scheduledMcpServers() async throws -> [AidenScheduledMcpServer] { + let value: ScheduledMcpServerList = try await send( + method: "GET", path: ["scheduled-tasks", "mcp-servers"] + ) + guard value.servers.count <= 4_000, + value.servers.allSatisfy({ !$0.id.isEmpty && $0.id.count <= 256 && !$0.name.isEmpty && $0.name.count <= 256 }), + Set(value.servers.map(\.id)).count == value.servers.count else { + throw AidenRemoteClientError.invalidResponse + } + return value.servers + } + + func scheduledSettings() async throws -> AidenScheduledSettings { + try await send(method: "GET", path: ["scheduled-tasks", "settings"]) + } + + func updateScheduledSettings( + revision: String, + mutation: AidenScheduledSettingsMutation + ) async throws -> AidenScheduledSettings { + try await send( + method: "PATCH", path: ["scheduled-tasks", "settings"], body: mutation, + headers: ["If-Match": revision] + ) + } + + func workspaceFiles(workspaceId: String) async throws -> AidenWorkspaceFileIndex { + let value: AidenWorkspaceFileIndex = try await send( + method: "GET", + path: ["workspaces", workspaceId, "files"] + ) + return try AidenWorkspaceEnvironmentValidation.validated(value) + } + + func workspaceFile(workspaceId: String, fileId: String) async throws -> AidenWorkspaceFileDocument { + guard AidenWorkspaceEnvironmentValidation.opaqueFileID(fileId) else { + throw AidenRemoteClientError.invalidResponse + } + let value: AidenWorkspaceFileDocument = try await send( + method: "GET", + path: ["workspaces", workspaceId, "files", fileId], + maximumResponseBytes: AidenRemoteProtocol.maxFileJSONBodyBytes + ) + return try AidenWorkspaceEnvironmentValidation.validated(value, expectedID: fileId) + } + + func writeWorkspaceFile( + workspaceId: String, + fileId: String, + content: String, + expectedVersion: String + ) async throws -> AidenWorkspaceFileDocument { + guard AidenWorkspaceEnvironmentValidation.opaqueFileID(fileId), !expectedVersion.isEmpty else { + throw AidenRemoteClientError.invalidResponse + } + let value: AidenWorkspaceFileDocument = try await send( + method: "PUT", + path: ["workspaces", workspaceId, "files", fileId], + body: AidenWorkspaceFileWriteRequest(content: content, expectedVersion: expectedVersion), + maximumResponseBytes: AidenRemoteProtocol.maxFileJSONBodyBytes + ) + return try AidenWorkspaceEnvironmentValidation.validated(value, expectedID: fileId) + } + + func gitReview(workspaceId: String) async throws -> AidenGitResult { + try validatedGit(await send(method: "GET", path: ["workspaces", workspaceId, "git", "review"])) + } + + func gitDiff(workspaceId: String, snapshotId: String, fileId: String) async throws -> AidenGitResult { + try validatedGit(await send( + method: "POST", + path: ["workspaces", workspaceId, "git", "diff"], + body: AidenGitDiffRequest(snapshotId: snapshotId, fileId: fileId) + )) + } + + func gitBranches(workspaceId: String) async throws -> AidenGitResult { + try validatedGit(await send(method: "GET", path: ["workspaces", workspaceId, "git", "branches"])) + } + + func createGitBranch( + workspaceId: String, + name: String, + startPoint: String, + idempotencyKey: UUID = UUID() + ) async throws -> AidenGitResult { + try validatedGit(await send( + method: "POST", + path: ["workspaces", workspaceId, "git", "branches"], + body: AidenGitCreateBranchRequest(name: name, startPoint: startPoint), + headers: idempotencyHeaders(idempotencyKey), + acceptedStatus: [202] + )) + } + + func checkoutGitBranch( + workspaceId: String, + branch: String, + snapshotId: String, + idempotencyKey: UUID = UUID() + ) async throws -> AidenGitResult { + try validatedGit(await send( + method: "POST", + path: ["workspaces", workspaceId, "git", "checkout"], + body: AidenGitCheckoutRequest(branch: branch, snapshotId: snapshotId), + headers: idempotencyHeaders(idempotencyKey), + acceptedStatus: [202] + )) + } + + func commitGit( + workspaceId: String, + snapshotId: String, + message: String, + stagedOnly: Bool, + idempotencyKey: UUID = UUID() + ) async throws -> AidenGitResult { + try validatedGit(await send( + method: "POST", + path: ["workspaces", workspaceId, "git", "commit"], + body: AidenGitCommitRequest( + snapshotId: snapshotId, + message: message, + scope: stagedOnly ? "staged-reviewed" : "all-reviewed" + ), + headers: idempotencyHeaders(idempotencyKey), + acceptedStatus: [202] + )) + } + + func gitPushCapability(workspaceId: String) async throws -> AidenGitResult { + try validatedGit(await send(method: "GET", path: ["workspaces", workspaceId, "git", "push-capability"])) + } + + func pushGit( + workspaceId: String, + snapshotId: String, + remote: String, + branch: String, + idempotencyKey: UUID = UUID() + ) async throws -> AidenGitResult { + try validatedGit(await send( + method: "POST", + path: ["workspaces", workspaceId, "git", "push"], + body: AidenGitPushRequest(snapshotId: snapshotId, remote: remote, branch: branch), + headers: idempotencyHeaders(idempotencyKey), + acceptedStatus: [202] + )) + } + + func compareGit(workspaceId: String, baseRef: String) async throws -> AidenGitResult { + try validatedGit(await send( + method: "POST", + path: ["workspaces", workspaceId, "git", "compare"], + body: AidenGitCompareRequest(baseRef: baseRef) + )) + } + + func gitComparisonDiff(workspaceId: String, comparisonId: String, fileId: String) async throws -> AidenGitResult { + try validatedGit(await send( + method: "POST", + path: ["workspaces", workspaceId, "git", "comparison-diff"], + body: AidenGitComparisonDiffRequest(comparisonId: comparisonId, fileId: fileId) + )) + } + + func gitWorktrees(workspaceId: String) async throws -> AidenGitResult { + try validatedGit(await send(method: "GET", path: ["workspaces", workspaceId, "git", "worktrees"])) + } + + func createGitWorktree( + workspaceId: String, + branch: String, + name: String, + idempotencyKey: UUID = UUID() + ) async throws -> AidenGitResult { + try validatedGit(await send( + method: "POST", + path: ["workspaces", workspaceId, "git", "worktrees"], + body: AidenGitCreateWorktreeRequest(branch: branch, name: name), + headers: idempotencyHeaders(idempotencyKey), + acceptedStatus: [202] + )) + } + + func deleteManagedGitWorktree( + workspaceId: String, + revision: String, + idempotencyKey: UUID = UUID() + ) async throws -> AidenGitResult { + try validatedGit(await send( + method: "DELETE", + path: ["workspaces", workspaceId, "git", "managed-worktree"], + body: AidenForegroundConfirmation(), + headers: [ + "If-Match": revision, + "Idempotency-Key": idempotencyKey.uuidString.lowercased(), + ], + acceptedStatus: [202] + )) + } + + func startTurn( + chatId: String, + request: AidenTurnStart, + idempotencyKey: UUID = UUID() + ) async throws -> AidenTurnStartResponse { + try await send( + method: "POST", + path: ["chats", chatId, "turns"], + body: request, + headers: ["Idempotency-Key": idempotencyKey.uuidString.lowercased()], + acceptedStatus: [202] + ) + } + + func streamStatus(id: String) async throws -> AidenStreamStatus { + try await send(method: "GET", path: ["streams", id]) + } + + func streamApproval(id: String) async throws -> AidenStreamApprovalSnapshot { + try await send(method: "GET", path: ["streams", id, "approval"]) + } + + func cancelStream(id: String, idempotencyKey: UUID = UUID()) async throws -> AidenStreamStatus { + try await send( + method: "POST", + path: ["streams", id, "cancel"], + headers: ["Idempotency-Key": idempotencyKey.uuidString.lowercased()], + acceptedStatus: [202] + ) + } + + func respondToApproval( + id: String, + decision: AidenApprovalDecision, + idempotencyKey: UUID = UUID() + ) async throws -> AidenApprovalResponse { + try await send( + method: "POST", + path: ["approvals", id, "respond"], + body: ApprovalRequest(decision: decision), + headers: ["Idempotency-Key": idempotencyKey.uuidString.lowercased()] + ) + } + + func streamEvents( + id: String, + after sequence: Int + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let query = sequence > 0 + ? [URLQueryItem(name: "after", value: String(sequence))] + : [] + var request = try makeRequest( + method: "GET", + path: ["streams", id, "events"], + query: query, + body: nil, + headers: sequence > 0 ? ["Last-Event-ID": String(sequence)] : [:], + authenticated: true + ) + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + let (bytes, response) = try await session.bytes(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw AidenRemoteClientError.invalidResponse + } + guard httpResponse.statusCode == 200 else { + var body = Data() + for try await byte in bytes { + guard body.count < AidenRemoteProtocol.maxJSONBodyBytes else { + throw AidenRemoteContractError.payloadTooLarge + } + body.append(byte) + } + try validate(response: response, data: body, acceptedStatus: [200]) + throw AidenRemoteClientError.unexpectedStatus(httpResponse.statusCode) + } + + var parser = AidenSSEParser() + var lineBytes: [UInt8] = [] + lineBytes.reserveCapacity(512) + for try await byte in bytes { + try Task.checkCancellation() + if byte == 0x0A { + if lineBytes.last == 0x0D { lineBytes.removeLast() } + guard let line = String(bytes: lineBytes, encoding: .utf8) else { + throw AidenRemoteClientError.invalidResponse + } + lineBytes.removeAll(keepingCapacity: true) + if let event = try parser.consume(line: line) { + continuation.yield(event) + } + } else { + lineBytes.append(byte) + guard lineBytes.count <= AidenRemoteProtocol.maxSSEFrameBytes else { + throw AidenSSEParserError.frameTooLarge + } + } + } + if !lineBytes.isEmpty { + if lineBytes.last == 0x0D { lineBytes.removeLast() } + guard let line = String(bytes: lineBytes, encoding: .utf8) else { + throw AidenRemoteClientError.invalidResponse + } + if let event = try parser.consume(line: line) { + continuation.yield(event) + } + } + if let event = try parser.finish() { + continuation.yield(event) + } + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { @Sendable _ in task.cancel() } + } + } + + private static func makePinnedSession( + endpoint: URL, + fingerprint: String, + trustPolicy: AidenServerTrustPolicy, + waitsForConnectivity: Bool = true, + requestTimeout: TimeInterval = 30 + ) -> URLSession { + let delegate = AidenPinnedServerSessionDelegate( + expectedHost: endpoint.host ?? "", + expectedPort: endpoint.port, + expectedFingerprint: fingerprint, + trustPolicy: trustPolicy + ) + let configuration = URLSessionConfiguration.ephemeral + configuration.waitsForConnectivity = waitsForConnectivity + configuration.timeoutIntervalForRequest = requestTimeout + configuration.timeoutIntervalForResource = 60 * 60 + configuration.httpCookieAcceptPolicy = .never + configuration.httpShouldSetCookies = false + return URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) + } + + private static func makeSealedBootstrapSession(endpoint: URL) -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.waitsForConnectivity = true + configuration.timeoutIntervalForRequest = 30 + configuration.timeoutIntervalForResource = 30 + configuration.httpCookieAcceptPolicy = .never + configuration.httpShouldSetCookies = false + return URLSession( + configuration: configuration, + delegate: AidenSealedBootstrapSessionDelegate(endpoint: endpoint), + delegateQueue: nil + ) + } + + private func send( + method: String, + path: [String], + query: [URLQueryItem] = [], + headers: [String: String] = [:], + authenticated: Bool = true, + acceptedStatus: Set = [200], + maximumResponseBytes: Int = AidenRemoteProtocol.maxJSONBodyBytes + ) async throws -> Response { + let request = try makeRequest( + method: method, + path: path, + query: query, + body: nil, + headers: headers, + authenticated: authenticated + ) + let (data, response) = try await boundedData( + for: request, + maximumBytes: maximumResponseBytes + ) + try validate(response: response, data: data, acceptedStatus: acceptedStatus) + do { + return try AidenRemoteJSONDecoder.decode(Response.self, from: data, maximumBytes: maximumResponseBytes) + } catch { + throw AidenRemoteClientError.invalidResponse + } + } + + private func send( + method: String, + path: [String], + query: [URLQueryItem] = [], + body: Body, + headers: [String: String] = [:], + authenticated: Bool = true, + acceptedStatus: Set = [200], + maximumResponseBytes: Int = AidenRemoteProtocol.maxJSONBodyBytes + ) async throws -> Response { + let encodedBody = try JSONEncoder().encode(body) + let request = try makeRequest( + method: method, + path: path, + query: query, + body: encodedBody, + headers: headers, + authenticated: authenticated + ) + let (data, response) = try await boundedData( + for: request, + maximumBytes: maximumResponseBytes + ) + try validate(response: response, data: data, acceptedStatus: acceptedStatus) + do { + return try AidenRemoteJSONDecoder.decode(Response.self, from: data, maximumBytes: maximumResponseBytes) + } catch { + throw AidenRemoteClientError.invalidResponse + } + } + + private func sendWithoutResponse( + method: String, + path: [String], + headers: [String: String], + acceptedStatus: Set + ) async throws { + let request = try makeRequest( + method: method, + path: path, + query: [], + body: nil, + headers: headers, + authenticated: true + ) + let (data, response) = try await boundedData( + for: request, + maximumBytes: AidenRemoteProtocol.maxJSONBodyBytes + ) + try validate(response: response, data: data, acceptedStatus: acceptedStatus) + } + + private func makeRequest( + method: String, + path: [String], + query: [URLQueryItem], + body: Data?, + headers: [String: String], + authenticated: Bool + ) throws -> URLRequest { + var url = endpoint + for component in path { + url.append(path: component) + } + if !query.isEmpty { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw AidenRemoteClientError.invalidEndpoint + } + components.queryItems = query + guard let queryURL = components.url else { throw AidenRemoteClientError.invalidEndpoint } + url = queryURL + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.httpBody = body + request.setValue(String(AidenRemoteProtocol.version), forHTTPHeaderField: "Aiden-Protocol-Version") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if body != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + if authenticated { + guard let credential, !credential.isEmpty else { + throw AidenRemoteClientError.missingCredential + } + request.setValue("Bearer \(credential)", forHTTPHeaderField: "Authorization") + } + for (name, value) in headers { + request.setValue(value, forHTTPHeaderField: name) + } + return request + } + + private func boundedData( + for request: URLRequest, + maximumBytes: Int + ) async throws -> (Data, URLResponse) { + let (bytes, response) = try await session.bytes(for: request) + if response.expectedContentLength > Int64(maximumBytes) { + throw AidenRemoteContractError.payloadTooLarge + } + var data = Data() + if response.expectedContentLength > 0 { + data.reserveCapacity(min(Int(response.expectedContentLength), maximumBytes)) + } + for try await byte in bytes { + guard data.count < maximumBytes else { + throw AidenRemoteContractError.payloadTooLarge + } + data.append(byte) + } + return (data, response) + } + + private func idempotencyHeaders(_ key: UUID) -> [String: String] { + ["Idempotency-Key": key.uuidString.lowercased()] + } + + private func validatedGit(_ result: AidenGitResult) throws -> AidenGitResult { + try AidenWorkspaceEnvironmentValidation.validated(result) + } + + private func validate( + response: URLResponse, + data: Data, + acceptedStatus: Set + ) throws { + guard let response = response as? HTTPURLResponse else { + throw AidenRemoteClientError.invalidResponse + } + guard acceptedStatus.contains(response.statusCode) else { + if let envelope = try? AidenRemoteJSONDecoder.decode(AidenRemoteErrorEnvelope.self, from: data) { + throw AidenRemoteClientError.server(statusCode: response.statusCode, body: envelope.error) + } + throw AidenRemoteClientError.unexpectedStatus(response.statusCode) + } + } +} diff --git a/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift b/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift new file mode 100644 index 00000000..d7e14a30 --- /dev/null +++ b/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift @@ -0,0 +1,1647 @@ +import Foundation + +enum AidenRemoteProtocol { + static let version = 1 + static let basePath = "/api/aiden/v1" + static let maxIdentifierLength = 128 + static let maxEndpointLength = 2_048 + static let maxEndpointPort = 65_535 + static let maxEventTypeLength = 80 + static let maxEventPayloadProperties = 32 + // The envelope is additively extensible. Its only key-count ceiling is the + // same whole-document abuse limit enforced by the TypeScript raw scanner; + // the event payload itself remains capped at 32 properties below. + static let maxEventEnvelopeProperties = 16_384 + static let maxJSONTotalObjectKeys = 16_384 + static let maxTextLength = 200_000 + static let maxToolNameLength = 120 + static let maxTimelineLabelLength = 500 + static let maxApprovalSummaryLength = 2_000 + static let maxErrorMessageLength = 2_000 + static let maxJSONBodyBytes = 1_048_576 + static let maxFileJSONBodyBytes = 6 * 1_048_576 + static let maxSSEFrameBytes = maxJSONBodyBytes + static let maxPairingPayloadBytes = 4_096 + static let maxSafeInteger = 9_007_199_254_740_991 + static let maxJSONNestingDepth = 128 + static let forbiddenWireKeys: Set = [ + "authorization", "credentialDigest", "providerFingerprint", "mcpServerBindings", + "folderPath", "repositoryPath", "worktreePath", "worktreeGitDir", + "ownershipToken", "worktreeDevice", "worktreeInode", "createdFromHead", + "canonicalPath", "absolutePath", "scriptPath", "environment", "stdout", "stderr", + ] +} + +enum AidenPairingBootstrapError: Error, Equatable { + case unsupportedProtocol + case invalidInstance + case invalidEndpoint + case invalidFingerprint + case weakSecret + case expired + case excessiveTTL +} + +enum AidenPairingPayloadError: Error, Equatable { + case invalidKind + case invalidTrust + case invalidCACertificateData +} + +enum AidenManualPairingError: Error, Equatable, LocalizedError { + case invalidCode + case invalidBootstrap + case decryptionFailed + case endpointMismatch + + var errorDescription: String? { + switch self { + case .invalidCode: + return String(localized: "Enter the 20-character setup code shown on your Mac.") + case .invalidBootstrap: + return String(localized: "Aiden Agent returned an invalid manual pairing response.") + case .decryptionFailed: + return String(localized: "The setup code is incorrect or belongs to a different pairing window.") + case .endpointMismatch: + return String(localized: "The setup code belongs to a different Aiden Agent address.") + } + } +} + +enum AidenRemoteContractError: Error, Equatable { + case duplicateJSONKey(String) + case invalidJSON + case unknownTerminalEvent(String) + case unsafePayloadField(String) + case payloadTooLarge + case unknownErrorCode(String) + case invalidTerminalClassification + case invalidProtocolVersion + case invalidStreamIdentity + case invalidSequence + case invalidPairingExchange +} + +private struct AidenDynamicCodingKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { return nil } +} + +private func assertKnownKeys( + _ container: KeyedDecodingContainer, + allowed: Set +) throws { + if let unsupported = container.allKeys.first(where: { !allowed.contains($0.stringValue) }) { + throw AidenRemoteContractError.unsafePayloadField(unsupported.stringValue) + } +} + +/// A JSON object member name after JSON escape processing, retaining its exact +/// Unicode scalar sequence for equality. `String` equality uses canonical +/// equivalence, while JSON member names are compared after unescaping only. +private struct AidenRawJSONKey: Hashable { + let scalars: [UInt32] + let displayValue: String + + init(_ value: String) { + scalars = value.unicodeScalars.map(\.value) + displayValue = value + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.scalars == rhs.scalars + } + + func hash(into hasher: inout Hasher) { + hasher.combine(scalars.count) + for scalar in scalars { + hasher.combine(scalar) + } + } +} + +/// Foundation's JSONDecoder does not provide a duplicate-member policy. Scan +/// the raw JSON first so object names cannot be overwritten by a later member, +/// including when one spelling uses JSON escapes and the other does not. +private struct AidenRawJSONDuplicateKeyScanner { + private let bytes: [UInt8] + private var offset = 0 + private var totalObjectKeys = 0 + + init(data: Data) { + bytes = Array(data) + } + + static func validate(_ data: Data) throws { + var scanner = Self(data: data) + try scanner.parseDocument() + } + + private mutating func parseDocument() throws { + try parseValue(depth: 0) + skipWhitespace() + guard offset == bytes.count else { throw AidenRemoteContractError.invalidJSON } + } + + private mutating func parseValue(depth: Int) throws { + guard depth <= AidenRemoteProtocol.maxJSONNestingDepth else { + throw AidenRemoteContractError.payloadTooLarge + } + skipWhitespace() + guard let byte = peek() else { throw AidenRemoteContractError.invalidJSON } + switch byte { + case 0x7B: // { + try parseObject(depth: depth) + case 0x5B: // [ + try parseArray(depth: depth) + case 0x22: // " + _ = try parseString() + case 0x74: // t + try parseLiteral([0x74, 0x72, 0x75, 0x65]) // true + case 0x66: // f + try parseLiteral([0x66, 0x61, 0x6C, 0x73, 0x65]) // false + case 0x6E: // n + try parseLiteral([0x6E, 0x75, 0x6C, 0x6C]) // null + case 0x2D, 0x30...0x39: // - or digit + try parseNumber() + default: + throw AidenRemoteContractError.invalidJSON + } + } + + private mutating func parseObject(depth: Int) throws { + try consume(0x7B) // { + skipWhitespace() + var keys = Set() + if consumeIf(0x7D) { return } // } + + while true { + skipWhitespace() + guard peek() == 0x22 else { throw AidenRemoteContractError.invalidJSON } + let key = try parseString() + totalObjectKeys += 1 + guard totalObjectKeys <= AidenRemoteProtocol.maxJSONTotalObjectKeys else { + throw AidenRemoteContractError.payloadTooLarge + } + guard keys.insert(key).inserted else { + throw AidenRemoteContractError.duplicateJSONKey(key.displayValue) + } + skipWhitespace() + try consume(0x3A) // : + try parseValue(depth: depth + 1) + skipWhitespace() + if consumeIf(0x2C) { continue } // , + try consume(0x7D) // } + return + } + } + + private mutating func parseArray(depth: Int) throws { + try consume(0x5B) // [ + skipWhitespace() + if consumeIf(0x5D) { return } // ] + + while true { + try parseValue(depth: depth + 1) + skipWhitespace() + if consumeIf(0x2C) { continue } // , + try consume(0x5D) // ] + return + } + } + + private mutating func parseLiteral(_ literal: [UInt8]) throws { + guard bytes.count - offset >= literal.count, + Array(bytes[offset..<(offset + literal.count)]) == literal else { + throw AidenRemoteContractError.invalidJSON + } + offset += literal.count + } + + private mutating func parseNumber() throws { + _ = consumeIf(0x2D) // - + + if consumeIf(0x30) { // 0 + if let next = peek(), next >= 0x30, next <= 0x39 { + throw AidenRemoteContractError.invalidJSON + } + } else { + guard let first = peek(), first >= 0x31, first <= 0x39 else { + throw AidenRemoteContractError.invalidJSON + } + offset += 1 + while let next = peek(), next >= 0x30, next <= 0x39 { offset += 1 } + } + + if consumeIf(0x2E) { // . + guard let first = peek(), first >= 0x30, first <= 0x39 else { + throw AidenRemoteContractError.invalidJSON + } + offset += 1 + while let next = peek(), next >= 0x30, next <= 0x39 { offset += 1 } + } + + if let next = peek(), next == 0x65 || next == 0x45 { // e/E + offset += 1 + _ = consumeIf(0x2B) // + + _ = consumeIf(0x2D) // - + guard let first = peek(), first >= 0x30, first <= 0x39 else { + throw AidenRemoteContractError.invalidJSON + } + offset += 1 + while let digit = peek(), digit >= 0x30, digit <= 0x39 { offset += 1 } + } + } + + private mutating func parseString() throws -> AidenRawJSONKey { + try consume(0x22) // " + var utf8: [UInt8] = [] + + while let byte = peek() { + offset += 1 + switch byte { + case 0x22: // " + guard let value = String(bytes: utf8, encoding: .utf8) else { + throw AidenRemoteContractError.invalidJSON + } + return AidenRawJSONKey(value) + case 0x5C: // \ + guard let escape = peek() else { throw AidenRemoteContractError.invalidJSON } + offset += 1 + switch escape { + case 0x22, 0x5C, 0x2F: // " \\ / + utf8.append(escape) + case 0x62: // b + utf8.append(0x08) + case 0x66: // f + utf8.append(0x0C) + case 0x6E: // n + utf8.append(0x0A) + case 0x72: // r + utf8.append(0x0D) + case 0x74: // t + utf8.append(0x09) + case 0x75: // u + let first = try readHexQuad() + if first >= 0xD800 && first <= 0xDBFF { + guard consumeIf(0x5C), consumeIf(0x75) else { + throw AidenRemoteContractError.invalidJSON + } + let second = try readHexQuad() + guard second >= 0xDC00 && second <= 0xDFFF else { + throw AidenRemoteContractError.invalidJSON + } + let scalar = 0x10000 + ((first - 0xD800) << 10) + (second - 0xDC00) + try appendScalar(scalar, to: &utf8) + } else if first >= 0xDC00 && first <= 0xDFFF { + throw AidenRemoteContractError.invalidJSON + } else { + try appendScalar(first, to: &utf8) + } + default: + throw AidenRemoteContractError.invalidJSON + } + default: + guard byte >= 0x20 else { throw AidenRemoteContractError.invalidJSON } + utf8.append(byte) + } + } + + throw AidenRemoteContractError.invalidJSON + } + + private mutating func readHexQuad() throws -> UInt32 { + guard bytes.count - offset >= 4 else { throw AidenRemoteContractError.invalidJSON } + var value: UInt32 = 0 + for _ in 0..<4 { + guard let digit = Self.hexValue(bytes[offset]) else { + throw AidenRemoteContractError.invalidJSON + } + value = (value << 4) | digit + offset += 1 + } + return value + } + + private func appendScalar(_ value: UInt32, to utf8: inout [UInt8]) throws { + guard let scalar = UnicodeScalar(value) else { + throw AidenRemoteContractError.invalidJSON + } + utf8.append(contentsOf: String(scalar).utf8) + } + + private static func hexValue(_ byte: UInt8) -> UInt32? { + switch byte { + case 0x30...0x39: return UInt32(byte - 0x30) + case 0x41...0x46: return UInt32(byte - 0x41 + 10) + case 0x61...0x66: return UInt32(byte - 0x61 + 10) + default: return nil + } + } + + private func peek() -> UInt8? { + guard offset < bytes.count else { return nil } + return bytes[offset] + } + + private mutating func consume(_ expected: UInt8) throws { + guard consumeIf(expected) else { throw AidenRemoteContractError.invalidJSON } + } + + private mutating func consumeIf(_ expected: UInt8) -> Bool { + guard peek() == expected else { return false } + offset += 1 + return true + } + + private mutating func skipWhitespace() { + while let byte = peek(), byte == 0x20 || byte == 0x09 || byte == 0x0A || byte == 0x0D { + offset += 1 + } + } +} + +private func boundedString( + _ container: KeyedDecodingContainer, + forKey key: Key, + maxLength: Int, + field: String, + required: Bool = false +) throws -> String? { + let value: String? + // `decodeIfPresent` treats an explicitly encoded JSON null the same as an + // absent member. Wire schemas distinguish those cases: null is invalid + // for every bounded string member, while only an absent optional member + // may decode to nil. + if required || container.contains(key) { + value = try container.decode(String.self, forKey: key) + } else { + value = nil + } + guard let value else { return nil } + guard !value.isEmpty, value.unicodeScalars.count <= maxLength else { + throw AidenRemoteContractError.unsafePayloadField(field) + } + return value +} + +private func decodeOptionalNonNull( + _ container: KeyedDecodingContainer, + _ type: Value.Type, + forKey key: Key +) throws -> Value? { + guard container.contains(key) else { return nil } + return try container.decode(type, forKey: key) +} + +private func isAidenASCIIAlphaNumeric(_ scalar: UnicodeScalar) -> Bool { + (scalar.value >= 48 && scalar.value <= 57) || + (scalar.value >= 65 && scalar.value <= 90) || + (scalar.value >= 97 && scalar.value <= 122) +} + +private func isAidenASCIIHex(_ scalar: UnicodeScalar) -> Bool { + (scalar.value >= 48 && scalar.value <= 57) || + (scalar.value >= 65 && scalar.value <= 70) || + (scalar.value >= 97 && scalar.value <= 102) +} + +private func isCanonicalAidenIPv4(_ value: String) -> Bool { + let octets = value.split(separator: ".", omittingEmptySubsequences: false) + guard octets.count == 4 else { return false } + return octets.allSatisfy { octet in + let scalars = Array(octet.unicodeScalars) + guard !scalars.isEmpty, + scalars.count <= 3, + scalars.allSatisfy({ $0.value >= 48 && $0.value <= 57 }) else { + return false + } + if scalars.count > 1, scalars[0].value == 48 { return false } + guard let number = Int(String(octet)) else { return false } + return number <= 255 + } +} + +private func parseAidenIPv6Side(_ value: String) -> Int? { + if value.isEmpty { return 0 } + let groups = value.split(separator: ":", omittingEmptySubsequences: false) + guard groups.allSatisfy({ !$0.isEmpty }) else { return nil } + var count = 0 + for (index, group) in groups.enumerated() { + let value = String(group) + if value.contains(".") { + guard index == groups.count - 1, isCanonicalAidenIPv4(value) else { return nil } + count += 2 + } else { + let scalars = Array(value.unicodeScalars) + guard (1...4).contains(scalars.count), scalars.allSatisfy(isAidenASCIIHex) else { + return nil + } + count += 1 + } + } + return count +} + +private func isCanonicalAidenIPv6(_ value: String) -> Bool { + guard !value.isEmpty, + value.unicodeScalars.allSatisfy({ isAidenASCIIHex($0) || $0.value == 46 || $0.value == 58 }) else { + return false + } + let sides = value.components(separatedBy: "::") + guard sides.count <= 2 else { return false } + if sides.count == 2 { + if sides[0].contains(".") { return false } + guard let left = parseAidenIPv6Side(sides[0]), + let right = parseAidenIPv6Side(sides[1]) else { + return false + } + return left + right < 8 + } + return parseAidenIPv6Side(value) == 8 +} + +private func isCanonicalAidenDNSLabel(_ label: Substring) -> Bool { + let scalars = Array(label.unicodeScalars) + guard !scalars.isEmpty, + scalars.count <= 63, + let first = scalars.first, + let last = scalars.last, + isAidenASCIIAlphaNumeric(first), + isAidenASCIIAlphaNumeric(last) else { + return false + } + return scalars.dropFirst().dropLast().allSatisfy { isAidenASCIIAlphaNumeric($0) || $0.value == 45 } +} + +private func isCanonicalAidenDNSHost(_ value: String) -> Bool { + guard !value.isEmpty, value.utf8.count <= 253 else { return false } + let labels = value.split(separator: ".", omittingEmptySubsequences: false) + guard labels.allSatisfy({ !$0.isEmpty }) else { return false } + if labels.allSatisfy({ !$0.isEmpty && $0.unicodeScalars.allSatisfy({ $0.value >= 48 && $0.value <= 57 }) }) { + // Numeric-only authorities are ambiguous under Foundation URL parsing. + // Keep only canonical dotted-decimal IPv4 instead of letting `123` + // become `0.0.0.123` on one platform and a DNS name on another. + return isCanonicalAidenIPv4(value) + } + // A DNS authority must not end in a numeric-only label. This keeps + // `aiden.123` distinct from canonical IPv4 while retaining numeric labels + // in non-terminal positions. + if labels.last?.unicodeScalars.allSatisfy({ $0.value >= 48 && $0.value <= 57 }) == true { + return false + } + return labels.allSatisfy(isCanonicalAidenDNSLabel) +} + +private func isCanonicalAidenPort(_ value: String) -> Bool { + let scalars = Array(value.unicodeScalars) + guard !scalars.isEmpty, + scalars.count <= 5, + scalars[0].value != 48, + scalars.allSatisfy({ $0.value >= 48 && $0.value <= 57 }), + let port = Int(value) else { + return false + } + return (1...AidenRemoteProtocol.maxEndpointPort).contains(port) +} + +private func isCanonicalAidenAuthority(_ value: String) -> Bool { + // Keep the raw grammar ASCII-only. This rejects C0/DEL, all Unicode + // whitespace and normalization-sensitive host spellings before Foundation + // can decode or normalize them. + guard value.unicodeScalars.allSatisfy({ + $0.value <= 0x7F && $0.value > 0x20 && $0.value != 0x7F + }) else { + return false + } + + let host: String + var rawPort: String? + if value.first == "[" { + guard let closingBracket = value.firstIndex(of: "]"), + closingBracket > value.index(after: value.startIndex) else { + return false + } + let hostStart = value.index(after: value.startIndex) + guard !value[hostStart.. Bool { + guard rawEndpoint.utf8.count <= AidenRemoteProtocol.maxEndpointLength, + rawEndpoint.hasPrefix("https://"), + rawEndpoint.hasSuffix(AidenRemoteProtocol.basePath) else { + return false + } + let authorityStart = rawEndpoint.index(rawEndpoint.startIndex, offsetBy: "https://".count) + let pathStart = rawEndpoint.index(rawEndpoint.endIndex, offsetBy: -AidenRemoteProtocol.basePath.count) + guard pathStart > authorityStart else { return false } + return isCanonicalAidenAuthority(String(rawEndpoint[authorityStart.. Bool { + isCanonicalAidenEndpoint(endpoint.absoluteString) +} + +struct AidenRemoteCapability: RawRepresentable, Codable, Hashable, Sendable { + let rawValue: String + + init(rawValue: String) { + self.rawValue = rawValue + } + + static let serverRead = Self(rawValue: "server:read") + static let chatRead = Self(rawValue: "chat:read") + static let chatWrite = Self(rawValue: "chat:write") + static let approvalRespond = Self(rawValue: "approval:respond") + static let workspaceRead = Self(rawValue: "workspace:read") + static let workspaceBrowse = Self(rawValue: "workspace:browse") + static let workspaceManage = Self(rawValue: "workspace:manage") + static let filesRead = Self(rawValue: "files:read") + static let filesWrite = Self(rawValue: "files:write") + static let gitRead = Self(rawValue: "git:read") + static let gitWrite = Self(rawValue: "git:write") + static let scheduleRead = Self(rawValue: "schedule:read") + static let scheduleWrite = Self(rawValue: "schedule:write") + + static let v1Known: [Self] = [ + .serverRead, .chatRead, .chatWrite, .approvalRespond, + .workspaceRead, .workspaceBrowse, .workspaceManage, + .filesRead, .filesWrite, .gitRead, .gitWrite, + .scheduleRead, .scheduleWrite, + ] + + init(from decoder: Decoder) throws { + let value = try decoder.singleValueContainer().decode(String.self) + guard !value.isEmpty, value.unicodeScalars.count <= AidenRemoteProtocol.maxEventTypeLength else { + throw AidenRemoteContractError.unsafePayloadField("capability") + } + self.init(rawValue: value) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +struct AidenRemoteEventType: RawRepresentable, Codable, Hashable, Sendable { + let rawValue: String + + init(rawValue: String) { + self.rawValue = rawValue + } + + static let snapshot = Self(rawValue: "snapshot") + static let status = Self(rawValue: "status") + static let textDelta = Self(rawValue: "text_delta") + static let reasoningDelta = Self(rawValue: "reasoning_delta") + static let toolStarted = Self(rawValue: "tool_started") + static let toolFinished = Self(rawValue: "tool_finished") + static let timeline = Self(rawValue: "timeline") + static let approvalRequired = Self(rawValue: "approval_required") + static let done = Self(rawValue: "done") + static let error = Self(rawValue: "error") + static let cancelled = Self(rawValue: "cancelled") + static let heartbeat = Self(rawValue: "heartbeat") + + static let v1Known: [Self] = [ + .snapshot, .status, .textDelta, .reasoningDelta, + .toolStarted, .toolFinished, .timeline, .approvalRequired, + .done, .error, .cancelled, .heartbeat, + ] + + var isTerminal: Bool { + self == .done || self == .error || self == .cancelled + } +} + +struct AidenRemoteErrorCode: RawRepresentable, Codable, Hashable, Sendable { + let rawValue: String + + init(rawValue: String) { + self.rawValue = rawValue + } + + static let v1Known: [Self] = [ + Self(rawValue: "invalid_request"), + Self(rawValue: "payload_too_large"), + Self(rawValue: "rate_limited"), + Self(rawValue: "authentication_required"), + Self(rawValue: "credential_revoked"), + Self(rawValue: "capability_denied"), + Self(rawValue: "pairing_closed"), + Self(rawValue: "pairing_expired"), + Self(rawValue: "pairing_already_used"), + Self(rawValue: "server_identity_changed"), + Self(rawValue: "not_found"), + Self(rawValue: "already_exists"), + Self(rawValue: "revision_conflict"), + Self(rawValue: "idempotency_conflict"), + Self(rawValue: "idempotency_capacity"), + Self(rawValue: "idempotency_in_flight"), + Self(rawValue: "workspace_unavailable"), + Self(rawValue: "workspace_changing"), + Self(rawValue: "permission_confirmation_required"), + Self(rawValue: "handle_invalid"), + Self(rawValue: "handle_expired"), + Self(rawValue: "handle_wrong_device"), + Self(rawValue: "root_policy_changed"), + Self(rawValue: "filesystem_identity_changed"), + Self(rawValue: "path_outside_root"), + Self(rawValue: "handle_capacity"), + Self(rawValue: "turn_already_active"), + Self(rawValue: "stream_gone"), + Self(rawValue: "approval_already_resolved"), + Self(rawValue: "approval_expired"), + Self(rawValue: "operation_in_progress"), + Self(rawValue: "operation_stale"), + Self(rawValue: "git_capability_denied"), + Self(rawValue: "schedule_disabled"), + Self(rawValue: "schedule_run_in_progress"), + Self(rawValue: "server_interrupted"), + Self(rawValue: "internal_error"), + ] + + init(from decoder: Decoder) throws { + let value = try decoder.singleValueContainer().decode(String.self) + guard Self.v1Known.contains(Self(rawValue: value)) else { + throw AidenRemoteContractError.unknownErrorCode(value) + } + self.init(rawValue: value) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +struct AidenRemoteErrorEnvelope: Codable, Equatable, Sendable { + struct Details: Codable, Equatable, Sendable { + let currentRevision: String? + let retryAfterSeconds: Int? + let chatId: String? + let minimumClientVersion: String? + let limit: Int? + let field: String? + + init(from decoder: Decoder) throws { + let dynamic = try decoder.container(keyedBy: AidenDynamicCodingKey.self) + try assertKnownKeys(dynamic, allowed: Set(CodingKeys.allCases.map(\.stringValue))) + let values = try decoder.container(keyedBy: CodingKeys.self) + currentRevision = try boundedString( + values, + forKey: .currentRevision, + maxLength: AidenRemoteProtocol.maxIdentifierLength, + field: "currentRevision" + ) + retryAfterSeconds = try decodeOptionalNonNull( + values, + Int.self, + forKey: .retryAfterSeconds + ) + if let retryAfterSeconds { + guard (0...86_400).contains(retryAfterSeconds) else { + throw AidenRemoteContractError.unsafePayloadField("retryAfterSeconds") + } + } + chatId = try boundedString( + values, + forKey: .chatId, + maxLength: AidenRemoteProtocol.maxIdentifierLength, + field: "chatId" + ) + minimumClientVersion = try boundedString( + values, + forKey: .minimumClientVersion, + maxLength: 40, + field: "minimumClientVersion" + ) + limit = try decodeOptionalNonNull(values, Int.self, forKey: .limit) + if let limit { + guard (0...1_000_000).contains(limit) else { + throw AidenRemoteContractError.unsafePayloadField("limit") + } + } + field = try boundedString( + values, + forKey: .field, + maxLength: 120, + field: "field" + ) + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case currentRevision, retryAfterSeconds, chatId, minimumClientVersion, limit, field + } + } + + struct Body: Codable, Equatable, Sendable { + let code: AidenRemoteErrorCode + let message: String + let requestId: String + let retryable: Bool + let details: Details? + + init(from decoder: Decoder) throws { + let dynamic = try decoder.container(keyedBy: AidenDynamicCodingKey.self) + try assertKnownKeys(dynamic, allowed: Set(CodingKeys.allCases.map(\.stringValue))) + let values = try decoder.container(keyedBy: CodingKeys.self) + code = try values.decode(AidenRemoteErrorCode.self, forKey: .code) + message = try boundedString( + values, + forKey: .message, + maxLength: AidenRemoteProtocol.maxErrorMessageLength, + field: "message", + required: true + )! + requestId = try boundedString( + values, + forKey: .requestId, + maxLength: AidenRemoteProtocol.maxIdentifierLength, + field: "requestId", + required: true + )! + retryable = try values.decode(Bool.self, forKey: .retryable) + details = try decodeOptionalNonNull(values, Details.self, forKey: .details) + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case code, message, requestId, retryable, details + } + } + + let error: Body + + init(from decoder: Decoder) throws { + let dynamic = try decoder.container(keyedBy: AidenDynamicCodingKey.self) + try assertKnownKeys(dynamic, allowed: ["error"]) + let values = try decoder.container(keyedBy: CodingKeys.self) + error = try values.decode(Body.self, forKey: .error) + } + + private enum CodingKeys: String, CodingKey { + case error + } +} + +struct AidenRemoteEventPayload: Codable, Equatable, Sendable { + let chatId: String? + let turnId: String? + let nextSequence: Int? + let state: String? + let text: String? + let toolId: String? + let name: String? + let status: String? + let label: String? + let timeline: AidenGenerationTimeline? + let approvalId: String? + let summary: String? + let expiresAt: Date? + let messageId: String? + let code: AidenRemoteErrorCode? + let message: String? + let source: String? + private let wirePresentKeys: Set + + var presentKeys: Set { + wirePresentKeys + } + + init(from decoder: Decoder) throws { + let dynamic = try decoder.container(keyedBy: AidenDynamicCodingKey.self) + guard dynamic.allKeys.count <= AidenRemoteProtocol.maxEventPayloadProperties else { + throw AidenRemoteContractError.payloadTooLarge + } + try assertKnownKeys(dynamic, allowed: Set(CodingKeys.allCases.map(\.stringValue))) + wirePresentKeys = Set(dynamic.allKeys.map(\.stringValue)) + let values = try decoder.container(keyedBy: CodingKeys.self) + chatId = try boundedString( + values, + forKey: .chatId, + maxLength: AidenRemoteProtocol.maxIdentifierLength, + field: "chatId" + ) + turnId = try boundedString( + values, + forKey: .turnId, + maxLength: AidenRemoteProtocol.maxIdentifierLength, + field: "turnId" + ) + nextSequence = try decodeOptionalNonNull(values, Int.self, forKey: .nextSequence) + state = try boundedString(values, forKey: .state, maxLength: 64, field: "state") + text = try boundedString( + values, + forKey: .text, + maxLength: AidenRemoteProtocol.maxTextLength, + field: "text" + ) + toolId = try boundedString( + values, + forKey: .toolId, + maxLength: AidenRemoteProtocol.maxIdentifierLength, + field: "toolId" + ) + name = try boundedString( + values, + forKey: .name, + maxLength: AidenRemoteProtocol.maxToolNameLength, + field: "name" + ) + status = try boundedString(values, forKey: .status, maxLength: 32, field: "status") + label = try boundedString( + values, + forKey: .label, + maxLength: AidenRemoteProtocol.maxTimelineLabelLength, + field: "label" + ) + timeline = try decodeOptionalNonNull(values, AidenGenerationTimeline.self, forKey: .timeline) + if let timeline, !timeline.isRendererSafe { + throw AidenRemoteContractError.unsafePayloadField("timeline") + } + approvalId = try boundedString( + values, + forKey: .approvalId, + maxLength: AidenRemoteProtocol.maxIdentifierLength, + field: "approvalId" + ) + summary = try boundedString( + values, + forKey: .summary, + maxLength: AidenRemoteProtocol.maxApprovalSummaryLength, + field: "summary" + ) + expiresAt = try decodeOptionalNonNull(values, Date.self, forKey: .expiresAt) + messageId = try boundedString( + values, + forKey: .messageId, + maxLength: AidenRemoteProtocol.maxIdentifierLength, + field: "messageId" + ) + code = try decodeOptionalNonNull(values, AidenRemoteErrorCode.self, forKey: .code) + message = try boundedString( + values, + forKey: .message, + maxLength: AidenRemoteProtocol.maxErrorMessageLength, + field: "message" + ) + source = try boundedString(values, forKey: .source, maxLength: 32, field: "source") + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case chatId, turnId, nextSequence, state, text, toolId, name, status, label, timeline + case approvalId, summary, expiresAt, messageId, code, message, source + } +} + +private indirect enum AidenUnknownJSONValue: Decodable { + case object + case array + case scalar + + init(from decoder: Decoder) throws { + guard decoder.codingPath.count <= AidenRemoteProtocol.maxJSONNestingDepth else { + throw AidenRemoteContractError.payloadTooLarge + } + if let values = try? decoder.container(keyedBy: AidenDynamicCodingKey.self) { + for key in values.allKeys { + if AidenRemoteProtocol.forbiddenWireKeys.contains(key.stringValue) { + throw AidenRemoteContractError.unsafePayloadField(key.stringValue) + } + _ = try values.decode(AidenUnknownJSONValue.self, forKey: key) + } + self = .object + return + } + if var values = try? decoder.unkeyedContainer() { + while !values.isAtEnd { _ = try values.decode(AidenUnknownJSONValue.self) } + self = .array + return + } + let value = try decoder.singleValueContainer() + if value.decodeNil() + || (try? value.decode(Bool.self)) != nil + || (try? value.decode(Int64.self)) != nil + || (try? value.decode(Double.self)) != nil + || (try? value.decode(String.self)) != nil { + self = .scalar + return + } + throw DecodingError.dataCorruptedError(in: value, debugDescription: "Unsupported JSON value.") + } +} + +private struct AidenUnknownEventPayload: Decodable { + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: AidenDynamicCodingKey.self) + guard values.allKeys.count <= AidenRemoteProtocol.maxEventPayloadProperties else { + throw AidenRemoteContractError.payloadTooLarge + } + for key in values.allKeys { + if AidenRemoteProtocol.forbiddenWireKeys.contains(key.stringValue) { + throw AidenRemoteContractError.unsafePayloadField(key.stringValue) + } + _ = try values.decode(AidenUnknownJSONValue.self, forKey: key) + } + } +} + +struct AidenRemoteStreamEvent: Decodable, Equatable, Sendable { + let protocolVersion: Int + let streamId: String + let sequence: Int + let timestamp: Date + let type: AidenRemoteEventType + let terminal: Bool + let payload: AidenRemoteEventPayload? + + var shouldApply: Bool { AidenRemoteEventType.v1Known.contains(type) } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case protocolVersion, streamId, sequence, timestamp, type, terminal, payload + } + + init(from decoder: Decoder) throws { + let dynamic = try decoder.container(keyedBy: AidenDynamicCodingKey.self) + guard dynamic.allKeys.count <= AidenRemoteProtocol.maxEventEnvelopeProperties else { + throw AidenRemoteContractError.payloadTooLarge + } + let envelopeKeys = Set(CodingKeys.allCases.map(\.stringValue)) + for key in dynamic.allKeys where !envelopeKeys.contains(key.stringValue) { + if AidenRemoteProtocol.forbiddenWireKeys.contains(key.stringValue) { + throw AidenRemoteContractError.unsafePayloadField(key.stringValue) + } + _ = try dynamic.decode(AidenUnknownJSONValue.self, forKey: key) + } + let values = try decoder.container(keyedBy: CodingKeys.self) + protocolVersion = try values.decode(Int.self, forKey: .protocolVersion) + guard protocolVersion == AidenRemoteProtocol.version else { + throw AidenRemoteContractError.invalidProtocolVersion + } + streamId = try values.decode(String.self, forKey: .streamId) + guard !streamId.isEmpty, streamId.unicodeScalars.count <= AidenRemoteProtocol.maxIdentifierLength else { + throw AidenRemoteContractError.invalidStreamIdentity + } + sequence = try values.decode(Int.self, forKey: .sequence) + guard (1...AidenRemoteProtocol.maxSafeInteger).contains(sequence) else { + throw AidenRemoteContractError.invalidSequence + } + timestamp = try values.decode(Date.self, forKey: .timestamp) + type = try values.decode(AidenRemoteEventType.self, forKey: .type) + guard !type.rawValue.isEmpty, + type.rawValue.unicodeScalars.count <= AidenRemoteProtocol.maxEventTypeLength else { + throw AidenRemoteContractError.unsafePayloadField("type") + } + terminal = try values.decode(Bool.self, forKey: .terminal) + if !AidenRemoteEventType.v1Known.contains(type) { + guard !terminal else { throw AidenRemoteContractError.unknownTerminalEvent(type.rawValue) } + _ = try values.decode(AidenUnknownEventPayload.self, forKey: .payload) + payload = nil + return + } + guard terminal == type.isTerminal else { + throw AidenRemoteContractError.invalidTerminalClassification + } + let decodedPayload = try values.decode(AidenRemoteEventPayload.self, forKey: .payload) + let allowedKeys: Set + switch type { + case .snapshot: allowedKeys = ["chatId", "turnId", "nextSequence"] + case .status: allowedKeys = ["state"] + case .textDelta, .reasoningDelta: allowedKeys = ["text"] + case .toolStarted: allowedKeys = ["toolId", "name"] + case .toolFinished: allowedKeys = ["toolId", "status"] + case .timeline: allowedKeys = ["timeline"] + case .approvalRequired: allowedKeys = ["approvalId", "summary", "expiresAt"] + case .done: allowedKeys = ["messageId"] + case .error: allowedKeys = ["code", "message"] + case .cancelled: allowedKeys = ["source"] + case .heartbeat: allowedKeys = [] + default: allowedKeys = [] + } + if let unsupported = decodedPayload.presentKeys.subtracting(allowedKeys).first { + throw AidenRemoteContractError.unsafePayloadField(unsupported) + } + guard decodedPayload.presentKeys == allowedKeys else { + throw AidenRemoteContractError.unsafePayloadField("missing-required-field") + } + if type == .status, + let state = decodedPayload.state, + !["queued", "running", "waiting_for_approval", "reconciling"].contains(state) { + throw AidenRemoteContractError.unsafePayloadField("state") + } + if type == .snapshot, + let nextSequence = decodedPayload.nextSequence, + !(1...AidenRemoteProtocol.maxSafeInteger).contains(nextSequence) { + throw AidenRemoteContractError.unsafePayloadField("nextSequence") + } + if type == .toolFinished, + let status = decodedPayload.status, + !["succeeded", "failed", "cancelled"].contains(status) { + throw AidenRemoteContractError.unsafePayloadField("status") + } + if type == .cancelled, + let source = decodedPayload.source, + !["device", "server"].contains(source) { + throw AidenRemoteContractError.unsafePayloadField("source") + } + payload = decodedPayload + } +} + +struct AidenRemoteContractFixture: Decodable { + struct Health: Decodable { + let ok: Bool + let protocolVersion: Int + + init(from decoder: Decoder) throws { + let dynamic = try decoder.container(keyedBy: AidenDynamicCodingKey.self) + try assertKnownKeys(dynamic, allowed: Set(CodingKeys.allCases.map(\.stringValue))) + let values = try decoder.container(keyedBy: CodingKeys.self) + ok = try values.decode(Bool.self, forKey: .ok) + guard ok else { throw AidenRemoteContractError.unsafePayloadField("ok") } + protocolVersion = try values.decode(Int.self, forKey: .protocolVersion) + guard protocolVersion == AidenRemoteProtocol.version else { + throw AidenRemoteContractError.invalidProtocolVersion + } + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case ok, protocolVersion + } + } + + struct ManualPairingBootstrap: Decodable, Equatable { + static let kindValue = "aiden-manual-pairing-v1" + + let kind: String + let protocolVersion: Int + let sessionId: String + let expiresAt: Date + let rawExpiresAt: String + let salt: Data + let nonce: Data + let ciphertext: Data + let tag: Data + + init(from decoder: Decoder) throws { + let dynamic = try decoder.container(keyedBy: AidenDynamicCodingKey.self) + try assertKnownKeys(dynamic, allowed: Set(CodingKeys.allCases.map(\.stringValue))) + let values = try decoder.container(keyedBy: CodingKeys.self) + kind = try values.decode(String.self, forKey: .kind) + protocolVersion = try values.decode(Int.self, forKey: .protocolVersion) + sessionId = try values.decode(String.self, forKey: .sessionId) + rawExpiresAt = try values.decode(String.self, forKey: .expiresAt) + guard let parsedExpiry = AidenStrictRFC3339Date.date(from: rawExpiresAt) else { + throw AidenManualPairingError.invalidBootstrap + } + expiresAt = parsedExpiry + let rawSalt = try values.decode(String.self, forKey: .salt) + let rawNonce = try values.decode(String.self, forKey: .nonce) + let rawCiphertext = try values.decode(String.self, forKey: .ciphertext) + let rawTag = try values.decode(String.self, forKey: .tag) + guard rawSalt.count == 22, + rawNonce.count == 16, + (2...5_462).contains(rawCiphertext.count), + rawTag.count == 22, + let decodedSalt = rawSalt.canonicalBase64URLDecoded, + let decodedNonce = rawNonce.canonicalBase64URLDecoded, + let decodedCiphertext = rawCiphertext.canonicalBase64URLDecoded, + let decodedTag = rawTag.canonicalBase64URLDecoded else { + throw AidenManualPairingError.invalidBootstrap + } + salt = decodedSalt + nonce = decodedNonce + ciphertext = decodedCiphertext + tag = decodedTag + } + + @discardableResult + func validated(at now: Date = Date()) throws -> Self { + guard kind == Self.kindValue, + protocolVersion == AidenRemoteProtocol.version, + sessionId.range( + of: "^pairing_[A-Za-z0-9_-]{32}$", + options: .regularExpression + ) != nil, + expiresAt > now, + expiresAt.timeIntervalSince(now) <= 5 * 60, + salt.count == 16, + nonce.count == 12, + !ciphertext.isEmpty, + ciphertext.count <= AidenRemoteProtocol.maxPairingPayloadBytes, + tag.count == 16 else { + throw AidenManualPairingError.invalidBootstrap + } + return self + } + + var keyDerivationInfo: Data { + Data("\(Self.kindValue)\n\(sessionId)".utf8) + } + + var additionalAuthenticatedData: Data { + Data("\(Self.kindValue)\n\(sessionId)\n\(rawExpiresAt)".utf8) + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case kind, protocolVersion, sessionId, expiresAt, salt, nonce, ciphertext, tag + } + } + + struct PairingBootstrap: Codable, Equatable { + let protocolVersion: Int + let instanceId: String + let endpoint: URL + fileprivate let rawEndpoint: String + let serverSpkiSha256: String + let secret: String + let expiresAt: Date + + init( + protocolVersion: Int, + instanceId: String, + endpoint: URL, + serverSpkiSha256: String, + secret: String, + expiresAt: Date + ) { + self.protocolVersion = protocolVersion + self.instanceId = instanceId + self.endpoint = endpoint + self.rawEndpoint = endpoint.absoluteString + self.serverSpkiSha256 = serverSpkiSha256 + self.secret = secret + self.expiresAt = expiresAt + } + + init(from decoder: Decoder) throws { + let dynamic = try decoder.container(keyedBy: AidenDynamicCodingKey.self) + try assertKnownKeys(dynamic, allowed: Set(CodingKeys.allCases.map(\.stringValue))) + let values = try decoder.container(keyedBy: CodingKeys.self) + protocolVersion = try values.decode(Int.self, forKey: .protocolVersion) + instanceId = try boundedString( + values, + forKey: .instanceId, + maxLength: AidenRemoteProtocol.maxIdentifierLength, + field: "instanceId", + required: true + )! + let rawEndpoint = try values.decode(String.self, forKey: .endpoint) + guard isCanonicalAidenEndpoint(rawEndpoint), let endpoint = URL(string: rawEndpoint) else { + throw AidenPairingBootstrapError.invalidEndpoint + } + self.endpoint = endpoint + self.rawEndpoint = rawEndpoint + serverSpkiSha256 = try values.decode(String.self, forKey: .serverSpkiSha256) + secret = try values.decode(String.self, forKey: .secret) + expiresAt = try values.decode(Date.self, forKey: .expiresAt) + } + + @discardableResult + func validated(at now: Date = Date()) throws -> Self { + guard protocolVersion == AidenRemoteProtocol.version else { + throw AidenPairingBootstrapError.unsupportedProtocol + } + guard !instanceId.isEmpty, + instanceId.unicodeScalars.count <= AidenRemoteProtocol.maxIdentifierLength else { + throw AidenPairingBootstrapError.invalidInstance + } + guard isCanonicalAidenEndpoint(rawEndpoint) else { + throw AidenPairingBootstrapError.invalidEndpoint + } + guard serverSpkiSha256.range( + of: "^sha256/[A-Za-z0-9+/]{43}=$", + options: .regularExpression + ) != nil else { + throw AidenPairingBootstrapError.invalidFingerprint + } + let encodedFingerprint = String(serverSpkiSha256.dropFirst("sha256/".count)) + guard serverSpkiSha256.hasPrefix("sha256/"), + let fingerprint = Data(base64Encoded: encodedFingerprint), + fingerprint.count == 32 else { + throw AidenPairingBootstrapError.invalidFingerprint + } + guard secret.base64URLDecoded?.count == 32, + secret.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil else { + throw AidenPairingBootstrapError.weakSecret + } + guard expiresAt > now else { throw AidenPairingBootstrapError.expired } + guard expiresAt.timeIntervalSince(now) <= 5 * 60 else { + throw AidenPairingBootstrapError.excessiveTTL + } + return self + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case protocolVersion, instanceId, endpoint, serverSpkiSha256, secret, expiresAt + } + } + + struct PairingTrust: Codable, Equatable, Sendable { + enum Mode: String, Codable, Sendable { + case privateCA = "private-ca" + case system + } + + let mode: Mode + let caCertificateDerBase64: String? + + init(mode: Mode, caCertificateDerBase64: String? = nil) { + self.mode = mode + self.caCertificateDerBase64 = caCertificateDerBase64 + } + + init(from decoder: Decoder) throws { + let dynamic = try decoder.container(keyedBy: AidenDynamicCodingKey.self) + try assertKnownKeys(dynamic, allowed: Set(CodingKeys.allCases.map(\.stringValue))) + let values = try decoder.container(keyedBy: CodingKeys.self) + mode = try values.decode(Mode.self, forKey: .mode) + caCertificateDerBase64 = try values.decodeIfPresent( + String.self, + forKey: .caCertificateDerBase64 + ) + _ = try validated() + } + + @discardableResult + func validated() throws -> Self { + switch mode { + case .system: + guard caCertificateDerBase64 == nil else { + throw AidenPairingPayloadError.invalidTrust + } + case .privateCA: + guard let value = caCertificateDerBase64, + !value.isEmpty, + let data = Data(base64Encoded: value), + data.count <= AidenRemoteProtocol.maxPairingPayloadBytes, + data.base64EncodedString() == value else { + throw AidenPairingPayloadError.invalidCACertificateData + } + } + return self + } + + var caCertificateDER: Data? { + guard mode == .privateCA, + let value = caCertificateDerBase64 else { return nil } + return Data(base64Encoded: value) + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case mode, caCertificateDerBase64 + } + } + + struct PairingPayload: Codable, Equatable { + static let kindValue = "aiden-pairing-v1" + + let kind: String + let bootstrap: PairingBootstrap + let trust: PairingTrust + + init(bootstrap: PairingBootstrap, trust: PairingTrust) { + kind = Self.kindValue + self.bootstrap = bootstrap + self.trust = trust + } + + init(from decoder: Decoder) throws { + let dynamic = try decoder.container(keyedBy: AidenDynamicCodingKey.self) + try assertKnownKeys(dynamic, allowed: Set(CodingKeys.allCases.map(\.stringValue))) + let values = try decoder.container(keyedBy: CodingKeys.self) + kind = try values.decode(String.self, forKey: .kind) + guard kind == Self.kindValue else { throw AidenPairingPayloadError.invalidKind } + bootstrap = try values.decode(PairingBootstrap.self, forKey: .bootstrap) + trust = try values.decode(PairingTrust.self, forKey: .trust) + } + + @discardableResult + func validated(at now: Date = Date()) throws -> Self { + guard kind == Self.kindValue else { throw AidenPairingPayloadError.invalidKind } + _ = try bootstrap.validated(at: now) + _ = try trust.validated() + return self + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case kind, bootstrap, trust + } + } + + struct PairingExchange: Codable, Equatable { + let protocolVersion: Int + let instanceId: String + let deviceId: String + let credential: String + let capabilities: [AidenRemoteCapability] + let endpoint: URL + fileprivate let rawEndpoint: String + let serverSpkiSha256: String + let displayName: String? + + init( + protocolVersion: Int, + instanceId: String, + deviceId: String, + credential: String, + capabilities: [AidenRemoteCapability], + endpoint: URL, + serverSpkiSha256: String, + displayName: String? = nil + ) { + self.protocolVersion = protocolVersion + self.instanceId = instanceId + self.deviceId = deviceId + self.credential = credential + self.capabilities = capabilities + self.endpoint = endpoint + self.rawEndpoint = endpoint.absoluteString + self.serverSpkiSha256 = serverSpkiSha256 + self.displayName = displayName + } + + init(from decoder: Decoder) throws { + let dynamic = try decoder.container(keyedBy: AidenDynamicCodingKey.self) + try assertKnownKeys(dynamic, allowed: Set(CodingKeys.allCases.map(\.stringValue))) + let values = try decoder.container(keyedBy: CodingKeys.self) + protocolVersion = try values.decode(Int.self, forKey: .protocolVersion) + instanceId = try boundedString( + values, + forKey: .instanceId, + maxLength: AidenRemoteProtocol.maxIdentifierLength, + field: "instanceId", + required: true + )! + deviceId = try boundedString( + values, + forKey: .deviceId, + maxLength: AidenRemoteProtocol.maxIdentifierLength, + field: "deviceId", + required: true + )! + credential = try values.decode(String.self, forKey: .credential) + capabilities = try values.decode([AidenRemoteCapability].self, forKey: .capabilities) + let rawEndpoint = try values.decode(String.self, forKey: .endpoint) + guard isCanonicalAidenEndpoint(rawEndpoint), let endpoint = URL(string: rawEndpoint) else { + throw AidenRemoteContractError.invalidPairingExchange + } + self.endpoint = endpoint + self.rawEndpoint = rawEndpoint + serverSpkiSha256 = try values.decode(String.self, forKey: .serverSpkiSha256) + displayName = try boundedString( + values, + forKey: .displayName, + maxLength: 80, + field: "displayName", + required: false + ) + } + + func validated(against bootstrap: PairingBootstrap) throws -> Self { + guard protocolVersion == AidenRemoteProtocol.version, + !instanceId.isEmpty, + instanceId.unicodeScalars.count <= AidenRemoteProtocol.maxIdentifierLength, + !deviceId.isEmpty, + deviceId.unicodeScalars.count <= AidenRemoteProtocol.maxIdentifierLength, + instanceId == bootstrap.instanceId, + isCanonicalAidenEndpoint(rawEndpoint), + rawEndpoint == bootstrap.rawEndpoint, + endpoint == bootstrap.endpoint, + endpoint.absoluteString.utf8.count <= AidenRemoteProtocol.maxEndpointLength, + serverSpkiSha256 == bootstrap.serverSpkiSha256, + credential.base64URLDecoded?.count == 32, + credential.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil, + Set(capabilities).count == capabilities.count, + Set(capabilities).isSubset(of: Set(AidenRemoteCapability.v1Known)) else { + throw AidenRemoteContractError.invalidPairingExchange + } + return self + } + + private enum CodingKeys: String, CodingKey, CaseIterable { + case protocolVersion, instanceId, deviceId, credential, capabilities, endpoint, serverSpkiSha256, displayName + } + } + + let contractRevision: Int + let protocolVersion: Int + let capabilities: [AidenRemoteCapability] + let health: Health + let pairingBootstrap: PairingBootstrap + let pairingExchange: PairingExchange + let streamStatus: AidenStreamStatus + let streamApproval: AidenStreamApprovalSnapshot + let events: [AidenRemoteStreamEvent] + let error: AidenRemoteErrorEnvelope +} + +extension String { + var base64URLDecoded: Data? { + var value = replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + value += String(repeating: "=", count: (4 - value.count % 4) % 4) + return Data(base64Encoded: value) + } + + var canonicalBase64URLDecoded: Data? { + guard !isEmpty, + unicodeScalars.allSatisfy({ $0.isASCII }), + range(of: "^[A-Za-z0-9_-]+$", options: .regularExpression) != nil, + count % 4 != 1, + let decoded = base64URLDecoded else { return nil } + let canonical = decoded.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return canonical == self ? decoded : nil + } +} + +enum AidenRemoteJSONDecoder { + static func decode( + _ type: Value.Type, + from data: Data, + maximumBytes: Int = AidenRemoteProtocol.maxJSONBodyBytes + ) throws -> Value { + try JSONDecoder.aidenRemote().decodeAidenRemote(type, from: data, maximumBytes: maximumBytes) + } + + static func decodeSSEEvent(from data: Data) throws -> AidenRemoteStreamEvent { + try JSONDecoder.aidenRemote().decodeAidenRemoteStreamEvent(from: data) + } + + static func decodePairingBootstrap( + from data: Data + ) throws -> AidenRemoteContractFixture.PairingBootstrap { + try decode(AidenRemoteContractFixture.PairingBootstrap.self, from: data) + } + + static func decodePairingPayload( + from data: Data + ) throws -> AidenRemoteContractFixture.PairingPayload { + guard data.count <= AidenRemoteProtocol.maxPairingPayloadBytes else { + throw AidenRemoteContractError.payloadTooLarge + } + return try decode(AidenRemoteContractFixture.PairingPayload.self, from: data) + } + + static func decodeManualPairingBootstrap( + from data: Data + ) throws -> AidenRemoteContractFixture.ManualPairingBootstrap { + try decode( + AidenRemoteContractFixture.ManualPairingBootstrap.self, + from: data, + maximumBytes: AidenRemoteProtocol.maxPairingPayloadBytes * 2 + ) + } +} + +extension JSONDecoder { + static func aidenRemote() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .strictRFC3339 + return decoder + } + + func decodeAidenRemote( + _ type: Value.Type, + from data: Data, + maximumBytes: Int = AidenRemoteProtocol.maxJSONBodyBytes + ) throws -> Value { + guard data.count <= maximumBytes else { + throw AidenRemoteContractError.payloadTooLarge + } + try AidenRawJSONDuplicateKeyScanner.validate(data) + _ = try decode(AidenUnknownJSONValue.self, from: data) + return try decode(type, from: data) + } + + func decodeAidenRemoteStreamEvent(from data: Data) throws -> AidenRemoteStreamEvent { + guard data.count <= AidenRemoteProtocol.maxSSEFrameBytes else { + throw AidenRemoteContractError.payloadTooLarge + } + return try decodeAidenRemote(AidenRemoteStreamEvent.self, from: data) + } +} + +private enum AidenStrictRFC3339Date { + private static let pattern = try! NSRegularExpression( + pattern: #"^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$"# + ) + + static func date(from value: String) -> Date? { + let range = NSRange(value.startIndex.. String? { + let captureRange = match.range(at: index) + guard captureRange.location != NSNotFound, + let swiftRange = Range(captureRange, in: value) else { + return nil + } + return String(value[swiftRange]) + } + + guard let year = capture(1).flatMap(Int.init), + let month = capture(2).flatMap(Int.init), + let day = capture(3).flatMap(Int.init), + let hour = capture(4).flatMap(Int.init), + let minute = capture(5).flatMap(Int.init), + let second = capture(6).flatMap(Int.init), + let offset = capture(8) else { + return nil + } + + let leapYear = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) + let daysInMonth: [Int] = [ + 31, leapYear ? 29 : 28, 31, 30, 31, 30, + 31, 31, 30, 31, 30, 31, + ] + guard (1...12).contains(month), + (1...daysInMonth[month - 1]).contains(day), + (0...23).contains(hour), + (0...59).contains(minute), + (0...59).contains(second) else { + return nil + } + + let fractionDigits = capture(7).map { String($0.dropFirst()) } ?? "" + let milliseconds = Int(String((fractionDigits + "000").prefix(3))) ?? 0 + let offsetHours: Int + let offsetMinutes: Int + let offsetSign: Int + if offset == "Z" { + offsetHours = 0 + offsetMinutes = 0 + offsetSign = 1 + } else { + offsetHours = Int(offset.dropFirst().prefix(2)) ?? 0 + offsetMinutes = Int(offset.dropFirst(4).prefix(2)) ?? 0 + offsetSign = offset.first == "+" ? 1 : -1 + } + guard offsetHours <= 23, offsetMinutes <= 59 else { return nil } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + var components = DateComponents() + components.calendar = calendar + components.timeZone = calendar.timeZone + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = minute + components.second = second + components.nanosecond = milliseconds * 1_000_000 + guard let date = calendar.date(from: components) else { return nil } + + let offsetSeconds = offsetSign * (offsetHours * 60 + offsetMinutes) * 60 + return date.addingTimeInterval(-TimeInterval(offsetSeconds)) + } +} + +private extension JSONDecoder.DateDecodingStrategy { + static let strictRFC3339 = custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + if let date = AidenStrictRFC3339Date.date(from: value) { return date } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Expected a strict RFC 3339 timestamp." + ) + } +} diff --git a/ios/AidenOnTheGo/Networking/AidenSSEParser.swift b/ios/AidenOnTheGo/Networking/AidenSSEParser.swift new file mode 100644 index 00000000..59b343a5 --- /dev/null +++ b/ios/AidenOnTheGo/Networking/AidenSSEParser.swift @@ -0,0 +1,77 @@ +import Foundation + +enum AidenSSEParserError: Error, Equatable { + case frameTooLarge + case invalidEventID + case eventIDMismatch + case eventNameMismatch + case missingData +} + +struct AidenSSEParser { + private var eventID: String? + private var eventName: String? + private var dataLines: [String] = [] + private var frameBytes = 0 + + mutating func consume(line: String) throws -> AidenRemoteStreamEvent? { + frameBytes += line.utf8.count + 1 + guard frameBytes <= AidenRemoteProtocol.maxSSEFrameBytes else { + throw AidenSSEParserError.frameTooLarge + } + guard !line.isEmpty else { return try finishFrame() } + guard !line.hasPrefix(":") else { return nil } + + let field: Substring + let value: Substring + if let separator = line.firstIndex(of: ":") { + field = line[.. AidenRemoteStreamEvent? { + guard frameBytes > 0 else { return nil } + return try finishFrame() + } + + private mutating func finishFrame() throws -> AidenRemoteStreamEvent? { + defer { reset() } + guard !dataLines.isEmpty else { + if eventID == nil, eventName == nil { return nil } + throw AidenSSEParserError.missingData + } + guard let eventID, let sequence = Int(eventID), sequence > 0 else { + throw AidenSSEParserError.invalidEventID + } + let event = try AidenRemoteJSONDecoder.decodeSSEEvent( + from: Data(dataLines.joined(separator: "\n").utf8) + ) + guard event.sequence == sequence else { throw AidenSSEParserError.eventIDMismatch } + if let eventName, eventName != event.type.rawValue { + throw AidenSSEParserError.eventNameMismatch + } + return event + } + + private mutating func reset() { + eventID = nil + eventName = nil + dataLines.removeAll(keepingCapacity: true) + frameBytes = 0 + } +} diff --git a/ios/AidenOnTheGo/Networking/AidenServerTrust.swift b/ios/AidenOnTheGo/Networking/AidenServerTrust.swift new file mode 100644 index 00000000..8ccddc11 --- /dev/null +++ b/ios/AidenOnTheGo/Networking/AidenServerTrust.swift @@ -0,0 +1,252 @@ +import CryptoKit +import Foundation +import Security + +enum AidenServerTrustError: Error, Equatable { + case missingLeafCertificate + case invalidAnchorCertificate + case missingPublicKey + case unsupportedPublicKey + case invalidPublicKeyRepresentation + case trustConfigurationFailed(OSStatus) + case hostnameOrCertificateInvalid + case publicKeyPinMismatch +} + +enum AidenServerTrustPolicy: Equatable, Sendable { + case system + case privateCA(Data) + + init(pairingTrust: AidenRemoteContractFixture.PairingTrust) throws { + switch pairingTrust.mode { + case .system: + self = .system + case .privateCA: + guard let certificate = pairingTrust.caCertificateDER, + SecCertificateCreateWithData(nil, certificate as CFData) != nil else { + throw AidenServerTrustError.invalidAnchorCertificate + } + self = .privateCA(certificate) + } + } +} + +enum AidenServerTrust { + // ASN.1 SubjectPublicKeyInfo prefix for an uncompressed P-256 public key. + private static let p256SPKIPrefix = Data([ + 0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, + 0x3D, 0x02, 0x01, 0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, + 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, + ]) + + static func spkiSHA256(p256ExternalRepresentation: Data) throws -> String { + guard p256ExternalRepresentation.count == 65, + p256ExternalRepresentation.first == 0x04 else { + throw AidenServerTrustError.invalidPublicKeyRepresentation + } + let digest = SHA256.hash(data: p256SPKIPrefix + p256ExternalRepresentation) + return "sha256/\(Data(digest).base64EncodedString())" + } + + static func spkiSHA256(certificate: SecCertificate) throws -> String { + guard let publicKey = SecCertificateCopyKey(certificate) else { + throw AidenServerTrustError.missingPublicKey + } + guard let attributes = SecKeyCopyAttributes(publicKey) as? [CFString: Any], + let keyType = attributes[kSecAttrKeyType] as? String, + keyType == (kSecAttrKeyTypeECSECPrimeRandom as String), + let keySize = attributes[kSecAttrKeySizeInBits] as? Int, + keySize == 256 else { + throw AidenServerTrustError.unsupportedPublicKey + } + var error: Unmanaged? + guard let representation = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else { + throw error?.takeRetainedValue() ?? AidenServerTrustError.invalidPublicKeyRepresentation + } + return try spkiSHA256(p256ExternalRepresentation: representation) + } + + static func evaluate( + serverTrust: SecTrust, + expectedHost: String, + expectedFingerprint: String, + policy: AidenServerTrustPolicy, + verificationDate: Date? = nil + ) throws { + guard let chain = SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate], + let leaf = chain.first else { + throw AidenServerTrustError.missingLeafCertificate + } + let policyStatus = SecTrustSetPolicies( + serverTrust, + SecPolicyCreateSSL(true, expectedHost as CFString) + ) + guard policyStatus == errSecSuccess else { + throw AidenServerTrustError.trustConfigurationFailed(policyStatus) + } + switch policy { + case .system: + let anchorStatus = SecTrustSetAnchorCertificates(serverTrust, nil) + guard anchorStatus == errSecSuccess else { + throw AidenServerTrustError.trustConfigurationFailed(anchorStatus) + } + let anchorsOnlyStatus = SecTrustSetAnchorCertificatesOnly(serverTrust, false) + guard anchorsOnlyStatus == errSecSuccess else { + throw AidenServerTrustError.trustConfigurationFailed(anchorsOnlyStatus) + } + case .privateCA(let certificateData): + guard let trustAnchor = SecCertificateCreateWithData(nil, certificateData as CFData) else { + throw AidenServerTrustError.invalidAnchorCertificate + } + let anchorStatus = SecTrustSetAnchorCertificates(serverTrust, [trustAnchor] as CFArray) + guard anchorStatus == errSecSuccess else { + throw AidenServerTrustError.trustConfigurationFailed(anchorStatus) + } + let anchorsOnlyStatus = SecTrustSetAnchorCertificatesOnly(serverTrust, true) + guard anchorsOnlyStatus == errSecSuccess else { + throw AidenServerTrustError.trustConfigurationFailed(anchorsOnlyStatus) + } + } + if let verificationDate { + let dateStatus = SecTrustSetVerifyDate(serverTrust, verificationDate as CFDate) + guard dateStatus == errSecSuccess else { + throw AidenServerTrustError.trustConfigurationFailed(dateStatus) + } + } + var evaluationError: CFError? + guard SecTrustEvaluateWithError(serverTrust, &evaluationError) else { + throw AidenServerTrustError.hostnameOrCertificateInvalid + } + let actualFingerprint = try spkiSHA256(certificate: leaf) + guard constantTimeEqual(actualFingerprint, expectedFingerprint) else { + throw AidenServerTrustError.publicKeyPinMismatch + } + } + + private static func constantTimeEqual(_ left: String, _ right: String) -> Bool { + let lhs = Array(left.utf8) + let rhs = Array(right.utf8) + var difference = lhs.count ^ rhs.count + for index in 0.. Bool { + destination.scheme?.lowercased() == "https" && + destination.host == expectedHost && + (destination.port ?? 443) == expectedPort + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + challenge.protectionSpace.host == expectedHost, + challenge.protectionSpace.port == expectedPort, + let serverTrust = challenge.protectionSpace.serverTrust else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + do { + try AidenServerTrust.evaluate( + serverTrust: serverTrust, + expectedHost: expectedHost, + expectedFingerprint: expectedFingerprint, + policy: trustPolicy + ) + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + } catch let error as AidenServerTrustError { + failureLock.lock() + recordedTrustError = error + failureLock.unlock() + completionHandler(.cancelAuthenticationChallenge, nil) + } catch { + completionHandler(.cancelAuthenticationChallenge, nil) + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void + ) { + guard let destination = request.url, allowsRedirect(to: destination) else { + completionHandler(nil) + return + } + completionHandler(request) + } +} + +/// Used only to fetch an AES-GCM sealed trust envelope. No unsealed response +/// data from this session is trusted, and redirects are always rejected. +final class AidenSealedBootstrapSessionDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + private let expectedHost: String + private let expectedPort: Int + + init(endpoint: URL) { + expectedHost = endpoint.host ?? "" + expectedPort = endpoint.port ?? 443 + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + challenge.protectionSpace.host == expectedHost, + challenge.protectionSpace.port == expectedPort, + let serverTrust = challenge.protectionSpace.serverTrust else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void + ) { + completionHandler(nil) + } +} diff --git a/ios/AidenOnTheGo/Persistence/AidenChatCache.swift b/ios/AidenOnTheGo/Persistence/AidenChatCache.swift new file mode 100644 index 00000000..ecbb9621 --- /dev/null +++ b/ios/AidenOnTheGo/Persistence/AidenChatCache.swift @@ -0,0 +1,309 @@ +import CryptoKit +import Foundation + +actor AidenChatCache { + static let shared = AidenChatCache() + + struct ActiveStream: Codable, Equatable, Sendable { + let deviceId: String + let streamId: String + let turnId: String + var lastSequence: Int + } + + private struct ChatListEnvelope: Codable { + let instanceId: String + let workspaceId: String + let chats: [AidenChat] + } + + private struct ChatEnvelope: Codable { + let instanceId: String + let chat: AidenChat + } + + private struct StreamEnvelope: Codable { + let instanceId: String + let chatId: String + let stream: ActiveStream + } + + private let root: URL + private let fileManager: FileManager + private let maxCacheFileBytes = 10 * 1_024 * 1_024 + private let maxAttachmentImageCacheBytes = 96 * 1_024 * 1_024 + + init(root: URL? = nil, fileManager: FileManager = .default) { + self.fileManager = fileManager + if let root { + self.root = root + } else { + let applicationSupport = fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? fileManager.temporaryDirectory + self.root = applicationSupport + .appending(path: "AidenOnTheGo", directoryHint: .isDirectory) + .appending(path: "RemoteChatCache-v1", directoryHint: .isDirectory) + } + } + + func loadChats(instanceId: String, workspaceId: String) -> [AidenChat]? { + guard let envelope: ChatListEnvelope = load( + ChatListEnvelope.self, + from: fileURL(kind: "lists", instanceId, workspaceId) + ), envelope.instanceId == instanceId, envelope.workspaceId == workspaceId else { + return nil + } + return envelope.chats + } + + func saveChats(_ chats: [AidenChat], instanceId: String, workspaceId: String) throws { + try save( + ChatListEnvelope(instanceId: instanceId, workspaceId: workspaceId, chats: chats), + to: fileURL(kind: "lists", instanceId, workspaceId) + ) + } + + func loadChat(instanceId: String, chatId: String) -> AidenChat? { + guard let envelope: ChatEnvelope = load( + ChatEnvelope.self, + from: fileURL(kind: "chats", instanceId, chatId) + ), envelope.instanceId == instanceId, envelope.chat.id == chatId else { + return nil + } + return envelope.chat + } + + func saveChat(_ chat: AidenChat, instanceId: String) throws { + try save( + ChatEnvelope(instanceId: instanceId, chat: chat), + to: fileURL(kind: "chats", instanceId, chat.id) + ) + } + + func loadActiveStream(instanceId: String, chatId: String) -> ActiveStream? { + guard let envelope: StreamEnvelope = load( + StreamEnvelope.self, + from: fileURL(kind: "streams", instanceId, chatId) + ), envelope.instanceId == instanceId, envelope.chatId == chatId else { + return nil + } + return envelope.stream + } + + func saveActiveStream(_ stream: ActiveStream, instanceId: String, chatId: String) throws { + try save( + StreamEnvelope(instanceId: instanceId, chatId: chatId, stream: stream), + to: fileURL(kind: "streams", instanceId, chatId) + ) + } + + func removeActiveStream(instanceId: String, chatId: String) { + try? fileManager.removeItem(at: fileURL(kind: "streams", instanceId, chatId)) + } + + @discardableResult + func removeActiveStream(instanceId: String, chatId: String, ifStreamId streamId: String) -> Bool { + guard loadActiveStream(instanceId: instanceId, chatId: chatId)?.streamId == streamId else { + return false + } + removeActiveStream(instanceId: instanceId, chatId: chatId) + return true + } + + func removeChat(instanceId: String, chatId: String) { + try? fileManager.removeItem(at: fileURL(kind: "chats", instanceId, chatId)) + removeActiveStream(instanceId: instanceId, chatId: chatId) + try? fileManager.removeItem(at: attachmentChatDirectory(instanceId: instanceId, chatId: chatId)) + } + + func purge(instanceId: String) { + purgeFiles(kind: "lists", instanceId: instanceId, as: ChatListEnvelope.self) { $0.instanceId } + purgeFiles(kind: "chats", instanceId: instanceId, as: ChatEnvelope.self) { $0.instanceId } + purgeFiles(kind: "streams", instanceId: instanceId, as: StreamEnvelope.self) { $0.instanceId } + try? fileManager.removeItem(at: attachmentInstanceDirectory(instanceId: instanceId)) + } + + func removeActiveStreams(instanceId: String) { + purgeFiles(kind: "streams", instanceId: instanceId, as: StreamEnvelope.self) { $0.instanceId } + } + + func attachmentImage( + instanceId: String, + deviceId: String, + chatId: String, + attachment: AidenMessageAttachment + ) -> Data? { + guard attachment.kind == .image else { return nil } + let url = attachmentImageURL( + instanceId: instanceId, + deviceId: deviceId, + chatId: chatId, + attachmentId: attachment.id + ) + guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]) else { return nil } + guard let validated = AidenAttachmentImageValidation.validatedData( + data, + mimeType: attachment.mimeType, + declaredSize: attachment.size + ) else { + try? fileManager.removeItem(at: url) + return nil + } + try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path) + return validated + } + + func saveAttachmentImage( + _ data: Data, + instanceId: String, + deviceId: String, + chatId: String, + attachment: AidenMessageAttachment + ) throws { + guard attachment.kind == .image, + AidenAttachmentImageValidation.validatedData( + data, + mimeType: attachment.mimeType, + declaredSize: attachment.size + ) != nil + else { throw CocoaError(.fileReadCorruptFile) } + let url = attachmentImageURL( + instanceId: instanceId, + deviceId: deviceId, + chatId: chatId, + attachmentId: attachment.id + ) + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] + ) + try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + pruneAttachmentImages(instanceId: instanceId, preserving: url) + } + + func removeAttachmentImage(instanceId: String, deviceId: String, chatId: String, attachmentId: String) { + try? fileManager.removeItem(at: attachmentImageURL( + instanceId: instanceId, + deviceId: deviceId, + chatId: chatId, + attachmentId: attachmentId + )) + } + + private func fileURL(kind: String, _ parts: String...) -> URL { + let digest = SHA256.hash(data: Data(parts.joined(separator: "\u{1f}").utf8)) + let name = digest.map { String(format: "%02x", $0) }.joined() + return root + .appending(path: kind, directoryHint: .isDirectory) + .appending(path: "\(name).json") + } + + private func digestName(_ value: String) -> String { + SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } + + private func attachmentInstanceDirectory(instanceId: String) -> URL { + root + .appending(path: "attachment-images", directoryHint: .isDirectory) + .appending(path: digestName(instanceId), directoryHint: .isDirectory) + } + + private func attachmentChatDirectory(instanceId: String, chatId: String) -> URL { + attachmentInstanceDirectory(instanceId: instanceId) + .appending(path: digestName(chatId), directoryHint: .isDirectory) + } + + private func attachmentImageURL( + instanceId: String, + deviceId: String, + chatId: String, + attachmentId: String + ) -> URL { + attachmentChatDirectory(instanceId: instanceId, chatId: chatId) + .appending(path: digestName(deviceId), directoryHint: .isDirectory) + .appending(path: "\(digestName(attachmentId)).image") + } + + private func pruneAttachmentImages(instanceId: String, preserving preservedURL: URL) { + let directory = attachmentInstanceDirectory(instanceId: instanceId) + guard let enumerator = fileManager.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { return } + let files = enumerator.compactMap { value -> (url: URL, bytes: Int, modified: Date)? in + guard let url = value as? URL, + let values = try? url.resourceValues(forKeys: [ + .isRegularFileKey, + .fileSizeKey, + .contentModificationDateKey, + ]), + values.isRegularFile == true + else { return nil } + return (url, max(0, values.fileSize ?? 0), values.contentModificationDate ?? .distantPast) + }.sorted { lhs, rhs in + if lhs.url == preservedURL { return true } + if rhs.url == preservedURL { return false } + return lhs.modified > rhs.modified + } + var retainedBytes = 0 + for file in files { + if retainedBytes + file.bytes <= maxAttachmentImageCacheBytes { + retainedBytes += file.bytes + } else { + try? fileManager.removeItem(at: file.url) + } + } + } + + private func load(_ type: Value.Type, from url: URL) -> Value? { + guard let data = try? Data(contentsOf: url), + data.count <= maxCacheFileBytes else { return nil } + return try? JSONDecoder().decode(type, from: data) + } + + private func purgeFiles( + kind: String, + instanceId: String, + as type: Value.Type, + instance: (Value) -> String + ) { + let directory = root.appending(path: kind, directoryHint: .isDirectory) + guard let urls = try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { return } + for url in urls { + if let envelope: Value = load(type, from: url), instance(envelope) == instanceId { + try? fileManager.removeItem(at: url) + continue + } + // 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. + guard let data = try? Data(contentsOf: url), + data.count <= maxCacheFileBytes, + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + object["instanceId"] as? String == instanceId else { continue } + try? fileManager.removeItem(at: url) + } + } + + private func save(_ value: Value, to url: URL) throws { + let data = try JSONEncoder().encode(value) + guard data.count <= maxCacheFileBytes else { + throw CocoaError(.fileWriteOutOfSpace) + } + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] + ) + try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } +} diff --git a/ios/AidenOnTheGo/Resources/AidenOnTheGo.entitlements b/ios/AidenOnTheGo/Resources/AidenOnTheGo.entitlements new file mode 100644 index 00000000..d9849a81 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/AidenOnTheGo.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(APP_GROUP_IDENTIFIER) + + + diff --git a/ios/AidenOnTheGo/Resources/AppIcon.icon/Assets/aiden-icon-june15.png b/ios/AidenOnTheGo/Resources/AppIcon.icon/Assets/aiden-icon-june15.png new file mode 100644 index 00000000..6c4d3adc Binary files /dev/null and b/ios/AidenOnTheGo/Resources/AppIcon.icon/Assets/aiden-icon-june15.png differ diff --git a/ios/AidenOnTheGo/Resources/AppIcon.icon/icon.json b/ios/AidenOnTheGo/Resources/AppIcon.icon/icon.json new file mode 100644 index 00000000..7742a58c --- /dev/null +++ b/ios/AidenOnTheGo/Resources/AppIcon.icon/icon.json @@ -0,0 +1,45 @@ +{ + "fill" : { + "solid" : "display-p3:0.33209,0.32777,0.96783,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "glass-specializations" : [ + { + "value" : false + }, + { + "appearance" : "dark", + "value" : true + } + ], + "image-name" : "aiden-icon-june15.png", + "name" : "aiden-icon-june15", + "position" : { + "scale" : 1.1, + "translation-in-points" : [ + 49, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/AidenAppIcon.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/AidenAppIcon.imageset/Contents.json new file mode 100644 index 00000000..b7d27f7f --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/AidenAppIcon.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "aiden-app-icon.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/AidenAppIcon.imageset/aiden-app-icon.png b/ios/AidenOnTheGo/Resources/Assets.xcassets/AidenAppIcon.imageset/aiden-app-icon.png new file mode 100644 index 00000000..5fda07a1 Binary files /dev/null and b/ios/AidenOnTheGo/Resources/Assets.xcassets/AidenAppIcon.imageset/aiden-app-icon.png differ diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/AidenSidebarLogo.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/AidenSidebarLogo.imageset/Contents.json new file mode 100644 index 00000000..677c9407 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/AidenSidebarLogo.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "aiden-sidebar-logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/AidenSidebarLogo.imageset/aiden-sidebar-logo.png b/ios/AidenOnTheGo/Resources/Assets.xcassets/AidenSidebarLogo.imageset/aiden-sidebar-logo.png new file mode 100644 index 00000000..6e69b89f Binary files /dev/null and b/ios/AidenOnTheGo/Resources/Assets.xcassets/AidenSidebarLogo.imageset/aiden-sidebar-logo.png differ diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..2d274073 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "aiden-icon-june15.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIcon.appiconset/aiden-icon-june15.png b/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIcon.appiconset/aiden-icon-june15.png new file mode 100644 index 00000000..6c4d3adc Binary files /dev/null and b/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIcon.appiconset/aiden-icon-june15.png differ diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIconMonochrome.appiconset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIconMonochrome.appiconset/Contents.json new file mode 100644 index 00000000..f7e578c1 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIconMonochrome.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "aiden-app-icon-monochrome.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIconMonochrome.appiconset/aiden-app-icon-monochrome.png b/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIconMonochrome.appiconset/aiden-app-icon-monochrome.png new file mode 100644 index 00000000..923507f6 Binary files /dev/null and b/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIconMonochrome.appiconset/aiden-app-icon-monochrome.png differ diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIconMonochromePreview.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIconMonochromePreview.imageset/Contents.json new file mode 100644 index 00000000..cbd838a3 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIconMonochromePreview.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "aiden-app-icon-monochrome.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIconMonochromePreview.imageset/aiden-app-icon-monochrome.png b/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIconMonochromePreview.imageset/aiden-app-icon-monochrome.png new file mode 100644 index 00000000..d07186ad Binary files /dev/null and b/ios/AidenOnTheGo/Resources/Assets.xcassets/AppIconMonochromePreview.imageset/aiden-app-icon-monochrome.png differ diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingBuild.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingBuild.imageset/Contents.json new file mode 100644 index 00000000..dbee68e6 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingBuild.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "onboarding-build.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingBuild.imageset/onboarding-build.png b/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingBuild.imageset/onboarding-build.png new file mode 100644 index 00000000..3613a375 Binary files /dev/null and b/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingBuild.imageset/onboarding-build.png differ diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingControl.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingControl.imageset/Contents.json new file mode 100644 index 00000000..c0d92f39 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingControl.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "onboarding-control.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingControl.imageset/onboarding-control.png b/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingControl.imageset/onboarding-control.png new file mode 100644 index 00000000..1617bfaf Binary files /dev/null and b/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingControl.imageset/onboarding-control.png differ diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingExtend.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingExtend.imageset/Contents.json new file mode 100644 index 00000000..a1a98fb4 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingExtend.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "onboarding-extend.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingExtend.imageset/onboarding-extend.png b/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingExtend.imageset/onboarding-extend.png new file mode 100644 index 00000000..cd30e701 Binary files /dev/null and b/ios/AidenOnTheGo/Resources/Assets.xcassets/OnboardingExtend.imageset/onboarding-extend.png differ diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-amazon-bedrock.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-amazon-bedrock.imageset/Contents.json new file mode 100644 index 00000000..440f66fc --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-amazon-bedrock.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "amazon-bedrock.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-amazon-bedrock.imageset/amazon-bedrock.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-amazon-bedrock.imageset/amazon-bedrock.svg new file mode 100644 index 00000000..70316bab --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-amazon-bedrock.imageset/amazon-bedrock.svg @@ -0,0 +1 @@ +Amazon \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-ant-ling.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-ant-ling.imageset/Contents.json new file mode 100644 index 00000000..1445a6f4 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-ant-ling.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "ant-ling.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-ant-ling.imageset/ant-ling.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-ant-ling.imageset/ant-ling.svg new file mode 100644 index 00000000..3253266f --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-ant-ling.imageset/ant-ling.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-anthropic.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-anthropic.imageset/Contents.json new file mode 100644 index 00000000..ee77b5e1 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-anthropic.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "anthropic.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-anthropic.imageset/anthropic.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-anthropic.imageset/anthropic.svg new file mode 100644 index 00000000..c917480d --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-anthropic.imageset/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-apple-foundation-models.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-apple-foundation-models.imageset/Contents.json new file mode 100644 index 00000000..26216090 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-apple-foundation-models.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "apple-foundation-models.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-apple-foundation-models.imageset/apple-foundation-models.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-apple-foundation-models.imageset/apple-foundation-models.svg new file mode 100644 index 00000000..de4b0d17 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-apple-foundation-models.imageset/apple-foundation-models.svg @@ -0,0 +1 @@ +Apple \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-azure-openai-responses.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-azure-openai-responses.imageset/Contents.json new file mode 100644 index 00000000..042f9556 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-azure-openai-responses.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "azure-openai-responses.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-azure-openai-responses.imageset/azure-openai-responses.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-azure-openai-responses.imageset/azure-openai-responses.svg new file mode 100644 index 00000000..69a264e2 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-azure-openai-responses.imageset/azure-openai-responses.svg @@ -0,0 +1 @@ +Microsoft Azure \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cerebras.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cerebras.imageset/Contents.json new file mode 100644 index 00000000..98c01ddb --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cerebras.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "cerebras.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cerebras.imageset/cerebras.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cerebras.imageset/cerebras.svg new file mode 100644 index 00000000..9562aff9 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cerebras.imageset/cerebras.svg @@ -0,0 +1,5 @@ + + Cerebras + + + diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-claude.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-claude.imageset/Contents.json new file mode 100644 index 00000000..2ab0af9c --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-claude.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "claude.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-claude.imageset/claude.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-claude.imageset/claude.svg new file mode 100644 index 00000000..1beee861 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-claude.imageset/claude.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cloudflare-ai-gateway.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cloudflare-ai-gateway.imageset/Contents.json new file mode 100644 index 00000000..2b4ad463 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cloudflare-ai-gateway.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "cloudflare-ai-gateway.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cloudflare-ai-gateway.imageset/cloudflare-ai-gateway.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cloudflare-ai-gateway.imageset/cloudflare-ai-gateway.svg new file mode 100644 index 00000000..a1cf2d6d --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cloudflare-ai-gateway.imageset/cloudflare-ai-gateway.svg @@ -0,0 +1 @@ +Cloudflare \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cloudflare-workers-ai.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cloudflare-workers-ai.imageset/Contents.json new file mode 100644 index 00000000..9db13a9e --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cloudflare-workers-ai.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "cloudflare-workers-ai.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cloudflare-workers-ai.imageset/cloudflare-workers-ai.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cloudflare-workers-ai.imageset/cloudflare-workers-ai.svg new file mode 100644 index 00000000..a1cf2d6d --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-cloudflare-workers-ai.imageset/cloudflare-workers-ai.svg @@ -0,0 +1 @@ +Cloudflare \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-concentrate.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-concentrate.imageset/Contents.json new file mode 100644 index 00000000..fe7ffbce --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-concentrate.imageset/Contents.json @@ -0,0 +1,17 @@ +{ + "images": [ + { + "filename": "concentrate.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} + diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-concentrate.imageset/concentrate.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-concentrate.imageset/concentrate.svg new file mode 100644 index 00000000..0394c087 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-concentrate.imageset/concentrate.svg @@ -0,0 +1,16 @@ + + + Concentrate + An eight-by-eight field of dots that grow toward the upper right. + + + + + + + + + + + + diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-deepseek.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-deepseek.imageset/Contents.json new file mode 100644 index 00000000..7250436b --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-deepseek.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "deepseek.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-deepseek.imageset/deepseek.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-deepseek.imageset/deepseek.svg new file mode 100644 index 00000000..e19a4597 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-deepseek.imageset/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-fireworks.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-fireworks.imageset/Contents.json new file mode 100644 index 00000000..cfe55ecc --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-fireworks.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "fireworks.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-fireworks.imageset/fireworks.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-fireworks.imageset/fireworks.svg new file mode 100644 index 00000000..cfbfcfd4 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-fireworks.imageset/fireworks.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-github-copilot.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-github-copilot.imageset/Contents.json new file mode 100644 index 00000000..d6ee362a --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-github-copilot.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "github-copilot.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-github-copilot.imageset/github-copilot.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-github-copilot.imageset/github-copilot.svg new file mode 100644 index 00000000..f064947d --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-github-copilot.imageset/github-copilot.svg @@ -0,0 +1 @@ +GitHub Copilot \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-google-vertex.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-google-vertex.imageset/Contents.json new file mode 100644 index 00000000..0a8f2413 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-google-vertex.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "google-vertex.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-google-vertex.imageset/google-vertex.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-google-vertex.imageset/google-vertex.svg new file mode 100644 index 00000000..765adb08 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-google-vertex.imageset/google-vertex.svg @@ -0,0 +1 @@ +Google Gemini \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-google.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-google.imageset/Contents.json new file mode 100644 index 00000000..b0b767a3 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-google.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "google.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-google.imageset/google.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-google.imageset/google.svg new file mode 100644 index 00000000..765adb08 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-google.imageset/google.svg @@ -0,0 +1 @@ +Google Gemini \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-grok.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-grok.imageset/Contents.json new file mode 100644 index 00000000..bada30df --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-grok.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "grok.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-grok.imageset/grok.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-grok.imageset/grok.svg new file mode 100644 index 00000000..741c54e9 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-grok.imageset/grok.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-groq.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-groq.imageset/Contents.json new file mode 100644 index 00000000..5dc3c765 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-groq.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "groq.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-groq.imageset/groq.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-groq.imageset/groq.svg new file mode 100644 index 00000000..e00197c1 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-groq.imageset/groq.svg @@ -0,0 +1 @@ + diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-huggingface.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-huggingface.imageset/Contents.json new file mode 100644 index 00000000..b893d38e --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-huggingface.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "huggingface.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-huggingface.imageset/huggingface.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-huggingface.imageset/huggingface.svg new file mode 100644 index 00000000..dd2db93b --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-huggingface.imageset/huggingface.svg @@ -0,0 +1 @@ +Hugging Face \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-kimi-coding.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-kimi-coding.imageset/Contents.json new file mode 100644 index 00000000..49bbb929 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-kimi-coding.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "kimi-coding.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-kimi-coding.imageset/kimi-coding.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-kimi-coding.imageset/kimi-coding.svg new file mode 100644 index 00000000..3d254a1a --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-kimi-coding.imageset/kimi-coding.svg @@ -0,0 +1 @@ +KIMI \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-lmstudio.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-lmstudio.imageset/Contents.json new file mode 100644 index 00000000..a2647e25 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-lmstudio.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "lmstudio.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-lmstudio.imageset/lmstudio.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-lmstudio.imageset/lmstudio.svg new file mode 100644 index 00000000..ff243d99 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-lmstudio.imageset/lmstudio.svg @@ -0,0 +1 @@ +LM Studio \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-minimax-cn.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-minimax-cn.imageset/Contents.json new file mode 100644 index 00000000..41fb1ae6 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-minimax-cn.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "minimax-cn.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-minimax-cn.imageset/minimax-cn.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-minimax-cn.imageset/minimax-cn.svg new file mode 100644 index 00000000..cca72298 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-minimax-cn.imageset/minimax-cn.svg @@ -0,0 +1 @@ +MiniMax \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-minimax.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-minimax.imageset/Contents.json new file mode 100644 index 00000000..891663ea --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-minimax.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "minimax.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-minimax.imageset/minimax.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-minimax.imageset/minimax.svg new file mode 100644 index 00000000..cca72298 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-minimax.imageset/minimax.svg @@ -0,0 +1 @@ +MiniMax \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-mistral.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-mistral.imageset/Contents.json new file mode 100644 index 00000000..5a6fe72d --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-mistral.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "mistral.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-mistral.imageset/mistral.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-mistral.imageset/mistral.svg new file mode 100644 index 00000000..6388f65a --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-mistral.imageset/mistral.svg @@ -0,0 +1 @@ +Mistral AI \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-moonshotai-cn.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-moonshotai-cn.imageset/Contents.json new file mode 100644 index 00000000..d3d1e2ac --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-moonshotai-cn.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "moonshotai-cn.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-moonshotai-cn.imageset/moonshotai-cn.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-moonshotai-cn.imageset/moonshotai-cn.svg new file mode 100644 index 00000000..3d254a1a --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-moonshotai-cn.imageset/moonshotai-cn.svg @@ -0,0 +1 @@ +KIMI \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-moonshotai.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-moonshotai.imageset/Contents.json new file mode 100644 index 00000000..f5348b3d --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-moonshotai.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "moonshotai.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-moonshotai.imageset/moonshotai.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-moonshotai.imageset/moonshotai.svg new file mode 100644 index 00000000..3d254a1a --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-moonshotai.imageset/moonshotai.svg @@ -0,0 +1 @@ +KIMI \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-nvidia.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-nvidia.imageset/Contents.json new file mode 100644 index 00000000..d77a7b25 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-nvidia.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "nvidia.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-nvidia.imageset/nvidia.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-nvidia.imageset/nvidia.svg new file mode 100644 index 00000000..2c7ff66f --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-nvidia.imageset/nvidia.svg @@ -0,0 +1 @@ +NVIDIA \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-ollama.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-ollama.imageset/Contents.json new file mode 100644 index 00000000..684f53c3 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-ollama.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "ollama.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-ollama.imageset/ollama.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-ollama.imageset/ollama.svg new file mode 100644 index 00000000..bc368e99 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-ollama.imageset/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openai-codex.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openai-codex.imageset/Contents.json new file mode 100644 index 00000000..3a2aa15e --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openai-codex.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "openai-codex.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openai-codex.imageset/openai-codex.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openai-codex.imageset/openai-codex.svg new file mode 100644 index 00000000..ebbdab0e --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openai-codex.imageset/openai-codex.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openai.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openai.imageset/Contents.json new file mode 100644 index 00000000..f9c116ea --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openai.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "openai.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openai.imageset/openai.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openai.imageset/openai.svg new file mode 100644 index 00000000..ebbdab0e --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openai.imageset/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-opencode-go.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-opencode-go.imageset/Contents.json new file mode 100644 index 00000000..2ba4d636 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-opencode-go.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "opencode-go.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-opencode-go.imageset/opencode-go.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-opencode-go.imageset/opencode-go.svg new file mode 100644 index 00000000..157edc4d --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-opencode-go.imageset/opencode-go.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-opencode.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-opencode.imageset/Contents.json new file mode 100644 index 00000000..bc6ee1e9 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-opencode.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "opencode.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-opencode.imageset/opencode.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-opencode.imageset/opencode.svg new file mode 100644 index 00000000..157edc4d --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-opencode.imageset/opencode.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openrouter.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openrouter.imageset/Contents.json new file mode 100644 index 00000000..7276fb1d --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openrouter.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "openrouter.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openrouter.imageset/openrouter.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openrouter.imageset/openrouter.svg new file mode 100644 index 00000000..83ec807b --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-openrouter.imageset/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-together.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-together.imageset/Contents.json new file mode 100644 index 00000000..3c34c015 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-together.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "together.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-together.imageset/together.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-together.imageset/together.svg new file mode 100644 index 00000000..73f80f71 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-together.imageset/together.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-vercel-ai-gateway.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-vercel-ai-gateway.imageset/Contents.json new file mode 100644 index 00000000..cb72002d --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-vercel-ai-gateway.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "vercel-ai-gateway.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-vercel-ai-gateway.imageset/vercel-ai-gateway.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-vercel-ai-gateway.imageset/vercel-ai-gateway.svg new file mode 100644 index 00000000..821ecfff --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-vercel-ai-gateway.imageset/vercel-ai-gateway.svg @@ -0,0 +1 @@ +Vercel \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xai.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xai.imageset/Contents.json new file mode 100644 index 00000000..bf041557 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xai.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "xai.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xai.imageset/xai.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xai.imageset/xai.svg new file mode 100644 index 00000000..74af63dd --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xai.imageset/xai.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-ams.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-ams.imageset/Contents.json new file mode 100644 index 00000000..b256c996 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-ams.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "xiaomi-token-plan-ams.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-ams.imageset/xiaomi-token-plan-ams.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-ams.imageset/xiaomi-token-plan-ams.svg new file mode 100644 index 00000000..f08eae5b --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-ams.imageset/xiaomi-token-plan-ams.svg @@ -0,0 +1 @@ +Xiaomi \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-cn.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-cn.imageset/Contents.json new file mode 100644 index 00000000..2c8bd6b9 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-cn.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "xiaomi-token-plan-cn.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-cn.imageset/xiaomi-token-plan-cn.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-cn.imageset/xiaomi-token-plan-cn.svg new file mode 100644 index 00000000..f08eae5b --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-cn.imageset/xiaomi-token-plan-cn.svg @@ -0,0 +1 @@ +Xiaomi \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-sgp.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-sgp.imageset/Contents.json new file mode 100644 index 00000000..e03da649 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-sgp.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "xiaomi-token-plan-sgp.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-sgp.imageset/xiaomi-token-plan-sgp.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-sgp.imageset/xiaomi-token-plan-sgp.svg new file mode 100644 index 00000000..f08eae5b --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi-token-plan-sgp.imageset/xiaomi-token-plan-sgp.svg @@ -0,0 +1 @@ +Xiaomi \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi.imageset/Contents.json new file mode 100644 index 00000000..ab138fa0 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "xiaomi.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "template" + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi.imageset/xiaomi.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi.imageset/xiaomi.svg new file mode 100644 index 00000000..f08eae5b --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-xiaomi.imageset/xiaomi.svg @@ -0,0 +1 @@ +Xiaomi \ No newline at end of file diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-zai-coding-cn.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-zai-coding-cn.imageset/Contents.json new file mode 100644 index 00000000..92664fa1 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-zai-coding-cn.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "zai-coding-cn.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-zai-coding-cn.imageset/zai-coding-cn.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-zai-coding-cn.imageset/zai-coding-cn.svg new file mode 100644 index 00000000..4f511bd7 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-zai-coding-cn.imageset/zai-coding-cn.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-zai.imageset/Contents.json b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-zai.imageset/Contents.json new file mode 100644 index 00000000..aeb04652 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-zai.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "zai.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-zai.imageset/zai.svg b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-zai.imageset/zai.svg new file mode 100644 index 00000000..4f511bd7 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Assets.xcassets/ProviderLogo-zai.imageset/zai.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/AidenOnTheGo/Resources/Info.plist b/ios/AidenOnTheGo/Resources/Info.plist new file mode 100644 index 00000000..7694184a --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Info.plist @@ -0,0 +1,79 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleDisplayName + $(APP_DISPLAY_NAME) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER).share + CFBundleURLSchemes + + $(APP_URL_SCHEME) + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + AidenAppGroupIdentifier + $(APP_GROUP_IDENTIFIER) + AidenKeychainService + $(KEYCHAIN_SERVICE) + AidenURLScheme + $(APP_URL_SCHEME) + NSCameraUsageDescription + Aiden On The Go uses the camera to scan Aiden Agent pairing QR codes. + NSBonjourServices + + _aiden-agent._tcp + + NSLocalNetworkUsageDescription + Aiden On The Go connects to your Aiden Agent on your local network. + NSMicrophoneUsageDescription + Aiden On The Go uses the microphone only when you start on-device voice input. + NSPhotoLibraryAddUsageDescription + Aiden On The Go saves chat images to your Photos library only when you choose Save Image or Save All Images. + NSSpeechRecognitionUsageDescription + Aiden On The Go transcribes your voice on this device to fill the chat composer. + NSSupportsLiveActivities + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/AidenOnTheGo/Resources/Localizable.xcstrings b/ios/AidenOnTheGo/Resources/Localizable.xcstrings new file mode 100644 index 00000000..00ebfd34 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/Localizable.xcstrings @@ -0,0 +1,6 @@ +{ + "sourceLanguage" : "en", + "strings" : { + }, + "version" : "1.0" +} diff --git a/ios/AidenOnTheGo/Resources/PrivacyInfo.xcprivacy b/ios/AidenOnTheGo/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..0a95d093 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + diff --git a/ios/AidenOnTheGo/Resources/ThirdPartyNotices/Hermex-LICENSE.txt b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/Hermex-LICENSE.txt new file mode 100644 index 00000000..7c6ff3a2 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/Hermex-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Uzair Ansar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ios/AidenOnTheGo/Resources/ThirdPartyNotices/KeychainAccess-LICENSE.txt b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/KeychainAccess-LICENSE.txt new file mode 100644 index 00000000..1c519dba --- /dev/null +++ b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/KeychainAccess-LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 kishikawa katsumi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ios/AidenOnTheGo/Resources/ThirdPartyNotices/MarkdownUI-LICENSE.txt b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/MarkdownUI-LICENSE.txt new file mode 100644 index 00000000..8f993169 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/MarkdownUI-LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 Guillermo Gonzalez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ios/AidenOnTheGo/Resources/ThirdPartyNotices/NOTICE.txt b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/NOTICE.txt new file mode 100644 index 00000000..c800b8bb --- /dev/null +++ b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/NOTICE.txt @@ -0,0 +1,19 @@ +Aiden On The Go third-party notices + +This folder contains license notices for third-party code and assets bundled +with the iOS app. + +- Hermex (adapted SwiftUI interaction and implementation foundation) + See Hermex-LICENSE.txt. +- KeychainAccess 4.2.2 + See KeychainAccess-LICENSE.txt. +- Thinking Orbs 0.3.1 (vendored SwiftUI iOS port) + See ThinkingOrbs-LICENSE.txt. +- MarkdownUI 2.4.1 + See MarkdownUI-LICENSE.txt. +- NetworkImage 6.0.1 (transitive dependency of MarkdownUI) + See NetworkImage-LICENSE.txt. +- swift-cmark 0.8.0 (transitive dependency of MarkdownUI) + See swift-cmark-COPYING.txt. +- Provider logos (vector marks shared with Aiden Agent for provider identity) + See ProviderLogos-NOTICE.md for sources and trademark ownership. diff --git a/ios/AidenOnTheGo/Resources/ThirdPartyNotices/NetworkImage-LICENSE.txt b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/NetworkImage-LICENSE.txt new file mode 100644 index 00000000..925effba --- /dev/null +++ b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/NetworkImage-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Guille Gonzalez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ios/AidenOnTheGo/Resources/ThirdPartyNotices/ProviderLogos-NOTICE.md b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/ProviderLogos-NOTICE.md new file mode 100644 index 00000000..e6f8a99a --- /dev/null +++ b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/ProviderLogos-NOTICE.md @@ -0,0 +1,31 @@ +# Provider logo assets + +These SVGs are the compact provider marks shared with Aiden Agent's +`ProviderIcon` system. Aiden On The Go copies the reviewed vector sources into +its Xcode asset catalog so native provider surfaces use the same identity map. + +- Root Simple Icons assets remain monochrome template images so they follow + Aiden's semantic palette in light, dark, and custom appearances. +- Multicolor or opaque-square assets preserve their original rendering so + their negative space and brand colors remain intact. +- Fireworks, Together, and Grok retain their original official vector paths + with the viewBox cropped to the logomark portion of the supplied wordmark. +- The supplied `cerebras.svg` was an OpenAI wordmark. It was replaced with the + compact Cerebras mark from `@lobehub/icons-static-svg`. +- Radius intentionally has no asset. Unknown, custom, Radius, and future Pi + providers use the native neutral-initial fallback in `AidenProviderIcon`. + +## Provenance + +- Simple Icons via jsDelivr: OpenAI, Anthropic/Claude, Google Gemini, DeepSeek, + NVIDIA, OpenRouter, Hugging Face, Mistral, Cloudflare, Vercel, Amazon, Azure, + GitHub Copilot, MiniMax, Xiaomi, LM Studio, Ollama, and their provider aliases. +- Official provider sites: Ant Ling, Fireworks, Groq, Together, Z.AI, OpenCode, + Kimi/Moonshot, and their regional or product aliases. +- Wikimedia Commons: xAI and Grok. +- Lobe Icons static SVG package: Cerebras, replacing an incorrect OpenAI asset + in the supplied folder. + +The corresponding acquisition notes remain with Aiden Agent's canonical +`renderer/assets/provider-logos/README.md` inventory. +All marks remain the property of their respective owners. diff --git a/ios/AidenOnTheGo/Resources/ThirdPartyNotices/ThinkingOrbs-LICENSE.txt b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/ThinkingOrbs-LICENSE.txt new file mode 100644 index 00000000..0e9405a2 --- /dev/null +++ b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/ThinkingOrbs-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jakub Antalik + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ios/AidenOnTheGo/Resources/ThirdPartyNotices/swift-cmark-COPYING.txt b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/swift-cmark-COPYING.txt new file mode 100644 index 00000000..db88a81b --- /dev/null +++ b/ios/AidenOnTheGo/Resources/ThirdPartyNotices/swift-cmark-COPYING.txt @@ -0,0 +1,170 @@ +Copyright (c) 2014, John MacFarlane + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +----- + +houdini.h, houdini_href_e.c, houdini_html_e.c, houdini_html_u.c + +derive from https://github.com/vmg/houdini (with some modifications) + +Copyright (C) 2012 Vicent Martí + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +----- + +buffer.h, buffer.c, chunk.h + +are derived from code (C) 2012 Github, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +----- + +utf8.c and utf8.c + +are derived from utf8proc +(), +(C) 2009 Public Software Group e. V., Berlin, Germany. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +----- + +The normalization code in normalize.py was derived from the +markdowntest project, Copyright 2013 Karl Dubost: + +The MIT License (MIT) + +Copyright (c) 2013 Karl Dubost + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +----- + +The CommonMark spec (test/spec.txt) is + +Copyright (C) 2014-15 John MacFarlane + +Released under the Creative Commons CC-BY-SA 4.0 license: +. + +----- + +The test software in test/ is + +Copyright (c) 2014, John MacFarlane + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/ios/AidenOnTheGoTests/AidenChatTests.swift b/ios/AidenOnTheGoTests/AidenChatTests.swift new file mode 100644 index 00000000..1369db81 --- /dev/null +++ b/ios/AidenOnTheGoTests/AidenChatTests.swift @@ -0,0 +1,1605 @@ +import Foundation +import SwiftUI +import UIKit +import XCTest +@testable import AidenOnTheGo + +final class AidenChatTests: XCTestCase { + func testRemoteChatDecodesPendingBackgroundTitleAndUsesABoundedRetryWindow() throws { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let chat = try decoder.decode( + AidenChat.self, + from: Data( + #"{"id":"chat-1","workspaceId":"workspace-1","title":"Tell me about this repo","messages":[],"createdAt":"2026-08-20T12:00:00Z","updatedAt":"2026-08-20T12:00:01Z","revision":"rev_1","titlePending":true}"#.utf8 + ) + ) + + XCTAssertTrue(chat.isTitlePending) + XCTAssertFalse(AidenChatTitleReconciliation.retryMilliseconds.isEmpty) + XCTAssertLessThanOrEqual( + AidenChatTitleReconciliation.retryMilliseconds.reduce(0, +), + 15_000 + ) + } + + func testRemoteChatDecodesAssistantImageAttachmentsForTheSharedGallery() throws { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let chat = try decoder.decode( + AidenChat.self, + from: Data( + #"{"id":"chat-1","workspaceId":"workspace-1","title":"Image","messages":[{"id":"message-1","role":"assistant","text":"Here it is.","createdAt":"2026-08-20T12:00:00Z","attachments":[{"id":"attachment-1","name":"Result.png","mimeType":"image/png","kind":"image","size":70}]}],"createdAt":"2026-08-20T12:00:00Z","updatedAt":"2026-08-20T12:00:01Z","revision":"rev_1"}"#.utf8 + ) + ) + + XCTAssertEqual(chat.messages.first?.role, .assistant) + XCTAssertEqual(chat.messages.first?.attachments?.first?.name, "Result.png") + XCTAssertEqual(AidenMessageMediaEdge.forRole(chat.messages.first?.role ?? .user), .leading) + } + + func testRemoteChatDecodesDurableMacActivityAndUsesMacPresentationLanguage() throws { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let chat = try decoder.decode( + AidenChat.self, + from: Data( + #"{"id":"chat-1","workspaceId":"workspace-1","title":"Activity","messages":[{"id":"message-1","role":"assistant","text":"Done.","createdAt":"2026-08-20T12:00:00Z","timeline":{"version":3,"generationId":"stream-1","status":"completed","startedAt":1000,"finishedAt":3000,"steps":[{"id":"tool-1","order":0,"kind":"tool","toolName":"read_file","label":"Read file","status":"completed","startedAt":1000,"updatedAt":1500,"finishedAt":1500,"contentOffset":0,"target":"README.md"},{"id":"think-1","order":1,"kind":"thinking","startedAt":1500,"updatedAt":2500,"finishedAt":2500,"contentOffset":0,"durationMs":1000},{"id":"tool-2","order":2,"kind":"tool","toolName":"run_command","label":"Run command","status":"completed","startedAt":2500,"updatedAt":3000,"finishedAt":3000,"contentOffset":0,"detail":"Run tests"}]}}],"createdAt":"2026-08-20T12:00:00Z","updatedAt":"2026-08-20T12:00:01Z","revision":"rev_1"}"#.utf8 + ) + ) + + let timeline = try XCTUnwrap(chat.messages.first?.timeline) + XCTAssertTrue(timeline.isRendererSafe) + XCTAssertEqual(AidenAgentActivityPresentation.line(for: timeline.steps[0]), "Read README.md") + XCTAssertEqual(AidenAgentActivityPresentation.line(for: timeline.steps[1]), "Thought briefly") + XCTAssertEqual(AidenAgentActivityPresentation.line(for: timeline.steps[2]), "Ran Run tests") + XCTAssertEqual(AidenAgentActivityPresentation.summary(timeline), "Explored 1 file, ran 1 command") + } + + func testActivityTimelineRejectsAbsoluteTargetsBeforePresentation() throws { + let timeline = try JSONDecoder().decode( + AidenGenerationTimeline.self, + from: Data( + #"{"version":3,"generationId":"stream-1","status":"running","startedAt":1000,"steps":[{"id":"tool-1","order":0,"kind":"tool","toolName":"read_file","label":"Read file","status":"running","startedAt":1000,"updatedAt":1000,"contentOffset":0,"target":"/Users/private/secret"}]}"#.utf8 + ) + ) + XCTAssertFalse(timeline.isRendererSafe) + } + + func testActivityTimelineRejectsWindowsAbsoluteAndTraversalTargets() throws { + for target in [#"C:\Users\private\secret"#, #"folder\..\secret"#, #"\\server\share\secret"#] { + let timeline = AidenGenerationTimeline( + version: 3, + generationId: "stream-1", + status: .running, + startedAt: 1_000, + finishedAt: nil, + steps: [ + AidenAgentStep( + id: "tool-1", + order: 0, + kind: .tool, + toolName: "read_file", + label: "Read file", + status: .running, + startedAt: 1_000, + updatedAt: 1_000, + finishedAt: nil, + contentOffset: 0, + durationMs: nil, + target: target, + detail: nil, + lineChanges: nil + ) + ] + ) + XCTAssertFalse(timeline.isRendererSafe, "Expected to reject unsafe target: \(target)") + } + } + + func testActivitySummaryMatchesMacCategories() throws { + let timeline = AidenGenerationTimeline( + version: 3, + generationId: "stream-1", + status: .completed, + startedAt: 1_000, + finishedAt: 2_000, + steps: ["web_search", "computer_use", "compact_context", "custom_tool"].enumerated().map { index, name in + AidenAgentStep( + id: "tool-\(index)", order: index, kind: .tool, toolName: name, + label: "Tool", status: .completed, startedAt: 1_000, updatedAt: 2_000, + finishedAt: 2_000, contentOffset: 0, durationMs: 1_000, + target: nil, detail: nil, lineChanges: nil + ) + } + ) + XCTAssertEqual( + AidenAgentActivityPresentation.summary(timeline), + "1 web search, 1 Mac action, compacted context, 1 tool call" + ) + } + + func testModelCatalogHidesPresentationOnlyModelsWithoutDroppingTheirIdentity() throws { + let catalog = try JSONDecoder().decode( + AidenModelCatalog.self, + from: Data( + #"{"providers":[{"id":"google","label":"Google","models":[{"id":"gemini-pro","label":"Gemini Pro","hidden":true},{"id":"gemini-flash","label":"Gemini Flash"}]},{"id":"all-hidden","label":"Hidden","models":[{"id":"legacy","label":"Legacy","hidden":true}]}],"defaults":{"providerId":"google","modelId":"gemini-flash"}}"#.utf8 + ) + ) + + XCTAssertEqual(catalog.providers.first?.models.map(\.id), ["gemini-pro", "gemini-flash"]) + XCTAssertEqual(catalog.visibleProviders.map(\.id), ["google"]) + XCTAssertEqual(catalog.visibleProviders.first?.models.map(\.id), ["gemini-flash"]) + } + + func testModelCatalogPreservesThinkingDefaultAndRequiredThinkingPresentation() throws { + let catalog = try JSONDecoder().decode( + AidenModelCatalog.self, + from: Data( + #"{"providers":[{"id":"opencode-go","label":"OpenCode Go","models":[{"id":"ox-alpha-free","label":"Ox Alpha","thinkingLevels":["low","high","max"],"defaultThinkingLevel":"high","thinkingCanDisable":false},{"id":"legacy","label":"Legacy","thinkingLevels":["low","high"]}]}],"defaults":{}}"#.utf8 + ) + ) + + let models = try XCTUnwrap(catalog.providers.first?.models) + XCTAssertEqual(models[0].effectiveThinkingLevel, "high") + XCTAssertEqual(models[0].thinkingLabel(for: "off"), "Hide") + XCTAssertEqual(models[1].effectiveThinkingLevel, "high") + } + + func testModelCatalogKeepsNormalizedCustomProviderArtworkThroughVisibleProjection() throws { + let catalog = try JSONDecoder().decode( + AidenModelCatalog.self, + from: Data( + #"{"providers":[{"id":"custom:server","label":"Server","artwork":{"mimeType":"image/png","dataBase64":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="},"models":[{"id":"chat","label":"Chat"}]}],"defaults":{}}"#.utf8 + ) + ) + + XCTAssertEqual(catalog.visibleProviders.first?.artwork?.mimeType, "image/png") + XCTAssertNotNil(catalog.visibleProviders.first?.artwork?.boundedPNGData) + + var oversizedHeader = Data(repeating: 0, count: 24) + oversizedHeader.replaceSubrange(0..<8, with: [137, 80, 78, 71, 13, 10, 26, 10]) + oversizedHeader.replaceSubrange(12..<16, with: [73, 72, 68, 82]) + oversizedHeader.replaceSubrange(16..<20, with: [0, 0, 0, 65]) + oversizedHeader.replaceSubrange(20..<24, with: [0, 0, 0, 1]) + XCTAssertNil( + AidenProviderArtwork( + mimeType: "image/png", + dataBase64: oversizedHeader.base64EncodedString() + ).boundedPNGData + ) + } + + func testRelativeTimestampUsesProductSpecificBoundaries() { + let now = Date(timeIntervalSince1970: 2_000_000_000) + XCTAssertEqual(AidenRelativeTimestamp.text(for: now, now: now), "just now") + XCTAssertEqual(AidenRelativeTimestamp.text(for: now.addingTimeInterval(-59), now: now), "just now") + XCTAssertEqual(AidenRelativeTimestamp.text(for: now.addingTimeInterval(-60), now: now), "1 min ago") + XCTAssertEqual(AidenRelativeTimestamp.text(for: now.addingTimeInterval(-120), now: now), "2 mins ago") + XCTAssertEqual(AidenRelativeTimestamp.text(for: now.addingTimeInterval(-3_600), now: now), "1 hr ago") + XCTAssertEqual(AidenRelativeTimestamp.text(for: now.addingTimeInterval(-7_200), now: now), "2 hrs ago") + XCTAssertEqual(AidenRelativeTimestamp.text(for: now.addingTimeInterval(-86_400), now: now), "1 day ago") + XCTAssertEqual(AidenRelativeTimestamp.text(for: now.addingTimeInterval(-172_800), now: now), "2 days ago") + XCTAssertEqual(AidenRelativeTimestamp.text(for: now.addingTimeInterval(30), now: now), "just now") + } + + func testNewAgentPopoverKeepsTheThreeReviewedWorkspaceChoices() { + XCTAssertEqual( + AidenNewAgentChoice.allCases, + [.existingWorkspace, .newWorkspace, .scratchWorkspace] + ) + XCTAssertEqual( + AidenNewAgentChoice.allCases.map(\.title), + ["Existing Workspace", "New Workspace", "Managed Scratch Workspace"] + ) + XCTAssertEqual( + AidenNewAgentChoice.allCases.map(\.symbol), + ["folder", "folder.badge.plus", "hammer.fill"] + ) + XCTAssertTrue(AidenNewAgentChoice.allCases.allSatisfy { !$0.detail.isEmpty }) + } + + func testProviderIconResolverMatchesDesktopAliasesAndFallbackRules() { + XCTAssertEqual(AidenProviderIconResolver.slug(providerID: "openai"), "openai") + XCTAssertEqual(AidenProviderIconResolver.slug(providerID: "concentrate"), "concentrate") + XCTAssertEqual(AidenProviderIconResolver.slug(providerID: "gemini"), "google") + XCTAssertEqual(AidenProviderIconResolver.slug(providerID: "moonshot"), "moonshotai") + XCTAssertEqual( + AidenProviderIconResolver.slug(providerID: "anthropic", modelID: "claude-sonnet-4"), + "claude" + ) + XCTAssertEqual( + AidenProviderIconResolver.slug(providerID: "xai", modelID: "grok-4-fast"), + "grok" + ) + XCTAssertEqual(AidenProviderIconResolver.slug(providerID: "custom:lmstudio-2"), "lmstudio") + XCTAssertEqual(AidenProviderIconResolver.slug(providerID: "custom:ollama-42"), "ollama") + XCTAssertNil(AidenProviderIconResolver.slug(providerID: "custom:lmstudio-1")) + XCTAssertNil(AidenProviderIconResolver.slug(providerID: "future-provider")) + } + + func testAgentReplyCopyKeepsOriginalMarkdownAndRejectsNonReplies() { + let assistant = AidenChatMessage( + id: "assistant-1", + role: .assistant, + text: "## Result\n\nUse `xcodebuild test`.", + createdAt: Date(timeIntervalSince1970: 1) + ) + let user = AidenChatMessage( + id: "user-1", + role: .user, + text: "Please test it", + createdAt: Date(timeIntervalSince1970: 2) + ) + let emptyAssistant = AidenChatMessage( + id: "assistant-2", + role: .assistant, + text: "", + createdAt: Date(timeIntervalSince1970: 3) + ) + + XCTAssertEqual( + AidenMessageActionContent.copyText(for: assistant), + "## Result\n\nUse `xcodebuild test`." + ) + XCTAssertNil(AidenMessageActionContent.copyText(for: user)) + XCTAssertNil(AidenMessageActionContent.copyText(for: emptyAssistant)) + } + + func testMarkdownDocumentParsesHeadingsListsAndInlineEmphasis() { + let markdown = """ + I'll explore the repository to understand what it is. + + ## Long Live Kodak 📷 + + - **Film Frame Editor** — adjustable parameters + - `Batch processing` and export + """ + + let plainText = AidenMarkdownDocument.plainText(from: markdown) + XCTAssertTrue(plainText.hasPrefix("I'll explore")) + XCTAssertTrue(plainText.contains("Long Live Kodak 📷")) + XCTAssertTrue(plainText.contains("Film Frame Editor — adjustable parameters")) + XCTAssertTrue(plainText.contains("Batch processing and export")) + XCTAssertFalse(plainText.contains("##")) + XCTAssertFalse(plainText.contains("**")) + XCTAssertFalse(plainText.contains("`")) + } + + func testMarkdownRenderingPolicyBoundsCharactersAndCrossPlatformLineBreaks() { + XCTAssertNil(AidenMarkdownRenderingPolicy.fallbackReason( + for: String(repeating: "a", count: AidenMarkdownRenderingPolicy.maximumCharacterCount) + )) + XCTAssertEqual( + AidenMarkdownRenderingPolicy.fallbackReason( + for: String(repeating: "a", count: AidenMarkdownRenderingPolicy.maximumCharacterCount + 1) + ), + .tooManyCharacters + ) + + let allowedLines = Array( + repeating: "line", + count: AidenMarkdownRenderingPolicy.maximumLineCount + ).joined(separator: "\r\n") + XCTAssertNil(AidenMarkdownRenderingPolicy.fallbackReason(for: allowedLines)) + XCTAssertEqual( + AidenMarkdownRenderingPolicy.fallbackReason(for: allowedLines + "\u{2028}overflow"), + .tooManyLines + ) + } + + @MainActor + func testMarkdownViewRendersBlockContentAtTheFullProposedWidth() throws { + let renderer = ImageRenderer(content: AidenMarkdownView(content: """ + ## Heading + + - First item + - Second item + """).frame(width: 320)) + renderer.scale = 1 + + let image = try XCTUnwrap(renderer.uiImage) + XCTAssertEqual(image.size.width, 320, accuracy: 0.5) + XCTAssertGreaterThan(try XCTUnwrap(image.pngData()).count, 1_000) + } + + @MainActor + func testUserTextStaysContentSizedWhileAssistantMarkdownUsesTranscriptWidth() { + let userHost = UIHostingController(rootView: AidenMessageTextView( + role: .user, + content: "Short prompt" + )) + let assistantHost = UIHostingController(rootView: AidenMessageTextView( + role: .assistant, + content: "A short reply" + )) + let proposal = CGSize(width: 320, height: 1_000) + + let userSize = userHost.sizeThatFits(in: proposal) + let assistantSize = assistantHost.sizeThatFits(in: proposal) + + XCTAssertLessThan(userSize.width, 200) + XCTAssertEqual(assistantSize.width, proposal.width, accuracy: 0.5) + } + + @MainActor + func testAssistantMarkdownKeepsTheFirstGlyphInsideItsRenderedBounds() throws { + let renderer = ImageRenderer(content: AidenMessageTextView( + role: .assistant, + content: "Sounds good — the first letter must remain visible." + ).frame(width: 320, alignment: .leading)) + renderer.scale = 3 + + let image = try XCTUnwrap(renderer.cgImage) + let width = image.width + let height = image.height + var pixels = [UInt8](repeating: 0, count: width * height * 4) + let context = try XCTUnwrap(CGContext( + data: &pixels, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + + let firstPaintedColumn = (0.. 8 } + } + XCTAssertGreaterThan(try XCTUnwrap(firstPaintedColumn), 0) + } + + func testSSEParserAcceptsCanonicalFrameAndRejectsIdentityMismatches() throws { + let json = eventJSON(sequence: 1, type: "text_delta", payload: "{\"text\":\"Hello\"}") + var parser = AidenSSEParser() + XCTAssertNil(try parser.consume(line: "id: 1")) + XCTAssertNil(try parser.consume(line: "event: text_delta")) + XCTAssertNil(try parser.consume(line: "data: \(json)")) + let event = try XCTUnwrap(parser.consume(line: "")) + XCTAssertEqual(event.sequence, 1) + XCTAssertEqual(event.payload?.text, "Hello") + + var wrongID = AidenSSEParser() + _ = try wrongID.consume(line: "id: 2") + _ = try wrongID.consume(line: "data: \(json)") + XCTAssertThrowsError(try wrongID.consume(line: "")) { + XCTAssertEqual($0 as? AidenSSEParserError, .eventIDMismatch) + } + + var wrongName = AidenSSEParser() + _ = try wrongName.consume(line: "id: 1") + _ = try wrongName.consume(line: "event: reasoning_delta") + _ = try wrongName.consume(line: "data: \(json)") + XCTAssertThrowsError(try wrongName.consume(line: "")) { + XCTAssertEqual($0 as? AidenSSEParserError, .eventNameMismatch) + } + } + + func testSSEParserRejectsDuplicateJSONKeysAndOversizedFrames() throws { + let duplicate = """ + {"protocolVersion":1,"streamId":"stream-1","sequence":1,"sequence":1, + "timestamp":"2026-08-19T07:00:00.000Z","type":"heartbeat","terminal":false,"payload":{}} + """ + var parser = AidenSSEParser() + _ = try parser.consume(line: "id: 1") + _ = try parser.consume(line: "data: \(duplicate)") + XCTAssertThrowsError(try parser.consume(line: "")) { + guard case .duplicateJSONKey("sequence") = $0 as? AidenRemoteContractError else { + return XCTFail("Expected duplicate key rejection, received \($0)") + } + } + + var oversized = AidenSSEParser() + XCTAssertThrowsError( + try oversized.consume(line: String(repeating: "x", count: AidenRemoteProtocol.maxSSEFrameBytes + 1)) + ) { + XCTAssertEqual($0 as? AidenSSEParserError, .frameTooLarge) + } + } + + func testChatCacheIsScopedByInstallationAndRestoresStreamCursor() async throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "aiden-chat-cache-tests-\(UUID().uuidString)", directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: root) } + let cache = AidenChatCache(root: root) + let chat = sampleChat() + + try await cache.saveChats([chat], instanceId: "instance-a", workspaceId: "workspace-1") + try await cache.saveChat(chat, instanceId: "instance-a") + try await cache.saveActiveStream( + .init(deviceId: "device-a", streamId: "stream-1", turnId: "turn-1", lastSequence: 14), + instanceId: "instance-a", + chatId: chat.id + ) + try await cache.saveChats([chat], instanceId: "instance-b", workspaceId: "workspace-1") + try await cache.saveActiveStream( + .init(deviceId: "device-b", streamId: "stream-2", turnId: "turn-2", lastSequence: 3), + instanceId: "instance-b", + chatId: chat.id + ) + + let chatsA = await cache.loadChats(instanceId: "instance-a", workspaceId: "workspace-1") + let chatsB = await cache.loadChats(instanceId: "instance-b", workspaceId: "workspace-1") + let cachedChatA = await cache.loadChat(instanceId: "instance-a", chatId: chat.id) + let cachedChatB = await cache.loadChat(instanceId: "instance-b", chatId: chat.id) + let stream = await cache.loadActiveStream(instanceId: "instance-a", chatId: chat.id) + XCTAssertEqual(chatsA, [chat]) + XCTAssertEqual(chatsB, [chat]) + XCTAssertEqual(cachedChatA, chat) + XCTAssertNil(cachedChatB) + XCTAssertEqual( + stream, + .init(deviceId: "device-a", streamId: "stream-1", turnId: "turn-1", lastSequence: 14) + ) + + await cache.removeChat(instanceId: "instance-a", chatId: chat.id) + let removedChat = await cache.loadChat(instanceId: "instance-a", chatId: chat.id) + let removedStream = await cache.loadActiveStream(instanceId: "instance-a", chatId: chat.id) + XCTAssertNil(removedChat) + XCTAssertNil(removedStream) + + let legacyStreamURL = root + .appending(path: "streams", directoryHint: .isDirectory) + .appending(path: "legacy-stream.json") + try Data(""" + {"instanceId":"instance-a","chatId":"legacy-chat","stream":{ + "streamId":"legacy-stream","turnId":"legacy-turn","lastSequence":2}} + """.utf8).write(to: legacyStreamURL, options: .atomic) + + await cache.purge(instanceId: "instance-a") + let purgedChats = await cache.loadChats(instanceId: "instance-a", workspaceId: "workspace-1") + let retainedChats = await cache.loadChats(instanceId: "instance-b", workspaceId: "workspace-1") + let retainedActiveStream = await cache.loadActiveStream(instanceId: "instance-b", chatId: chat.id) + XCTAssertNil(purgedChats) + XCTAssertNotNil(retainedChats) + XCTAssertNotNil(retainedActiveStream) + XCTAssertFalse(FileManager.default.fileExists(atPath: legacyStreamURL.path)) + } + + func testTerminalCleanupCannotDeleteANewerActiveStream() async throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "aiden-stream-generation-tests-\(UUID().uuidString)", directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: root) } + let cache = AidenChatCache(root: root) + try await cache.saveActiveStream( + .init(deviceId: "device-a", streamId: "stream-new", turnId: "turn-new", lastSequence: 0), + instanceId: "instance-a", + chatId: "chat-1" + ) + + let staleRemoval = await cache.removeActiveStream( + instanceId: "instance-a", + chatId: "chat-1", + ifStreamId: "stream-old" + ) + XCTAssertFalse(staleRemoval) + let retained = await cache.loadActiveStream(instanceId: "instance-a", chatId: "chat-1") + XCTAssertEqual(retained?.streamId, "stream-new") + let currentRemoval = await cache.removeActiveStream( + instanceId: "instance-a", + chatId: "chat-1", + ifStreamId: "stream-new" + ) + XCTAssertTrue(currentRemoval) + } + + func testApprovalSnapshotMustBeLiveAndBoundToTheExactStreamAndChat() { + let now = Date(timeIntervalSince1970: 10_000) + let valid = AidenStreamPendingApproval( + approvalId: "approval-1", + streamId: "stream-1", + chatId: "chat-1", + summary: "Review", + toolCallId: "tool-1", + toolName: "run", + expiresAt: now.addingTimeInterval(60), + canAllow: false + ) + XCTAssertEqual( + AidenPendingApprovalResolution.resolve(valid, streamId: "stream-1", chatId: "chat-1", now: now)?.canAllow, + false + ) + XCTAssertNil(AidenPendingApprovalResolution.resolve(nil, streamId: "stream-1", chatId: "chat-1", now: now)) + XCTAssertNil(AidenPendingApprovalResolution.resolve(valid, streamId: "stream-2", chatId: "chat-1", now: now)) + XCTAssertNil(AidenPendingApprovalResolution.resolve(valid, streamId: "stream-1", chatId: "chat-2", now: now)) + XCTAssertNil( + AidenPendingApprovalResolution.resolve( + .init( + approvalId: valid.approvalId, + streamId: valid.streamId, + chatId: valid.chatId, + summary: valid.summary, + toolCallId: valid.toolCallId, + toolName: valid.toolName, + expiresAt: now, + canAllow: true + ), + streamId: "stream-1", + chatId: "chat-1", + now: now + ) + ) + } + + func testApprovalSummaryCollapsesWhitespaceForCompactDisclosure() { + XCTAssertEqual( + AidenApprovalPresentation.oneLineSummary("Run command:\n find ~/Downloads -type f"), + "Run command: find ~/Downloads -type f" + ) + XCTAssertEqual(AidenApprovalPresentation.oneLineSummary(" \n\t "), "Review requested action") + } + + func testAttachmentImageValidationAndProtectedCacheFailClosed() async throws { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 24, height: 16)) + let png = renderer.pngData { context in + UIColor.systemPink.setFill() + context.fill(CGRect(x: 0, y: 0, width: 24, height: 16)) + } + let attachment = AidenMessageAttachment( + id: "attachment-image-1", + name: "Preview.png", + mimeType: "image/png", + kind: .image, + size: png.count + ) + XCTAssertEqual( + AidenAttachmentImageValidation.validatedData( + png, + mimeType: attachment.mimeType, + declaredSize: attachment.size + ), + png + ) + XCTAssertNil(AidenAttachmentImageValidation.validatedData( + png, + mimeType: "image/jpeg", + declaredSize: png.count + )) + XCTAssertNil(AidenAttachmentImageValidation.validatedData( + png, + mimeType: "image/png", + declaredSize: png.count + 1 + )) + + let root = FileManager.default.temporaryDirectory + .appending(path: "aiden-attachment-cache-tests-\(UUID().uuidString)", directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: root) } + let cache = AidenChatCache(root: root) + try await cache.saveAttachmentImage( + png, + instanceId: "instance-a", + deviceId: "device-a", + chatId: "chat-a", + attachment: attachment + ) + let cachedImage = await cache.attachmentImage( + instanceId: "instance-a", + deviceId: "device-a", + chatId: "chat-a", + attachment: attachment + ) + XCTAssertEqual(cachedImage, png) + let wrongDeviceImage = await cache.attachmentImage( + instanceId: "instance-a", + deviceId: "device-b", + chatId: "chat-a", + attachment: attachment + ) + XCTAssertNil(wrongDeviceImage) + await cache.removeChat(instanceId: "instance-a", chatId: "chat-a") + let removedImage = await cache.attachmentImage( + instanceId: "instance-a", + deviceId: "device-a", + chatId: "chat-a", + attachment: attachment + ) + XCTAssertNil(removedImage) + } + + func testAttachmentThumbnailDownsamplesOffTheDisplayPath() async throws { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1_200, height: 800)) + let data = renderer.pngData { context in + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 1_200, height: 800)) + } + let decodedImage = await AidenAttachmentImageDecoding.thumbnail( + data: data, + maximumPixelSize: 320 + ) + let image = try XCTUnwrap(decodedImage) + XCTAssertLessThanOrEqual(max(image.size.width, image.size.height), 320) + XCTAssertEqual(image.size.width / image.size.height, 1.5, accuracy: 0.02) + } + + func testPersistedMessageOutcomesUseFixedSafePresentation() { + XCTAssertEqual( + AidenMessageOutcomePresentation.make(.init( + status: .failed, + category: "authentication", + attempts: 1, + retryExhausted: false + )), + .init( + title: "Generation failed", + detail: "The model provider rejected its credentials. Check Provider Settings on your Mac.", + symbol: "exclamationmark.triangle", + isFailure: true + ) + ) + XCTAssertEqual( + AidenMessageOutcomePresentation.make(.init( + status: .failed, + category: "private-provider-detail", + attempts: nil, + retryExhausted: nil + )).detail, + "The model provider could not complete this response." + ) + XCTAssertEqual( + AidenMessageOutcomePresentation.make(.init( + status: .cancelled, + category: nil, + attempts: nil, + retryExhausted: nil + )).title, + "Response cancelled" + ) + } + + func testTerminalStreamCursorGetsExactlyOneFinalReplayBeforeCleanup() { + var gate = AidenTerminalReplayGate() + XCTAssertFalse(gate.shouldReplay(.running)) + XCTAssertTrue(gate.shouldReplay(.cancelled)) + XCTAssertFalse(gate.shouldReplay(.cancelled)) + XCTAssertFalse(gate.shouldReplay(.error)) + } + + func testTerminalReconciliationRetriesIndefinitelyWithACappedBackoff() { + XCTAssertEqual(AidenTerminalReconciliation.retryDelayMilliseconds(attempt: -1), 1_000) + XCTAssertEqual(AidenTerminalReconciliation.retryDelayMilliseconds(attempt: 0), 1_000) + XCTAssertEqual(AidenTerminalReconciliation.retryDelayMilliseconds(attempt: 1), 2_000) + XCTAssertEqual(AidenTerminalReconciliation.retryDelayMilliseconds(attempt: 4), 16_000) + XCTAssertEqual(AidenTerminalReconciliation.retryDelayMilliseconds(attempt: 5), 30_000) + XCTAssertEqual(AidenTerminalReconciliation.retryDelayMilliseconds(attempt: 500), 30_000) + } + + func testTypedMissingStreamFallsBackToDurableChatReconciliation() throws { + let gone = try AidenRemoteJSONDecoder.decode( + AidenRemoteErrorEnvelope.self, + from: Data(#"{"error":{"code":"stream_gone","message":"Gone","requestId":"req-1","retryable":false}}"#.utf8) + ) + let notFound = try AidenRemoteJSONDecoder.decode( + AidenRemoteErrorEnvelope.self, + from: Data(#"{"error":{"code":"not_found","message":"Missing","requestId":"req-2","retryable":false}}"#.utf8) + ) + let transient = try AidenRemoteJSONDecoder.decode( + AidenRemoteErrorEnvelope.self, + from: Data(#"{"error":{"code":"internal_error","message":"Retry","requestId":"req-3","retryable":true}}"#.utf8) + ) + + XCTAssertTrue(AidenTerminalReconciliation.isDefinitiveMissingStream( + AidenRemoteClientError.server(statusCode: 404, body: gone.error) + )) + XCTAssertTrue(AidenTerminalReconciliation.isDefinitiveMissingStream( + AidenRemoteClientError.server(statusCode: 404, body: notFound.error) + )) + XCTAssertFalse(AidenTerminalReconciliation.isDefinitiveMissingStream( + AidenRemoteClientError.server(statusCode: 503, body: transient.error) + )) + XCTAssertFalse(AidenTerminalReconciliation.isDefinitiveMissingStream( + AidenRemoteClientError.unexpectedStatus(404) + )) + } + + func testMissingStreamResolutionNeverReusesAnEarlierTurnOutcome() { + let earlierFailed = AidenChatMessage( + id: "assistant-old", + role: .assistant, + text: "", + outcome: AidenMessageOutcome( + status: .failed, + category: "network", + attempts: 1, + retryExhausted: false + ), + createdAt: Date(timeIntervalSince1970: 1) + ) + let earlierComplete = AidenChatMessage( + id: "assistant-complete", + role: .assistant, + text: "Done", + createdAt: Date(timeIntervalSince1970: 2) + ) + let currentUser = AidenChatMessage( + id: "user-current", + role: .user, + text: "Continue", + createdAt: Date(timeIntervalSince1970: 3) + ) + + XCTAssertEqual( + AidenMissingStreamResolution.resolve(messages: [earlierFailed, currentUser]), + .interrupted + ) + XCTAssertEqual( + AidenMissingStreamResolution.resolve(messages: [earlierComplete, currentUser]), + .interrupted + ) + XCTAssertEqual( + AidenMissingStreamResolution.resolve(messages: [currentUser, earlierComplete]), + .complete + ) + } + + func testFullscreenAttachmentGalleryOnlyKeepsTheSelectedPageAndNeighborsActive() { + XCTAssertTrue(AidenAttachmentGalleryWindow.contains(index: 0, selectedIndex: 0, count: 20)) + XCTAssertTrue(AidenAttachmentGalleryWindow.contains(index: 9, selectedIndex: 10, count: 20)) + XCTAssertTrue(AidenAttachmentGalleryWindow.contains(index: 10, selectedIndex: 10, count: 20)) + XCTAssertTrue(AidenAttachmentGalleryWindow.contains(index: 11, selectedIndex: 10, count: 20)) + XCTAssertFalse(AidenAttachmentGalleryWindow.contains(index: 8, selectedIndex: 10, count: 20)) + XCTAssertFalse(AidenAttachmentGalleryWindow.contains(index: 19, selectedIndex: 10, count: 20)) + XCTAssertFalse(AidenAttachmentGalleryWindow.contains(index: -1, selectedIndex: 0, count: 20)) + XCTAssertFalse(AidenAttachmentGalleryWindow.contains(index: 0, selectedIndex: 0, count: 0)) + } + + func testInlineCardDeckPagesWithBoundedVisibleNeighborsAndFlicks() { + XCTAssertEqual(AidenInlineCardDeckLayout.viewportAspectRatio, 1) + XCTAssertEqual(AidenInlineCardDeckLayout.singleImageCornerRadius, 16) + XCTAssertEqual(AidenInlineCardDeckLayout.cardCornerRadius, 18) + XCTAssertTrue(AidenInlineCardDeckLayout.isVisible(index: 0, selection: 0, count: 5)) + XCTAssertTrue(AidenInlineCardDeckLayout.isVisible(index: 1, selection: 0, count: 5)) + XCTAssertFalse(AidenInlineCardDeckLayout.isVisible(index: 2, selection: 0, count: 5)) + XCTAssertFalse(AidenInlineCardDeckLayout.isVisible(index: 3, selection: 0, count: 5)) + XCTAssertEqual( + AidenInlineCardDeckLayout.resistedTranslation( + current: 0, + count: 5, + translation: 100 + ), + 22, + accuracy: 0.001 + ) + XCTAssertEqual( + AidenInlineCardDeckLayout.resistedTranslation( + current: 1, + count: 5, + translation: -100 + ), + -100, + accuracy: 0.001 + ) + XCTAssertEqual( + AidenInlineCardDeckLayout.dragProgress(translation: -80, width: 320), + 0.25, + accuracy: 0.001 + ) + XCTAssertEqual( + AidenInlineCardDeckLayout.selectedCardOffset(translation: -80), + -70.4, + accuracy: 0.001 + ) + XCTAssertEqual( + AidenInlineCardDeckLayout.preferredBackgroundIndex( + selection: 2, + count: 5, + translation: -40 + ), + 3 + ) + XCTAssertEqual( + AidenInlineCardDeckLayout.preferredBackgroundIndex( + selection: 2, + count: 5, + translation: 40 + ), + 1 + ) + XCTAssertEqual( + AidenInlineCardDeckLayout.preferredBackgroundIndex( + selection: 0, + count: 5, + translation: 40 + ), + 1 + ) + XCTAssertEqual( + AidenInlineCardDeckLayout.resolvedSelection( + current: 1, + count: 5, + translation: -20, + predictedTranslation: -120 + ), + 2 + ) + XCTAssertEqual( + AidenInlineCardDeckLayout.resolvedSelection( + current: 1, + count: 5, + translation: 20, + predictedTranslation: 30 + ), + 1 + ) + XCTAssertEqual( + AidenInlineCardDeckLayout.resolvedSelection( + current: 0, + count: 5, + translation: 120, + predictedTranslation: 160 + ), + 0 + ) + } + + func testInlineCardDeckAnchorsToTheMessageSenderEdge() { + XCTAssertEqual(AidenMessageMediaEdge.forRole(.user), .trailing) + XCTAssertEqual(AidenMessageMediaEdge.forRole(.assistant), .leading) + XCTAssertLessThan(AidenMessageMediaEdge.trailing.backgroundRotationDegrees, 0) + XCTAssertGreaterThan(AidenMessageMediaEdge.leading.backgroundRotationDegrees, 0) + } + + func testUserImageAttachmentsSitOutsideTheTextBubble() { + XCTAssertTrue(AidenMessageContentSurface.usesRaisedBubble(role: .user, content: .text)) + XCTAssertTrue(AidenMessageContentSurface.usesRaisedBubble( + role: .user, + content: .fallbackAttachment + )) + XCTAssertFalse(AidenMessageContentSurface.usesRaisedBubble( + role: .user, + content: .imageAttachment + )) + XCTAssertFalse(AidenMessageContentSurface.usesRaisedBubble( + role: .assistant, + content: .text + )) + } + + func testAttachmentThumbnailCacheSeparatesContentAndRequestedResolution() { + let imageA = Data("image-a".utf8) + let imageB = Data("image-b".utf8) + let key = AidenAttachmentThumbnailCacheKey.make(data: imageA, maximumPixelSize: 960) + + XCTAssertEqual( + key, + AidenAttachmentThumbnailCacheKey.make(data: imageA, maximumPixelSize: 960) + ) + XCTAssertNotEqual( + key, + AidenAttachmentThumbnailCacheKey.make(data: imageB, maximumPixelSize: 960) + ) + XCTAssertNotEqual( + key, + AidenAttachmentThumbnailCacheKey.make(data: imageA, maximumPixelSize: 2_560) + ) + } + + func testPhotoLibraryUsageDescriptionIsExplicitAndSaveOnly() throws { + let value = try XCTUnwrap( + Bundle.main.object(forInfoDictionaryKey: "NSPhotoLibraryAddUsageDescription") as? String + ) + XCTAssertTrue(value.contains("only when you choose")) + XCTAssertTrue(value.contains("Save Image")) + } + + func testAttachmentModelsRoundTripMetadataWithoutInlineContents() throws { + let json = """ + {"id":"message-1","role":"user","text":"", + "attachments":[{"id":"att_\(String(repeating: "A", count: 43))","name":"notes.md", + "mimeType":"text/markdown","kind":"text","size":7}], + "createdAt":"2026-08-19T07:00:00.000Z"} + """ + let message = try AidenRemoteJSONDecoder.decode(AidenChatMessage.self, from: Data(json.utf8)) + XCTAssertEqual(message.attachments?.first?.name, "notes.md") + let encoded = try JSONEncoder().encode(message) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + let attachment = try XCTUnwrap((object["attachments"] as? [[String: Any]])?.first) + XCTAssertNil(attachment["text"]) + XCTAssertNil(attachment["data"]) + XCTAssertNil(attachment["path"]) + } + + func testAttachmentReferenceValidationFailsClosed() { + let valid = AidenAttachmentReference( + id: "att_\(String(repeating: "A", count: 43))", + name: "notes.md", + mimeType: "text/markdown", + kind: .text, + size: 7, + expiresAt: Date().addingTimeInterval(60) + ) + XCTAssertTrue(valid.isValid()) + XCTAssertFalse(AidenAttachmentReference( + id: valid.id, + name: "../notes.md", + mimeType: valid.mimeType, + kind: valid.kind, + size: valid.size, + expiresAt: valid.expiresAt + ).isValid()) + XCTAssertFalse(AidenAttachmentReference( + id: "attachment-1", + name: valid.name, + mimeType: "application/octet-stream", + kind: valid.kind, + size: valid.size, + expiresAt: valid.expiresAt + ).isValid()) + XCTAssertFalse(AidenAttachmentReference( + id: valid.id, + name: valid.name, + mimeType: valid.mimeType, + kind: valid.kind, + size: valid.size, + expiresAt: Date().addingTimeInterval(-1) + ).isValid()) + } + + func testTextAttachmentPreparationIsBoundedUTF8AndAllowlisted() throws { + let upload = try AidenAttachmentPreparation.textUpload( + data: Data("let value = 1".utf8), + name: "Example.swift", + mimeType: "application/octet-stream" + ) + XCTAssertEqual( + upload, + .text(name: "Example.swift", mimeType: "text/plain", text: "let value = 1") + ) + XCTAssertThrowsError( + try AidenAttachmentPreparation.textUpload( + data: Data([0xC3, 0x28]), + name: "bad.txt", + mimeType: "text/plain" + ) + ) { XCTAssertEqual($0 as? AidenAttachmentPreparationError, .invalidText) } + XCTAssertThrowsError( + try AidenAttachmentPreparation.textUpload( + data: Data(repeating: 0x61, count: AidenAttachmentPreparation.maximumTextBytes + 1), + name: "large.txt", + mimeType: "text/plain" + ) + ) { XCTAssertEqual($0 as? AidenAttachmentPreparationError, .fileTooLarge) } + } + + func testImageAttachmentPreparationPreservesValidPNGBytesAndExtension() throws { + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + let renderer = UIGraphicsImageRenderer( + size: CGSize(width: 4_096, height: 2_048), + format: format + ) + let source = renderer.pngData { context in + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 4_096, height: 2_048)) + } + let upload = try AidenAttachmentPreparation.imageUpload(data: source, name: "camera.heic") + guard case .image(let name, let mimeType, let data) = upload else { + return XCTFail("Expected an image upload") + } + XCTAssertEqual(name, "camera.png") + XCTAssertEqual(mimeType, "image/png") + XCTAssertEqual(data, source) + XCTAssertLessThanOrEqual(data.count, AidenAttachmentPreparation.maximumImageBytes) + } + + func testImageAttachmentPreparationDoesNotFlattenTransparentPNG() throws { + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + format.opaque = false + let source = UIGraphicsImageRenderer(size: CGSize(width: 32, height: 32), format: format).pngData { context in + UIColor.clear.setFill() + context.fill(CGRect(x: 0, y: 0, width: 32, height: 32)) + UIColor.systemPink.withAlphaComponent(0.5).setFill() + context.fill(CGRect(x: 8, y: 8, width: 16, height: 16)) + } + let upload = try AidenAttachmentPreparation.imageUpload(data: source, name: "diagram.png") + guard case .image(let name, let mimeType, let data) = upload else { + return XCTFail("Expected an image upload") + } + XCTAssertEqual(name, "diagram.png") + XCTAssertEqual(mimeType, "image/png") + XCTAssertEqual(data, source) + } + + func testImageAttachmentValidationRejectsTruncatedPixelData() throws { + let source = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64)).pngData { context in + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 64, height: 64)) + } + let truncated = Data(source.prefix(source.count / 2)) + XCTAssertNil(AidenAttachmentImageValidation.validatedData( + truncated, + mimeType: "image/png", + declaredSize: truncated.count + )) + } + + func testTextFilePreparationReadsABoundedPrefixAndMarksTruncation() throws { + let url = FileManager.default.temporaryDirectory.appending(path: "aiden-attachment-\(UUID().uuidString).txt") + defer { try? FileManager.default.removeItem(at: url) } + try Data(repeating: 0x61, count: AidenAttachmentPreparation.maximumTextBytes + 100).write(to: url) + let upload = try AidenAttachmentPreparation.fileUpload(url: url) + guard case .text(_, let mimeType, let text) = upload else { + return XCTFail("Expected a text upload") + } + XCTAssertEqual(mimeType, "text/plain") + XCTAssertTrue(text.hasSuffix("… [truncated]")) + XCTAssertLessThanOrEqual(text.unicodeScalars.count, AidenAttachmentPreparation.maximumTextScalars) + XCTAssertLessThanOrEqual(Data(text.utf8).count, AidenAttachmentPreparation.maximumTextBytes) + } + + func testPhotoTransferPreservesOriginalNameWithoutDependingOnTemporaryExtension() throws { + let url = FileManager.default.temporaryDirectory + .appending(path: "aiden-extensionless-photo-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: url) } + let source = UIGraphicsImageRenderer(size: CGSize(width: 20, height: 20)).pngData { context in + UIColor.systemPurple.setFill() + context.fill(CGRect(x: 0, y: 0, width: 20, height: 20)) + } + try source.write(to: url) + + let upload = try AidenAttachmentPreparation.fileUpload( + url: url, + preferredName: "Summer Photo.PNG", + forceImage: true + ) + guard case .image(let name, let mimeType, _) = upload else { + return XCTFail("Expected an image upload") + } + XCTAssertEqual(name, "Summer Photo.png") + XCTAssertEqual(mimeType, "image/png") + } + + func testTurnAttemptTrackerReusesOnlyTheExactAmbiguousRequestKey() { + var tracker = AidenTurnAttemptTracker() + let request = AidenTurnStart(text: "Hello", attachmentIds: ["att_\(String(repeating: "A", count: 43))"]) + let first = tracker.key(for: request) + XCTAssertEqual(tracker.key(for: request), first) + XCTAssertNotEqual(tracker.key(for: AidenTurnStart(text: "Edited")), first) + tracker.reset() + XCTAssertNotEqual(tracker.key(for: request), first) + } + + func testTurnRequestBuilderPreservesUploadedAttachmentReferences() { + let firstID = "att_\(String(repeating: "A", count: 43))" + let secondID = "att_\(String(repeating: "B", count: 43))" + let attachments = [ + AidenAttachmentReference( + id: firstID, + name: "photo.jpg", + mimeType: "image/jpeg", + kind: .image, + size: 128, + expiresAt: Date(timeIntervalSince1970: 2_000_000_000) + ), + AidenAttachmentReference( + id: secondID, + name: "notes.md", + mimeType: "text/markdown", + kind: .text, + size: 64, + expiresAt: Date(timeIntervalSince1970: 2_000_000_000) + ), + ] + + let request = AidenTurnRequestBuilder.make( + text: "Review these", + providerId: "provider", + modelId: "model", + thinkingLevel: "high", + attachments: attachments + ) + + XCTAssertEqual(request.attachmentIds, [firstID, secondID]) + XCTAssertNil(AidenTurnRequestBuilder.make( + text: "No files", + providerId: nil, + modelId: nil, + thinkingLevel: nil, + attachments: [] + ).attachmentIds) + } + + private func eventJSON(sequence: Int, type: String, payload: String) -> String { + """ + {"protocolVersion":1,"streamId":"stream-1","sequence":\(sequence), + "timestamp":"2026-08-19T07:00:00.000Z","type":"\(type)","terminal":false,"payload":\(payload)} + """ + } + + private func sampleChat() -> AidenChat { + AidenChat( + id: "chat-1", + workspaceId: "workspace-1", + title: "Aiden chat", + providerId: "openai", + modelId: "gpt-5.6", + messages: [ + AidenChatMessage( + id: "message-1", + role: .user, + text: "Hello", + createdAt: Date(timeIntervalSince1970: 1_787_100_000) + ), + ], + createdAt: Date(timeIntervalSince1970: 1_787_100_000), + updatedAt: Date(timeIntervalSince1970: 1_787_100_001), + revision: "revision-1" + ) + } +} + +final class AidenHapticTests: XCTestCase { + @MainActor + private final class RecordingEmitter: AidenHapticEmitting { + private(set) var events: [AidenHapticEvent] = [] + + func activate(scope: UUID) {} + func deactivate(scope: UUID) {} + + func emit(_ event: AidenHapticEvent, scope: UUID?, dedupeKey: String?) { + events.append(event) + } + } + + @MainActor + func testPreferenceDefaultsOnAndPersistsDeviceLocally() throws { + let suiteName = "AidenHapticTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let initial = AidenHapticCenter( + defaults: defaults, + isApplicationActive: { true }, + isAudioCaptureActive: { false }, + supportsHaptics: true + ) + XCTAssertTrue(initial.isEnabled) + initial.isEnabled = false + + let restored = AidenHapticCenter( + defaults: defaults, + isApplicationActive: { true }, + isAudioCaptureActive: { false }, + supportsHaptics: true + ) + XCTAssertFalse(restored.isEnabled) + } + + @MainActor + func testDeliveryRequiresHardwareForegroundPreferenceAudioSilenceAndActiveScope() throws { + let suiteName = "AidenHapticGateTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + var isActive = false + var isCapturing = false + let center = AidenHapticCenter( + defaults: defaults, + isApplicationActive: { isActive }, + isAudioCaptureActive: { isCapturing }, + supportsHaptics: true + ) + let scope = UUID() + + center.play(.success, scope: scope) + XCTAssertEqual(center.pulse.sequence, 0) + isActive = true + center.play(.success, scope: scope) + XCTAssertEqual(center.pulse.sequence, 0) + center.activate(scope: scope) + isCapturing = true + center.play(.success, scope: scope) + XCTAssertEqual(center.pulse.sequence, 0) + isCapturing = false + center.isEnabled = false + center.play(.success, scope: scope) + XCTAssertEqual(center.pulse.sequence, 0) + center.isEnabled = true + center.play(.success, scope: scope) + XCTAssertEqual(center.pulse.sequence, 1) + center.deactivate(scope: scope) + center.play(.error, scope: scope) + XCTAssertEqual(center.pulse.sequence, 1) + } + + @MainActor + func testUnsupportedHardwareNeverAdvancesPulse() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: "AidenHapticUnsupportedTests.\(UUID().uuidString)")) + let center = AidenHapticCenter( + defaults: defaults, + isApplicationActive: { true }, + isAudioCaptureActive: { false }, + supportsHaptics: false + ) + center.play(.success) + XCTAssertEqual(center.pulse.sequence, 0) + } + + @MainActor + func testDedupeIsConsumedBeforeDeliveryGatesAndIncludesSemanticEvent() throws { + let suiteName = "AidenHapticDedupeTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + var isActive = false + let center = AidenHapticCenter( + defaults: defaults, + isApplicationActive: { isActive }, + isAudioCaptureActive: { false }, + supportsHaptics: true + ) + + center.play(.warning, dedupeKey: "operation-1") + isActive = true + center.play(.warning, dedupeKey: "operation-1") + XCTAssertEqual(center.pulse.sequence, 0, "A background observation must never replay later") + center.play(.success, dedupeKey: "operation-1") + XCTAssertEqual(center.pulse.sequence, 1, "A different semantic outcome may share a caller key") + center.play(.success, dedupeKey: "operation-1") + XCTAssertEqual(center.pulse.sequence, 1) + } + + @MainActor + func testProtocolConveniencePlayDelegatesOnceWithoutRecursion() { + let emitter = RecordingEmitter() + emitter.play(.warning, dedupeKey: "approval-1") + XCTAssertEqual(emitter.events, [.warning]) + } + + @MainActor + func testDeliveryTimeGateRechecksForegroundAudioCaptureAndOriginatingScope() throws { + let suiteName = "AidenHapticDeliveryRaceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + var isActive = true + var isCapturing = false + let center = AidenHapticCenter( + defaults: defaults, + isApplicationActive: { isActive }, + isAudioCaptureActive: { isCapturing }, + supportsHaptics: true + ) + let scope = UUID() + + center.activate(scope: scope) + center.play(.success, scope: scope) + XCTAssertEqual(center.pulse.scope, scope) + XCTAssertTrue(center.shouldDeliverNow(scope: center.pulse.scope)) + center.deactivate(scope: scope) + XCTAssertFalse( + center.shouldDeliverNow(scope: center.pulse.scope), + "A queued pulse must not survive its view being dismissed in the same render batch" + ) + center.activate(scope: scope) + isActive = false + XCTAssertFalse(center.shouldDeliverNow(scope: center.pulse.scope)) + isActive = true + isCapturing = true + XCTAssertFalse(center.shouldDeliverNow(scope: center.pulse.scope)) + } + + func testCancellationRecognitionIncludesURLSessionCancellation() { + XCTAssertTrue(aidenIsCancellation(CancellationError())) + XCTAssertTrue(aidenIsCancellation(URLError(.cancelled))) + XCTAssertFalse(aidenIsCancellation(URLError(.timedOut))) + } + + func testOnlyLocallyStartedStreamsMayAnnounceFeedback() { + XCTAssertTrue(AidenStreamFeedbackPolicy.localTurn.allowsFeedback) + XCTAssertFalse(AidenStreamFeedbackPolicy.restoredStream.allowsFeedback) + XCTAssertTrue(AidenStreamFeedbackDecision.announcesApproval(.localTurn)) + XCTAssertFalse(AidenStreamFeedbackDecision.announcesApproval(.restoredStream)) + XCTAssertEqual( + AidenStreamFeedbackDecision.terminalEvent(for: .failed, policy: .localTurn), + .error + ) + XCTAssertEqual( + AidenStreamFeedbackDecision.terminalEvent(for: .interrupted, policy: .localTurn), + .error + ) + XCTAssertNil(AidenStreamFeedbackDecision.terminalEvent(for: .failed, policy: .restoredStream)) + XCTAssertNil(AidenStreamFeedbackDecision.terminalEvent(for: .cancelled, policy: .localTurn)) + XCTAssertNil(AidenStreamFeedbackDecision.terminalEvent(for: .complete, policy: .localTurn)) + } + + @MainActor + func testLocalStreamFeedbackIsExactlyOnceWhileRestoredAndDismissedFlowsStaySilent() throws { + let suiteName = "AidenHapticStreamRaceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let center = AidenHapticCenter( + defaults: defaults, + isApplicationActive: { true }, + isAudioCaptureActive: { false }, + supportsHaptics: true + ) + let scope = UUID() + center.activate(scope: scope) + + if AidenStreamFeedbackDecision.announcesApproval(.localTurn) { + center.play(.warning, scope: scope, dedupeKey: "approval:approval-1") + } + if AidenStreamFeedbackDecision.announcesApproval(.restoredStream) { + center.play(.warning, scope: scope, dedupeKey: "approval:approval-1") + } + XCTAssertEqual(center.pulse.sequence, 1) + + if let event = AidenStreamFeedbackDecision.terminalEvent(for: .failed, policy: .localTurn) { + center.play(event, scope: scope, dedupeKey: "terminal:stream-1") + center.play(event, scope: scope, dedupeKey: "terminal:stream-1") + } + XCTAssertEqual(center.pulse.sequence, 2, "Response and SSE convergence must announce one terminal outcome") + + if let event = AidenStreamFeedbackDecision.terminalEvent(for: .failed, policy: .restoredStream) { + center.play(event, scope: scope, dedupeKey: "terminal:restored-stream") + } + center.play(.actionStopped, scope: scope, dedupeKey: "turn-stop:stream-1") + center.play(.actionStopped, scope: scope, dedupeKey: "turn-stop:stream-1") + XCTAssertEqual(center.pulse.sequence, 3, "Stop response and SSE convergence must announce once") + + center.play(.success, scope: scope, dedupeKey: "pairing:pair-1") + center.deactivate(scope: scope) + XCTAssertFalse(center.shouldDeliverNow(scope: center.pulse.scope), "Dismissed pairing must not vibrate") + } + + func testMutationOutcomesSeparateDefinitiveFailureFromSilentNonOutcomes() { + let success = AidenRemoteMutationOutcome.success("workspace-1") + let failure = AidenRemoteMutationOutcome.failure + let cancelled = AidenRemoteMutationOutcome.cancelled + let stale = AidenRemoteMutationOutcome.stale + let busy = AidenRemoteMutationOutcome.busy + + XCTAssertEqual(success.value, "workspace-1") + XCTAssertFalse(success.isDefinitiveFailure) + XCTAssertTrue(failure.isDefinitiveFailure) + XCTAssertFalse(cancelled.isDefinitiveFailure) + XCTAssertFalse(stale.isDefinitiveFailure) + XCTAssertFalse(busy.isDefinitiveFailure) + } +} + +final class AidenAppearanceTests: XCTestCase { + private struct Fixture: Decodable { + let version: Int + let presets: [Preset] + } + + private struct Preset: Decodable { + let id: String + let label: String + let light: Palette + let dark: Palette + } + + private struct Palette: Decodable, Equatable { + let canvas: String + let sidebar: String + let raised: String + let foreground: String + let secondary: String + let accent: String + let success: String + let warning: String + let danger: String + + init(_ value: AidenPalette) { + canvas = value.canvasHex + sidebar = value.sidebarHex + raised = value.raisedHex + foreground = value.foregroundHex + secondary = value.secondaryHex + accent = value.accentHex + success = value.successHex + warning = value.warningHex + danger = value.dangerHex + } + } + + func testSwiftPalettesExactlyMatchSharedElectronFixture() throws { + let bundle = Bundle(for: AidenAppearanceTests.self) + let url = try XCTUnwrap(bundle.url(forResource: "aiden-appearance-v1", withExtension: "json")) + let fixture = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + XCTAssertEqual(fixture.version, 1) + XCTAssertEqual(fixture.presets.map(\.id), AidenThemePresetID.allCases.map(\.rawValue)) + + for entry in fixture.presets { + let preset = try XCTUnwrap(AidenThemePresetID(rawValue: entry.id)) + XCTAssertEqual(entry.label, preset.title) + XCTAssertEqual(entry.light, Palette(AidenThemeCatalog.palette(preset: preset, scheme: .light))) + XCTAssertEqual(entry.dark, Palette(AidenThemeCatalog.palette(preset: preset, scheme: .dark))) + } + } + + @MainActor + func testAppearanceSelectionIsDeviceLocalAndPersistsAllChoices() throws { + let suiteName = "AidenAppearanceTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let initial = AidenAppearanceStore(defaults: defaults) + XCTAssertEqual(initial.mode, .system) + XCTAssertEqual(initial.lightPreset, .aiden) + XCTAssertEqual(initial.darkPreset, .aiden) + initial.mode = .dark + initial.lightPreset = .berry + initial.darkPreset = .moss + initial.lightUIFont = .rounded + initial.darkUIFont = .humanist + initial.lightCodeFont = .menlo + initial.darkCodeFont = .monaco + initial.lightContrast = 72 + initial.darkContrast = 84 + initial.lightTranslucentSidebar = false + initial.darkTranslucentSidebar = false + initial.reduceMotion = .on + initial.uiFontSize = 18 + initial.codeFontSize = 17 + initial.diffMarkers = .color + + let restored = AidenAppearanceStore(defaults: defaults) + XCTAssertEqual(restored.mode, .dark) + XCTAssertEqual(restored.lightPreset, .berry) + XCTAssertEqual(restored.darkPreset, .moss) + XCTAssertEqual(restored.lightUIFont, .rounded) + XCTAssertEqual(restored.darkUIFont, .humanist) + XCTAssertEqual(restored.lightCodeFont, .menlo) + XCTAssertEqual(restored.darkCodeFont, .monaco) + XCTAssertEqual(restored.lightContrast, 72) + XCTAssertEqual(restored.darkContrast, 84) + XCTAssertFalse(restored.lightTranslucentSidebar) + XCTAssertFalse(restored.darkTranslucentSidebar) + XCTAssertEqual(restored.reduceMotion, .on) + XCTAssertEqual(restored.uiFontSize, 18) + XCTAssertEqual(restored.codeFontSize, 17) + XCTAssertEqual(restored.diffMarkers, .color) + XCTAssertEqual(restored.palette(for: .light).accentHex, "#B42C70") + XCTAssertEqual(restored.palette(for: .dark).accentHex, "#42B596") + XCTAssertNotEqual(restored.palette(for: .light).secondaryHex, "#6E6470") + XCTAssertTrue(restored.resolvedReduceMotion(system: false)) + + restored.lightContrast = -10 + restored.darkContrast = 110 + restored.uiFontSize = 99 + restored.codeFontSize = 1 + let normalized = AidenAppearanceStore(defaults: defaults) + XCTAssertEqual(normalized.lightContrast, 0) + XCTAssertEqual(normalized.darkContrast, 100) + XCTAssertEqual(normalized.uiFontSize, 18) + XCTAssertEqual(normalized.codeFontSize, 10) + } + + func testWorkspaceSelectionSurvivesAdaptiveLayoutChangesAndReconcilesCRUD() { + let ids = ["workspace-a", "workspace-b", "workspace-c"] + var selected = AidenWorkspaceNavigation.reconciledSelection(current: nil, workspaceIDs: ids) + XCTAssertEqual(selected, "workspace-a") + + selected = "workspace-b" + XCTAssertEqual( + AidenWorkspaceNavigation.reconciledSelection(current: selected, workspaceIDs: ids), + "workspace-b", + "Compact/regular layout changes must not replace a valid selection" + ) + XCTAssertEqual( + AidenWorkspaceNavigation.reconciledSelection(current: selected, workspaceIDs: ["workspace-a", "workspace-c"]), + "workspace-a", + "Removing the selected workspace should converge on an available detail" + ) + XCTAssertNil(AidenWorkspaceNavigation.reconciledSelection(current: selected, workspaceIDs: [])) + } + + func testCompactWorkspacePathPreservesOnlyAnAvailableDestination() { + let ids = ["workspace-a", "workspace-b", "workspace-c"] + + XCTAssertEqual( + AidenWorkspaceNavigation.reconciledCompactPath( + current: ["workspace-a", "workspace-b"], + workspaceIDs: ids + ), + ["workspace-b"], + "Compact navigation should preserve the visible workspace when SwiftUI reports a deeper path" + ) + XCTAssertEqual( + AidenWorkspaceNavigation.reconciledCompactPath( + current: ["workspace-b"], + workspaceIDs: ["workspace-a", "workspace-c"] + ), + [], + "Deleting the visible workspace should pop back to the workspace list" + ) + XCTAssertEqual( + AidenWorkspaceNavigation.reconciledCompactPath(current: [], workspaceIDs: ids), + [] + ) + } + + func testCompactWorkspacePathOnlyPushesWhenTransitioningFromSplitView() { + let ids = ["workspace-a", "workspace-b"] + + XCTAssertEqual( + AidenWorkspaceNavigation.compactPath( + enteringFromSplit: true, + current: [], + selectedWorkspaceID: "workspace-b", + workspaceIDs: ids + ), + ["workspace-b"], + "An iPad size-class transition should preserve the workspace that was visible in split view" + ) + XCTAssertEqual( + AidenWorkspaceNavigation.compactPath( + enteringFromSplit: false, + current: [], + selectedWorkspaceID: "workspace-a", + workspaceIDs: ids + ), + [], + "Launching on iPhone should start at the workspace list instead of auto-pushing the first row" + ) + XCTAssertEqual( + AidenWorkspaceNavigation.compactPath( + enteringFromSplit: true, + current: ["workspace-a"], + selectedWorkspaceID: "workspace-b", + workspaceIDs: ids + ), + ["workspace-a"], + "An existing compact destination should win over stale split-view selection" + ) + XCTAssertEqual( + AidenWorkspaceNavigation.compactPath( + enteringFromSplit: true, + current: [], + selectedWorkspaceID: "workspace-missing", + workspaceIDs: ids + ), + [] + ) + } + + @MainActor + func testWorkspaceArchivesAreDeviceLocalPersistentAndInstallationScoped() throws { + let suiteName = "AidenWorkspaceArchiveTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let store = AidenWorkspaceArchiveStore(defaults: defaults) + XCTAssertFalse(store.hasAcknowledgedDeviceOnlyArchive) + XCTAssertEqual(store.archivedWorkspaceIDs(for: "mac-one"), []) + + store.acknowledgeDeviceOnlyArchive() + store.archive(workspaceID: "workspace-a", instanceID: "mac-one") + store.archive(workspaceID: "workspace-b", instanceID: "mac-one") + store.archive(workspaceID: "workspace-a", instanceID: "mac-two") + + let restored = AidenWorkspaceArchiveStore(defaults: defaults) + XCTAssertTrue(restored.hasAcknowledgedDeviceOnlyArchive) + XCTAssertEqual(restored.archivedWorkspaceIDs(for: "mac-one"), ["workspace-a", "workspace-b"]) + XCTAssertEqual(restored.archivedWorkspaceIDs(for: "mac-two"), ["workspace-a"]) + XCTAssertEqual(restored.archivedWorkspaceIDs(for: nil), []) + + restored.unarchive(workspaceID: "workspace-a", instanceID: "mac-one") + XCTAssertEqual(restored.archivedWorkspaceIDs(for: "mac-one"), ["workspace-b"]) + XCTAssertEqual(restored.archivedWorkspaceIDs(for: "mac-two"), ["workspace-a"]) + } + + @MainActor + func testWorkspaceArchivePruningOnlyDropsMissingServerRecordsForActiveInstallation() throws { + let suiteName = "AidenWorkspaceArchivePruneTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let store = AidenWorkspaceArchiveStore(defaults: defaults) + store.archive(workspaceID: "keep", instanceID: "mac-one") + store.archive(workspaceID: "removed", instanceID: "mac-one") + store.archive(workspaceID: "other-installation", instanceID: "mac-two") + + store.prune(instanceID: "mac-one", validWorkspaceIDs: ["keep", "active"]) + + XCTAssertEqual(store.archivedWorkspaceIDs(for: "mac-one"), ["keep"]) + XCTAssertEqual(store.archivedWorkspaceIDs(for: "mac-two"), ["other-installation"]) + + let restored = AidenWorkspaceArchiveStore(defaults: defaults) + XCTAssertEqual(restored.archivedWorkspaceIDs(for: "mac-one"), ["keep"]) + XCTAssertEqual(restored.archivedWorkspaceIDs(for: "mac-two"), ["other-installation"]) + } +} diff --git a/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift b/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift new file mode 100644 index 00000000..304b97c5 --- /dev/null +++ b/ios/AidenOnTheGoTests/AidenNativeIntegrationTests.swift @@ -0,0 +1,538 @@ +import ActivityKit +import Foundation +import XCTest +@testable import AidenOnTheGo + +final class AidenNativeIntegrationTests: XCTestCase { + @MainActor + func testLiveActivityLookupScopesIdenticalStreamIDsToInstallation() { + let attributes = AgentRunActivityAttributes( + instanceID: "instance-a", + sessionID: "chat-1", + sessionTitle: "Chat", + streamID: "stream-shared", + startedAt: Date() + ) + XCTAssertTrue(AidenRemoteLiveActivityManager.matches( + attributes, + instanceID: "instance-a", + streamID: "stream-shared" + )) + XCTAssertFalse(AidenRemoteLiveActivityManager.matches( + attributes, + instanceID: "instance-b", + streamID: "stream-shared" + )) + } + + func testIntentCatalogContainsOnlyBoundedDisplayNamesAndStableIDs() throws { + let suiteName = "AidenNativeIntegrationTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = AidenIntentCatalogStore(defaults: defaults) + + try store.update( + installations: [.init(id: "instance-1", name: "Studio Mac")], + activeInstallationId: "instance-1", + workspaces: [.init(id: "workspace-1", instanceId: "instance-1", name: "Aiden")], + for: "instance-1" + ) + + XCTAssertEqual(store.load(), AidenIntentCatalogSnapshot( + installations: [.init(id: "instance-1", name: "Studio Mac")], + workspaces: [.init(id: "workspace-1", instanceId: "instance-1", name: "Aiden")], + activeInstallationId: "instance-1" + )) + let data = try XCTUnwrap(defaults.data(forKey: "aiden.intent-catalog.v1")) + let serialized = try XCTUnwrap(String(data: data, encoding: .utf8)).lowercased() + for forbidden in ["https://", "credential", "token", "pin", "/users/"] { + XCTAssertFalse(serialized.contains(forbidden)) + } + } + + func testIntentCatalogDropsUnsafeAndOrphanedRecords() throws { + let suiteName = "AidenNativeIntegrationTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = AidenIntentCatalogStore(defaults: defaults) + try store.update( + installations: [ + .init(id: "instance-1", name: "Studio"), + .init(id: "instance-2", name: "Laptop"), + ], + activeInstallationId: "instance-1", + workspaces: [ + .init(id: "../../secret", instanceId: "instance-1", name: "Unsafe"), + .init(id: "workspace-2", instanceId: "missing", name: "Orphan"), + .init(id: "shared-id", instanceId: "instance-1", name: "First"), + .init(id: "shared-id", instanceId: "instance-2", name: "Second"), + ], + for: "instance-1" + ) + XCTAssertEqual(store.load().workspaces.map(\.name).sorted(), ["First", "Second"]) + XCTAssertNotEqual( + AidenWorkspaceIntentEntity(workspaceId: "shared-id", instanceId: "instance-1", name: "First").id, + AidenWorkspaceIntentEntity(workspaceId: "shared-id", instanceId: "instance-2", name: "Second").id + ) + let serialized = try XCTUnwrap(String( + data: try XCTUnwrap(defaults.data(forKey: "aiden.intent-catalog.v1")), + encoding: .utf8 + )) + XCTAssertFalse(serialized.contains("../../secret")) + XCTAssertFalse(serialized.contains("Orphan")) + } + + func testDeepLinksCarryOnlyStableIdentifiersAndRejectAmbiguousInput() throws { + let newChat = try XCTUnwrap(AidenDeepLink.newChatURL( + instanceId: "instance-1", + workspaceId: "workspace-1", + startsVoice: true + )) + XCTAssertEqual( + AidenDeepLink.request(from: newChat), + AidenNavigationRequest( + destination: .newChat, + instanceId: "instance-1", + workspaceId: "workspace-1", + startsVoice: true + ) + ) + XCTAssertFalse(newChat.absoluteString.lowercased().contains("prompt")) + XCTAssertFalse(newChat.absoluteString.lowercased().contains("token")) + XCTAssertFalse(newChat.absoluteString.contains("/Users/")) + + let chat = try XCTUnwrap(AidenDeepLink.chatURL(instanceId: "instance-1", chatId: "chat-1")) + XCTAssertEqual(AidenDeepLink.request(from: chat)?.destination, .chat("chat-1")) + XCTAssertNil(AidenDeepLink.request(from: URL(string: "aiden-otg://chat?instance=a&instance=b&chat=c")!)) + XCTAssertNil(AidenDeepLink.request(from: URL(string: "aiden-otg://chat?instance=a&chat=../../secret")!)) + XCTAssertNil(AidenDeepLink.request(from: URL(string: "aiden-otg://chat/path?instance=a&chat=c")!)) + XCTAssertNil(AidenDeepLink.request(from: URL(string: "aiden-otg://chat?instance=a&chat=c&prompt=hello")!)) + } + + @MainActor + func testLiveActivityStateIsBoundedAndResponseExcerptDefaultsOff() throws { + let longTitle = String(repeating: "Title ", count: 30) + let longText = String(repeating: "private response ", count: 30) + let initial = AgentRunActivityStateReducer.initialState( + sessionID: "chat-1", + sessionTitle: longTitle + ) + let updated = AgentRunActivityStateReducer.appendingToken(longText, to: initial) + XCTAssertLessThanOrEqual(updated.sessionTitle.count, AgentRunActivitySanitizer.maximumSessionTitleCharacters) + XCTAssertLessThanOrEqual(updated.responseExcerpt.count, AgentRunActivitySanitizer.maximumExcerptCharacters) + + let suiteName = "AidenNativeIntegrationTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let manager = AidenRemoteLiveActivityManager(defaults: defaults) + XCTAssertFalse(manager.includesResponseExcerpts) + XCTAssertEqual(initial.responseExcerpt, "") + } + + @MainActor + func testPhysicalActivityKitLifecycleUsesPrivateBoundedStateAndImmediateCleanup() async throws { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + throw XCTSkip("Live Activities are disabled on this physical device.") + } + + let proofID = "physical-proof-\(UUID().uuidString)" + let suiteName = "AidenNativeIntegrationTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let manager = AidenRemoteLiveActivityManager(defaults: defaults) + + await manager.start( + instanceID: proofID, + chatID: proofID, + title: "Aiden verification", + streamID: proofID + ) + + guard let activity = Activity.activities.first(where: { + $0.attributes.instanceID == proofID && $0.attributes.streamID == proofID + }) else { + await manager.endAll(forInstanceID: proofID) + XCTFail("ActivityKit did not create the requested Aiden Live Activity.") + return + } + + XCTAssertEqual(activity.content.state.status, .starting) + XCTAssertEqual(activity.content.state.responseExcerpt, "") + XCTAssertFalse(activity.content.state.isFinal) + + await manager.toolStarted(name: "read_file", instanceID: proofID, streamID: proofID) + await manager.appendResponse("private response text", instanceID: proofID, streamID: proofID) + await manager.markStale(instanceID: proofID, streamID: proofID) + + let staleDeadline = Date().addingTimeInterval(2) + while !activity.content.state.isStale && Date() < staleDeadline { + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTAssertTrue(activity.content.state.isStale) + XCTAssertEqual(activity.content.state.status, .responding) + XCTAssertEqual(activity.content.state.responseExcerpt, "") + + await manager.endAll(forInstanceID: proofID) + + let endDeadline = Date().addingTimeInterval(2) + while (activity.activityState == .active || activity.activityState == .stale), + Date() < endDeadline { + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTAssertTrue(activity.activityState == .ended || activity.activityState == .dismissed) + XCTAssertFalse(Activity.activities.contains(where: { $0.id == activity.id })) + } + + @MainActor + func testFreshManagerReconcilesPersistedActivityThroughAuthenticatedClient() async throws { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + throw XCTSkip("Live Activities are disabled on this physical device.") + } + + let proofID = "relaunch-proof-\(UUID().uuidString)" + let suiteName = "AidenNativeIntegrationTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { + defaults.removePersistentDomain(forName: suiteName) + AidenNativeActivityURLProtocol.handler = nil + } + + var originalManager: AidenRemoteLiveActivityManager? = AidenRemoteLiveActivityManager(defaults: defaults) + await originalManager?.start( + instanceID: proofID, + chatID: proofID, + title: "Relaunch verification", + streamID: proofID + ) + + guard let activity = Activity.activities.first(where: { + $0.attributes.instanceID == proofID && $0.attributes.streamID == proofID + }) else { + await originalManager?.endAll(forInstanceID: proofID) + XCTFail("ActivityKit did not persist the Aiden activity for adoption.") + return + } + originalManager = nil + + AidenNativeActivityURLProtocol.handler = { request in + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/aiden/v1/streams/\(proofID)") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer proof-credential") + XCTAssertEqual(request.value(forHTTPHeaderField: "Aiden-Protocol-Version"), "1") + let response = try XCTUnwrap(HTTPURLResponse( + url: try XCTUnwrap(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )) + return (response, Data(""" + {"streamId":"\(proofID)","chatId":"\(proofID)","turnId":"turn-1", + "state":"running","lastSequence":3,"updatedAt":"2026-08-19T19:00:00.000Z"} + """.utf8)) + } + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AidenNativeActivityURLProtocol.self] + let client = AidenRemoteClient( + endpoint: URL(string: "https://aiden.test/api/aiden/v1")!, + credential: "proof-credential", + session: URLSession(configuration: configuration) + ) + let adoptingManager = AidenRemoteLiveActivityManager(defaults: defaults) + + await adoptingManager.reconcile(instanceID: proofID, client: client, isCurrent: { true }) + let reconcileDeadline = Date().addingTimeInterval(2) + while activity.content.state.status != .responding && Date() < reconcileDeadline { + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTAssertEqual(activity.content.state.status, .responding) + XCTAssertFalse(activity.content.state.isStale) + XCTAssertEqual(activity.content.state.responseExcerpt, "") + + await adoptingManager.endAll(forInstanceID: proofID) + let endDeadline = Date().addingTimeInterval(2) + while (activity.activityState == .active || activity.activityState == .stale), + Date() < endDeadline { + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTAssertTrue(activity.activityState == .ended || activity.activityState == .dismissed) + XCTAssertFalse(Activity.activities.contains(where: { $0.id == activity.id })) + } + + @MainActor + func testOptInPhysicalActivityKitProcessBoundaryPhase() async throws { + let environment = ProcessInfo.processInfo.environment + let proofIDValue = environment["AIDEN_ACTIVITYKIT_PROCESS_PROOF_ID"] + let phaseValue = environment["AIDEN_ACTIVITYKIT_PROCESS_PHASE"] + + guard proofIDValue != nil || phaseValue != nil else { + XCTAssertNil(proofIDValue) + XCTAssertNil(phaseValue) + return + } + + let proofID = try XCTUnwrap(proofIDValue) + let phase = try XCTUnwrap(phaseValue) + let permittedProofCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_.")) + XCTAssertFalse(proofID.isEmpty) + XCTAssertLessThanOrEqual(proofID.utf8.count, 128) + XCTAssertNil(proofID.unicodeScalars.first(where: { !permittedProofCharacters.contains($0) })) + guard !proofID.isEmpty, + proofID.utf8.count <= 128, + proofID.unicodeScalars.allSatisfy(permittedProofCharacters.contains) + else { + return + } + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + throw XCTSkip("Live Activities are disabled on this physical device.") + } + + let suiteName = "AidenNativeIntegrationTests.ProcessBoundary.\(proofID)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + switch phase { + case "start": + let manager = AidenRemoteLiveActivityManager(defaults: defaults) + await manager.endAll(forInstanceID: proofID) + await manager.start( + instanceID: proofID, + chatID: proofID, + title: "Process relaunch verification", + streamID: proofID + ) + let activity = try XCTUnwrap(Activity.activities.first(where: { + $0.attributes.instanceID == proofID && $0.attributes.streamID == proofID + })) + XCTAssertTrue(activity.activityState == .active || activity.activityState == .stale) + XCTAssertEqual(activity.content.state.status, .starting) + XCTAssertEqual(activity.content.state.responseExcerpt, "") + print("AIDEN_ACTIVITYKIT_PROCESS checkpoint=started proof=\(proofID)") + + case "reconcile": + guard let activity = Activity.activities.first(where: { + $0.attributes.instanceID == proofID && $0.attributes.streamID == proofID + }) else { + XCTFail("The system-persisted Aiden Live Activity was not available after process relaunch.") + return + } + + AidenNativeActivityURLProtocol.handler = { request in + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/aiden/v1/streams/\(proofID)") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer process-proof-credential") + XCTAssertEqual(request.value(forHTTPHeaderField: "Aiden-Protocol-Version"), "1") + let response = try XCTUnwrap(HTTPURLResponse( + url: try XCTUnwrap(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )) + return (response, Data(""" + {"streamId":"\(proofID)","chatId":"\(proofID)","turnId":"turn-process-proof", + "state":"running","lastSequence":4,"updatedAt":"2026-08-19T19:00:00.000Z"} + """.utf8)) + } + defer { AidenNativeActivityURLProtocol.handler = nil } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AidenNativeActivityURLProtocol.self] + let client = AidenRemoteClient( + endpoint: URL(string: "https://aiden.test/api/aiden/v1")!, + credential: "process-proof-credential", + session: URLSession(configuration: configuration) + ) + let manager = AidenRemoteLiveActivityManager(defaults: defaults) + await manager.reconcile(instanceID: proofID, client: client, isCurrent: { true }) + + let reconcileDeadline = Date().addingTimeInterval(2) + while activity.content.state.status != .responding && Date() < reconcileDeadline { + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTAssertEqual(activity.content.state.status, .responding) + XCTAssertFalse(activity.content.state.isStale) + XCTAssertEqual(activity.content.state.responseExcerpt, "") + + await manager.endAll(forInstanceID: proofID) + let endDeadline = Date().addingTimeInterval(2) + while (activity.activityState == .active || activity.activityState == .stale), + Date() < endDeadline { + try await Task.sleep(nanoseconds: 20_000_000) + } + XCTAssertTrue(activity.activityState == .ended || activity.activityState == .dismissed) + XCTAssertFalse(Activity.activities.contains(where: { $0.id == activity.id })) + print("AIDEN_ACTIVITYKIT_PROCESS checkpoint=reconciled-and-ended proof=\(proofID)") + + case "cleanup": + let manager = AidenRemoteLiveActivityManager(defaults: defaults) + await manager.endAll(forInstanceID: proofID) + XCTAssertFalse(Activity.activities.contains(where: { + $0.attributes.instanceID == proofID + })) + print("AIDEN_ACTIVITYKIT_PROCESS checkpoint=cleanup proof=\(proofID)") + + default: + XCTFail("AIDEN_ACTIVITYKIT_PROCESS_PHASE must be start, reconcile, or cleanup.") + } + } + + func testVoiceDraftIsLocalExplicitAndRejectsInvalidAudioInput() throws { + XCTAssertEqual( + ComposerVoiceDraftComposer.composedDraft(baseDraft: "Please", transcript: "summarize locally"), + "Please summarize locally" + ) + XCTAssertFalse(ComposerVoiceInputStartPolicy.canStart(appIsActive: false)) + XCTAssertThrowsError(try ComposerVoiceInputStartPolicy.validateAudioSessionInput( + isInputAvailable: false, + sampleRate: 44_100, + inputNumberOfChannels: 1 + )) + } + + func testHostedAppDeclaresVoiceAndLiveActivityPrivacyKeys() throws { + XCTAssertNotNil(Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription")) + XCTAssertNotNil(Bundle.main.object(forInfoDictionaryKey: "NSSpeechRecognitionUsageDescription")) + XCTAssertNotNil(Bundle.main.object(forInfoDictionaryKey: "NSCameraUsageDescription")) + XCTAssertNotNil(Bundle.main.object(forInfoDictionaryKey: "NSLocalNetworkUsageDescription")) + XCTAssertEqual(Bundle.main.object(forInfoDictionaryKey: "NSSupportsLiveActivities") as? Bool, true) + XCTAssertNil(Bundle.main.object(forInfoDictionaryKey: "NSAppTransportSecurity")) + + let privacyManifestURL = try XCTUnwrap(Bundle.main.url( + forResource: "PrivacyInfo", + withExtension: "xcprivacy" + )) + let privacyManifest = try XCTUnwrap( + PropertyListSerialization.propertyList( + from: try Data(contentsOf: privacyManifestURL), + format: nil + ) as? [String: Any] + ) + XCTAssertEqual(privacyManifest["NSPrivacyTracking"] as? Bool, false) + XCTAssertTrue((privacyManifest["NSPrivacyCollectedDataTypes"] as? [Any])?.isEmpty == true) + + let noticeURL = try XCTUnwrap(Bundle.main.url( + forResource: "NOTICE", + withExtension: "txt", + subdirectory: "ThirdPartyNotices" + )) + let notice = try String(contentsOf: noticeURL, encoding: .utf8) + XCTAssertTrue(notice.contains("Hermex (adapted SwiftUI interaction and implementation foundation)")) + XCTAssertTrue(notice.contains("KeychainAccess 4.2.2")) + XCTAssertTrue(notice.contains("MarkdownUI 2.4.1")) + XCTAssertTrue(notice.contains("NetworkImage 6.0.1")) + XCTAssertTrue(notice.contains("swift-cmark 0.8.0")) + XCTAssertFalse(notice.contains("swift-eventsource")) + + for licenseName in ["MarkdownUI-LICENSE", "NetworkImage-LICENSE", "swift-cmark-COPYING"] { + let licenseURL = try XCTUnwrap(Bundle.main.url( + forResource: licenseName, + withExtension: "txt", + subdirectory: "ThirdPartyNotices" + )) + XCTAssertFalse(try String(contentsOf: licenseURL, encoding: .utf8).isEmpty) + } + + let hermexLicenseURL = try XCTUnwrap(Bundle.main.url( + forResource: "Hermex-LICENSE", + withExtension: "txt", + subdirectory: "ThirdPartyNotices" + )) + let hermexLicense = try String(contentsOf: hermexLicenseURL, encoding: .utf8) + XCTAssertTrue(hermexLicense.contains("MIT License")) + XCTAssertTrue(hermexLicense.contains("Copyright (c) 2026 Uzair Ansar")) + } + + func testPublicPolicyAndSupportLinksUseCanonicalHTTPSDestinations() { + XCTAssertEqual(AppConfig.privacyPolicyURL.absoluteString, "https://chatwithaiden.com/privacy") + XCTAssertEqual( + AppConfig.supportURL.absoluteString, + "https://chatwithaiden.com/" + ) + XCTAssertEqual(AppConfig.privacyPolicyURL.scheme, "https") + XCTAssertEqual(AppConfig.supportURL.scheme, "https") + } + + func testUnpairedDeepLinkUsesGenericAidenErrorPresentation() { + XCTAssertEqual(AidenPairingAlertCopy.title, "Aiden On The Go") + XCTAssertEqual( + AidenPairingAlertCopy.fallbackMessage, + "Try again from Aiden Agent Remote Access settings." + ) + } + + func testPairingMethodsMirrorEveryMacConnectionChoice() { + XCTAssertEqual( + AidenPairingMethod.primary, + [.scanQRCode, .nearbyMac, .privateAddress] + ) + XCTAssertEqual(AidenPairingMethod.advanced, [.pastePayload]) + XCTAssertEqual( + Set(AidenPairingMethod.allCases), + [.scanQRCode, .nearbyMac, .privateAddress, .pastePayload] + ) + XCTAssertEqual(AidenPairingMethod.scanQRCode.badge, "Recommended") + XCTAssertEqual(AidenPairingMethod.nearbyMac.badge, "Local Network") + XCTAssertEqual(AidenPairingMethod.privateAddress.badge, "Tailscale") + XCTAssertNil(AidenPairingMethod.pastePayload.badge) + XCTAssertEqual( + AidenPairingMethod.primary.map(\.tabTitle), + ["QR", "Nearby", "Tailscale"] + ) + XCTAssertTrue(AidenPairingMethod.nearbyMac.detail.contains("local Wi-Fi")) + XCTAssertTrue(AidenPairingMethod.privateAddress.detail.contains("Tailscale")) + } + + func testMobileOnboardingMirrorsMacCapabilityGroups() { + XCTAssertEqual(AidenMobileOnboardingPhase.allCases, [.build, .extend, .control]) + XCTAssertEqual( + AidenMobileOnboardingPhase.allCases.map(\.imageName), + ["OnboardingBuild", "OnboardingExtend", "OnboardingControl"] + ) + XCTAssertEqual(AidenMobileOnboardingPhase.build.eyebrow, "BUILD IN YOUR WORKSPACE") + XCTAssertEqual(AidenMobileOnboardingPhase.extend.eyebrow, "CHOOSE AND EXTEND") + XCTAssertEqual( + AidenMobileOnboardingPhase.control.eyebrow, + "AUTOMATE AND STAY IN CONTROL" + ) + XCTAssertTrue(AidenMobileOnboardingPhase.build.detail.contains("Git")) + XCTAssertTrue(AidenMobileOnboardingPhase.extend.detail.contains("MCP")) + XCTAssertTrue(AidenMobileOnboardingPhase.control.detail.contains("scheduled")) + } + + func testMobileOnboardingUsesAvailableWindowSizeAndReadableMaximums() { + XCTAssertEqual(AidenMobileOnboardingLayout.contentWidth(for: 390), 390) + XCTAssertEqual(AidenMobileOnboardingLayout.contentWidth(for: 834), 620) + XCTAssertEqual(AidenMobileOnboardingLayout.contentWidth(for: 320), 320) + XCTAssertEqual(AidenMobileOnboardingLayout.contentHeight(for: 700), 700) + XCTAssertEqual(AidenMobileOnboardingLayout.contentHeight(for: 1_194), 760) + XCTAssertLessThan( + AidenMobileOnboardingLayout.maximumActionWidth, + AidenMobileOnboardingLayout.maximumContentWidth + ) + XCTAssertEqual(AidenMobileOnboardingLayout.actionHorizontalPadding, 24) + XCTAssertEqual(AidenMobileOnboardingLayout.actionBottomPadding, 12) + } +} + +private final class AidenNativeActivityURLProtocol: URLProtocol, @unchecked Sendable { + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = Self.handler else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift b/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift new file mode 100644 index 00000000..80b0cfae --- /dev/null +++ b/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift @@ -0,0 +1,2190 @@ +import Foundation +import UIKit +import XCTest +@testable import AidenOnTheGo + +final class AidenRemoteClientTests: XCTestCase { + override func setUp() { + super.setUp() + AidenRemoteMockURLProtocol.handler = nil + } + + override func tearDown() { + AidenRemoteMockURLProtocol.handler = nil + super.tearDown() + } + + func testPairingUsesBootstrapSecretWithoutBearerAndValidatesExchange() async throws { + let now = Date(timeIntervalSince1970: 1_787_100_000) + let bootstrap = makeBootstrap(now: now) + let payload = makePairingPayload(bootstrap: bootstrap) + let session = makeSession() + AidenRemoteMockURLProtocol.handler = { request in + XCTAssertEqual(request.url?.absoluteString, "https://aiden.test/api/aiden/v1/pairing/exchange") + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.value(forHTTPHeaderField: "Aiden-Protocol-Version"), "1") + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + let body = try Self.bodyData(request) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(object["secret"] as? String, String(repeating: "B", count: 43)) + XCTAssertEqual(object["deviceName"] as? String, "Sambit's iPhone") + XCTAssertEqual(object["deviceType"] as? String, "iphone") + XCTAssertEqual(object["clientVersion"] as? String, "1.0") + XCTAssertEqual(object["acceptsDisplayName"] as? Bool, true) + return Self.response( + for: request, + status: 200, + json: """ + { + "protocolVersion": 1, + "instanceId": "instance-1", + "deviceId": "device-1", + "credential": "\(String(repeating: "C", count: 43))", + "capabilities": ["server:read", "workspace:read", "workspace:manage"], + "endpoint": "https://aiden.test/api/aiden/v1", + "serverSpkiSha256": "\(bootstrap.serverSpkiSha256)" + } + """ + ) + } + + let exchange = try await AidenRemoteClient.pair( + payload: payload, + deviceName: "Sambit's iPhone", + deviceType: .iphone, + clientVersion: "1.0", + session: session, + now: now + ) + + XCTAssertEqual(exchange.instanceId, "instance-1") + XCTAssertEqual(exchange.deviceId, "device-1") + XCTAssertEqual(exchange.capabilities, [.serverRead, .workspaceRead, .workspaceManage]) + } + + func testManualPairingDecryptsSharedNodeVectorAndBindsSelectedEndpoint() async throws { + let vectorURL = try XCTUnwrap( + Bundle(for: Self.self).url(forResource: "manual-pairing-vector", withExtension: "json") + ) + let vectorData = try Data(contentsOf: vectorURL) + let vector = try XCTUnwrap( + JSONSerialization.jsonObject(with: vectorData) as? [String: Any] + ) + let code = try XCTUnwrap(vector["code"] as? String) + _ = try XCTUnwrap(vector["payload"] as? String) + let bootstrap = try XCTUnwrap(vector["bootstrap"] as? [String: Any]) + let bootstrapData = try JSONSerialization.data(withJSONObject: bootstrap, options: [.sortedKeys]) + let endpoint = try XCTUnwrap(URL(string: "https://aiden-fixture.example.test/api/aiden/v1")) + let session = makeSession() + AidenRemoteMockURLProtocol.handler = { request in + XCTAssertEqual( + request.url?.absoluteString, + "https://aiden-fixture.example.test/api/aiden/v1/pairing/manual-bootstrap" + ) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + XCTAssertEqual(try Self.bodyData(request), Data("{}".utf8)) + let response = try XCTUnwrap(HTTPURLResponse( + url: try XCTUnwrap(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "application/json", + "Aiden-Protocol-Version": "1", + ] + )) + return (response, bootstrapData) + } + + let payload = try await AidenRemoteClient.manualPairingPayload( + code: code.lowercased(), + endpoint: endpoint, + session: session, + now: Date(timeIntervalSince1970: 1_787_331_600) + ) + XCTAssertEqual(payload.kind, AidenRemoteContractFixture.PairingPayload.kindValue) + XCTAssertEqual(payload.bootstrap.instanceId, "instance_fixture_01") + XCTAssertEqual(payload.bootstrap.endpoint, endpoint) + XCTAssertEqual(payload.bootstrap.secret, "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE") + XCTAssertEqual(payload.trust.mode, .system) + + do { + _ = try await AidenRemoteClient.manualPairingPayload( + code: "1123-4567-89AB-CDEF-GHJK", + endpoint: endpoint, + session: session, + now: Date(timeIntervalSince1970: 1_787_331_600) + ) + XCTFail("Wrong setup code unexpectedly decrypted the pairing payload.") + } catch { + XCTAssertEqual(error as? AidenManualPairingError, .decryptionFailed) + } + + let otherEndpoint = try XCTUnwrap(URL(string: "https://other-aiden.example.test/api/aiden/v1")) + AidenRemoteMockURLProtocol.handler = { request in + XCTAssertEqual( + request.url?.absoluteString, + "https://other-aiden.example.test/api/aiden/v1/pairing/manual-bootstrap" + ) + let response = try XCTUnwrap(HTTPURLResponse( + url: try XCTUnwrap(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "application/json", + "Aiden-Protocol-Version": "1", + ] + )) + return (response, bootstrapData) + } + do { + _ = try await AidenRemoteClient.manualPairingPayload( + code: code, + endpoint: otherEndpoint, + session: session, + now: Date(timeIntervalSince1970: 1_787_331_600) + ) + XCTFail("A setup envelope for another Mac endpoint was accepted.") + } catch { + XCTAssertEqual(error as? AidenManualPairingError, .endpointMismatch) + } + } + + func testManualPairingCodeRejectsAmbiguousUnicodeAndInvalidLength() throws { + XCTAssertEqual( + try AidenRemoteClient.normalizeManualPairingCode("0123-4567-89ab-cdef-ghjk"), + "0123456789ABCDEFGHJK" + ) + for invalid in [ + "0123-4567-89AB-CDEF-GHJI", + "0123-4567-89AB-CDEF-GHJK", + "ß123-4567-89AB-CDEF-GHJK", + "ff23-4567-89AB-CDEF-GHJK", + "ſ123-4567-89AB-CDEF-GHJK", + "0123-4567-89AB-CDEF", + "0123-4567-89AB-CDEF-GHJK-X", + ] { + XCTAssertThrowsError(try AidenRemoteClient.normalizeManualPairingCode(invalid)) { + XCTAssertEqual($0 as? AidenManualPairingError, .invalidCode) + } + } + } + + func testManualPairingBootstrapRequiresCanonicalUnpaddedBase64URL() throws { + let vectorURL = try XCTUnwrap( + Bundle(for: Self.self).url(forResource: "manual-pairing-vector", withExtension: "json") + ) + let vector = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(contentsOf: vectorURL)) as? [String: Any] + ) + let original = try XCTUnwrap(vector["bootstrap"] as? [String: Any]) + XCTAssertNoThrow(try AidenRemoteJSONDecoder.decodeManualPairingBootstrap( + from: JSONSerialization.data(withJSONObject: original) + )) + + var padded = original + padded["salt"] = try XCTUnwrap(original["salt"] as? String) + "==" + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decodeManualPairingBootstrap( + from: JSONSerialization.data(withJSONObject: padded) + )) + + var standardAlphabet = original + standardAlphabet["ciphertext"] = try XCTUnwrap(original["ciphertext"] as? String) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decodeManualPairingBootstrap( + from: JSONSerialization.data(withJSONObject: standardAlphabet) + )) + } + + func testManualPairingBootstrapStopsAtTheTransportByteLimit() async throws { + let endpoint = try XCTUnwrap(URL(string: "https://bounded-aiden.example.test/api/aiden/v1")) + let session = makeSession() + AidenRemoteMockURLProtocol.handler = { request in + let response = try XCTUnwrap(HTTPURLResponse( + url: try XCTUnwrap(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "application/json", + "Content-Length": "20000", + "Aiden-Protocol-Version": "1", + ] + )) + return (response, Data(repeating: 0x41, count: 20_000)) + } + do { + _ = try await AidenRemoteClient.manualPairingPayload( + code: "0123-4567-89AB-CDEF-GHJK", + endpoint: endpoint, + session: session + ) + XCTFail("An oversized unauthenticated bootstrap response was buffered.") + } catch { + XCTAssertEqual(error as? AidenRemoteContractError, .payloadTooLarge) + } + } + + func testPairingRetriesFrozenFourFieldShapeForStrictEarlyV1Server() async throws { + let now = Date(timeIntervalSince1970: 1_787_100_000) + let bootstrap = makeBootstrap(now: now) + let payload = makePairingPayload(bootstrap: bootstrap) + let session = makeSession() + var attempts = 0 + AidenRemoteMockURLProtocol.handler = { request in + attempts += 1 + let object = try XCTUnwrap( + JSONSerialization.jsonObject(with: Self.bodyData(request)) as? [String: Any] + ) + if attempts == 1 { + XCTAssertEqual(object["acceptsDisplayName"] as? Bool, true) + return Self.response( + for: request, + status: 400, + json: #"{"error":{"code":"invalid_request","message":"Pairing details are invalid.","requestId":"request-legacy","retryable":false}}"# + ) + } + XCTAssertEqual(Set(object.keys), ["secret", "deviceName", "deviceType", "clientVersion"]) + return Self.response( + for: request, + status: 200, + json: """ + { + "protocolVersion": 1, + "instanceId": "instance-1", + "deviceId": "device-1", + "credential": "\(String(repeating: "C", count: 43))", + "capabilities": ["server:read"], + "endpoint": "https://aiden.test/api/aiden/v1", + "serverSpkiSha256": "\(bootstrap.serverSpkiSha256)" + } + """ + ) + } + + let exchange = try await AidenRemoteClient.pair( + payload: payload, + deviceName: "Legacy Test Phone", + deviceType: .iphone, + clientVersion: "1.0", + session: session, + now: now + ) + XCTAssertEqual(attempts, 2) + XCTAssertNil(exchange.displayName) + } + + func testWorkspaceListUsesBearerAndStrictAidenProtocolHeader() async throws { + let client = makeClient() + AidenRemoteMockURLProtocol.handler = { request in + XCTAssertEqual(request.url?.absoluteString, "https://aiden.test/api/aiden/v1/workspaces") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer device-credential") + XCTAssertEqual(request.value(forHTTPHeaderField: "Aiden-Protocol-Version"), "1") + return Self.response( + for: request, + status: 200, + json: """ + {"workspaces":[{ + "id":"workspace-1","name":"Aiden Agent","permission":"ask", + "hasFolder":true,"isManagedWorktree":false, + "repositoryName":"aiden-agent", + "git":{"isRepo":true,"branch":"main","uncommitted":2}, + "createdAt":"2026-08-19T07:00:00.000Z", + "updatedAt":"2026-08-19T07:01:00.000Z","revision":"rev-1" + }]} + """ + ) + } + + let workspaces = try await client.workspaces() + XCTAssertEqual(workspaces.count, 1) + XCTAssertEqual(workspaces[0].permission, .ask) + XCTAssertEqual(workspaces[0].git?.uncommitted, 2) + } + + func testUsageReadsPrivacySafeMacAggregate() async throws { + let client = makeClient() + AidenRemoteMockURLProtocol.handler = { request in + XCTAssertEqual(request.url?.absoluteString, "https://aiden.test/api/aiden/v1/usage?range=30d") + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer device-credential") + return Self.response( + for: request, + status: 200, + json: """ + { + "range":"30d","startDate":"2026-07-21","endDate":"2026-08-19", + "totals":{ + "requests":12,"completedRequests":11,"failedRequests":1,"cancelledRequests":0, + "reportedTokenRequests":10,"unmeteredRequests":2,"localRequests":3, + "costedRequests":8,"unpricedHostedRequests":1,"hostedCostUsd":1.25, + "activeDays":4,"currentStreak":2,"longestStreak":3, + "tokens":{"input":100,"output":50,"cacheRead":10,"cacheWrite":2,"cacheWrite1h":1,"reasoning":8,"total":170} + }, + "days":[{ + "date":"2026-08-19","requests":4,"reportedTokenRequests":3,"unmeteredRequests":1, + "tokens":{"input":40,"output":20,"cacheRead":4,"cacheWrite":1,"cacheWrite1h":0,"reasoning":3,"total":67}, + "hostedCostUsd":0.45 + }], + "models":[{ + "providerId":"openai","providerLabel":"OpenAI","modelId":"gpt-5.6", + "modelLabel":"GPT-5.6","local":false,"requests":8,"reportedTokenRequests":7, + "unmeteredRequests":1, + "tokens":{"input":80,"output":40,"cacheRead":8,"cacheWrite":2,"cacheWrite1h":1,"reasoning":6,"total":136}, + "hostedCostUsd":1.05 + }] + } + """ + ) + } + + let usage = try await client.usage() + XCTAssertEqual(usage.range, "30d") + XCTAssertEqual(usage.totals.requests, 12) + XCTAssertEqual(usage.totals.tokens.total, 170) + XCTAssertEqual(usage.totals.hostedCostUsd, 1.25) + XCTAssertEqual(usage.days.first?.date, "2026-08-19") + XCTAssertEqual(usage.days.first?.tokens.total, 67) + XCTAssertEqual(usage.models.first?.modelLabel, "GPT-5.6") + XCTAssertEqual(usage.models.first?.requests, 8) + + let heatmap = AidenUsagePresentation.heatmapDays(for: usage) + XCTAssertEqual(heatmap.count, 30) + XCTAssertEqual(heatmap.first?.date, "2026-07-21") + XCTAssertEqual(heatmap.first?.tokens, 0) + XCTAssertEqual(heatmap.last?.date, "2026-08-19") + XCTAssertEqual(heatmap.last?.tokens, 67) + XCTAssertEqual(AidenUsagePresentation.ratio(3, of: 12), 0.25) + XCTAssertEqual(AidenUsagePresentation.ratio(1, of: 0), 0) + XCTAssertEqual( + AidenUsagePresentation.tokenCount( + 1_234_567_890_123, + locale: Locale(identifier: "en_US") + ), + "1,234,567,890,123" + ) + XCTAssertEqual( + AidenUsagePresentation.tokenCount( + Int.max, + locale: Locale(identifier: "en_US") + ), + "9,223,372,036,854,775,807" + ) + } + + func testWorkspaceCreateUpdateAndDeleteCarryMutationPreconditions() async throws { + let client = makeClient() + var step = 0 + AidenRemoteMockURLProtocol.handler = { request in + step += 1 + switch step { + case 1: + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertNotNil(request.value(forHTTPHeaderField: "Idempotency-Key")) + let object = try Self.jsonBody(request) + XCTAssertEqual(object["mode"] as? String, "folderless") + XCTAssertEqual(object["name"] as? String, "New Workspace") + return Self.workspaceResponse(for: request, status: 201, revision: "rev-1") + case 2: + XCTAssertEqual(request.httpMethod, "PATCH") + XCTAssertEqual(request.value(forHTTPHeaderField: "If-Match"), "rev-1") + let object = try Self.jsonBody(request) + XCTAssertEqual(object["permission"] as? String, "full") + XCTAssertEqual(object["confirmedForeground"] as? Bool, true) + return Self.workspaceResponse(for: request, status: 200, revision: "rev-2") + case 3: + XCTAssertEqual(request.httpMethod, "DELETE") + XCTAssertEqual(request.value(forHTTPHeaderField: "If-Match"), "rev-2") + return (HTTPURLResponse(url: request.url!, statusCode: 204, httpVersion: nil, headerFields: nil)!, Data()) + default: + XCTFail("Unexpected request") + return Self.response(for: request, status: 500, json: "{}") + } + } + + let created = try await client.createWorkspace(.folderless(name: "New Workspace")) + let updated = try await client.updateWorkspace( + id: created.id, + revision: created.revision, + patch: AidenWorkspacePatch(permission: .full) + ) + try await client.removeWorkspace(id: updated.id, revision: updated.revision) + XCTAssertEqual(step, 3) + } + + func testApprovedFolderBrowserUsesOpaqueLocationsAndSelection() async throws { + let client = makeClient() + var step = 0 + let location = "loc_\(String(repeating: "L", count: 43))" + AidenRemoteMockURLProtocol.handler = { request in + step += 1 + switch step { + case 1: + XCTAssertEqual(request.url?.path, "/api/aiden/v1/workspace-browser/children") + let components = try XCTUnwrap(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)) + XCTAssertEqual(components.queryItems, [URLQueryItem(name: "location", value: location)]) + return Self.response( + for: request, + status: 200, + json: """ + {"rootId":"root-1","label":"Projects","breadcrumbs":[], + "entries":[{"id":"entry-1","name":"aiden-agent","location":"\(location)"}]} + """ + ) + case 2: + XCTAssertEqual(request.url?.path, "/api/aiden/v1/workspace-browser/selections") + XCTAssertEqual(try Self.jsonBody(request)["location"] as? String, location) + return Self.response( + for: request, + status: 201, + json: """ + {"selection":"sel_\(String(repeating: "S", count: 43))", + "displayName":"aiden-agent","expiresAt":"2026-08-19T07:05:00.000Z"} + """ + ) + default: + XCTFail("Unexpected request") + return Self.response(for: request, status: 500, json: "{}") + } + } + + let page = try await client.browserChildren(location: location) + XCTAssertEqual(page.entries.first?.name, "aiden-agent") + let selection = try await client.createWorkspaceSelection(location: location) + XCTAssertTrue(selection.selection.hasPrefix("sel_")) + } + + func testCredentialRevocationIsTypedAndNeverEchoesCredential() async throws { + let client = makeClient() + AidenRemoteMockURLProtocol.handler = { request in + Self.response( + for: request, + status: 401, + json: """ + {"error":{"code":"credential_revoked","message":"Pair this device again.", + "requestId":"request-1","retryable":false}} + """ + ) + } + + do { + _ = try await client.workspaces() + XCTFail("Expected credential revocation") + } catch let error as AidenRemoteClientError { + XCTAssertTrue(error.isCredentialRevoked) + XCTAssertFalse(error.localizedDescription.contains("device-credential")) + } + } + + func testChatCRUDModelsTurnCancelAndApprovalUseCanonicalRoutes() async throws { + let client = makeClient() + var step = 0 + AidenRemoteMockURLProtocol.handler = { request in + step += 1 + switch step { + case 1: + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.path, "/api/aiden/v1/chats") + XCTAssertEqual(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems, + [URLQueryItem(name: "workspaceId", value: "workspace-1")]) + return Self.response(for: request, status: 200, json: "{\"chats\":[]}") + case 2: + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertNotNil(request.value(forHTTPHeaderField: "Idempotency-Key")) + XCTAssertEqual(try Self.jsonBody(request)["workspaceId"] as? String, "workspace-1") + return Self.chatResponse(for: request, status: 201, revision: "revision-1") + case 3: + XCTAssertEqual(request.httpMethod, "PATCH") + XCTAssertEqual(request.value(forHTTPHeaderField: "If-Match"), "revision-1") + XCTAssertEqual(try Self.jsonBody(request)["title"] as? String, "Renamed") + return Self.chatResponse(for: request, status: 200, revision: "revision-2", title: "Renamed") + case 4: + XCTAssertEqual(request.url?.path, "/api/aiden/v1/models") + return Self.response( + for: request, + status: 200, + json: """ + {"providers":[{"id":"openai","label":"OpenAI","models":[ + {"id":"gpt-5.6","label":"GPT-5.6","thinkingLevels":["high","max"], + "defaultThinkingLevel":"max","thinkingCanDisable":false}]}], + "defaults":{"providerId":"openai","modelId":"gpt-5.6"}} + """ + ) + case 5: + XCTAssertEqual(request.url?.path, "/api/aiden/v1/chats/chat-1/turns") + XCTAssertNotNil(request.value(forHTTPHeaderField: "Idempotency-Key")) + let body = try Self.jsonBody(request) + XCTAssertEqual(body["text"] as? String, "Work on this") + XCTAssertEqual(body["thinkingLevel"] as? String, "max") + return Self.response( + for: request, + status: 202, + json: """ + {"turnId":"turn-1","streamId":"stream-1","status":"accepted", + "message":{"id":"message-1","role":"user","text":"Work on this", + "createdAt":"2026-08-19T07:00:00.000Z"}} + """ + ) + case 6: + XCTAssertEqual(request.url?.path, "/api/aiden/v1/streams/stream-1") + return Self.response( + for: request, + status: 200, + json: """ + {"streamId":"stream-1","chatId":"chat-1","turnId":"turn-1", + "state":"waiting_for_approval","lastSequence":3, + "updatedAt":"2026-08-19T07:00:01.000Z"} + """ + ) + case 7: + XCTAssertEqual(request.url?.path, "/api/aiden/v1/streams/stream-1/approval") + return Self.response( + for: request, + status: 200, + json: """ + {"approval":{ + "approvalId":"approval-1","streamId":"stream-1","chatId":"chat-1", + "summary":"Allow this command?","toolCallId":"tool-1","toolName":"run_command", + "expiresAt":"2026-08-19T07:05:01.000Z","canAllow":true}} + """ + ) + case 8: + XCTAssertEqual(request.url?.path, "/api/aiden/v1/streams/stream-1/cancel") + XCTAssertNotNil(request.value(forHTTPHeaderField: "Idempotency-Key")) + return Self.streamStatusResponse(for: request, state: "cancelled") + case 9: + XCTAssertEqual(request.url?.path, "/api/aiden/v1/approvals/approval-1/respond") + XCTAssertEqual(try Self.jsonBody(request)["decision"] as? String, "allow") + return Self.response( + for: request, + status: 200, + json: "{\"approvalId\":\"approval-1\",\"decision\":\"allow\",\"resolvedAt\":\"2026-08-19T07:00:00.000Z\"}" + ) + case 10: + XCTAssertEqual(request.httpMethod, "DELETE") + XCTAssertEqual(request.value(forHTTPHeaderField: "If-Match"), "revision-2") + return (HTTPURLResponse(url: request.url!, statusCode: 204, httpVersion: nil, headerFields: nil)!, Data()) + default: + XCTFail("Unexpected chat request") + return Self.response(for: request, status: 500, json: "{}") + } + } + + let initialChats = try await client.chats(workspaceId: "workspace-1") + XCTAssertEqual(initialChats, []) + let created = try await client.createChat(workspaceId: "workspace-1") + let updated = try await client.updateChat(id: created.id, revision: created.revision, title: "Renamed") + let catalog = try await client.modelCatalog() + XCTAssertEqual(catalog.providers.first?.models.first?.thinkingLevels, ["high", "max"]) + XCTAssertEqual(catalog.providers.first?.models.first?.effectiveThinkingLevel, "max") + XCTAssertEqual(catalog.providers.first?.models.first?.thinkingCanDisable, false) + let turn = try await client.startTurn( + chatId: updated.id, + request: .init(text: "Work on this", providerId: "openai", modelId: "gpt-5.6", thinkingLevel: "max") + ) + let runningStatus = try await client.streamStatus(id: turn.streamId) + let pendingApproval = try await client.streamApproval(id: turn.streamId) + let cancelledStatus = try await client.cancelStream(id: turn.streamId) + let approval = try await client.respondToApproval(id: "approval-1", decision: .allow) + XCTAssertEqual(runningStatus.state, .waitingForApproval) + XCTAssertEqual(pendingApproval.approval?.approvalId, "approval-1") + XCTAssertEqual(pendingApproval.approval?.toolName, "run_command") + XCTAssertEqual(pendingApproval.approval?.canAllow, true) + XCTAssertEqual(cancelledStatus.state, .cancelled) + XCTAssertEqual(approval.decision, .allow) + try await client.removeChat(id: updated.id, revision: updated.revision) + XCTAssertEqual(step, 10) + } + + func testAttachmentUploadTurnProjectionAndRemovalUseBoundedCanonicalRoutes() async throws { + let client = makeClient() + let attachmentID = "att_\(String(repeating: "A", count: 43))" + var step = 0 + AidenRemoteMockURLProtocol.handler = { request in + step += 1 + switch step { + case 1: + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/aiden/v1/chats/chat-1/attachments") + let body = try Self.jsonBody(request) + XCTAssertEqual(body["name"] as? String, "notes.md") + XCTAssertEqual(body["mimeType"] as? String, "text/markdown") + XCTAssertEqual(body["kind"] as? String, "text") + XCTAssertEqual(body["text"] as? String, "# Notes") + XCTAssertNil(body["path"]) + return Self.response( + for: request, + status: 201, + json: """ + {"id":"\(attachmentID)","name":"notes.md","mimeType":"text/markdown", + "kind":"text","size":7,"expiresAt":"2026-08-19T07:10:00.000Z"} + """ + ) + case 2: + XCTAssertEqual(request.url?.path, "/api/aiden/v1/chats/chat-1/turns") + let body = try Self.jsonBody(request) + XCTAssertEqual(body["text"] as? String, "") + XCTAssertEqual(body["attachmentIds"] as? [String], [attachmentID]) + return Self.response( + for: request, + status: 202, + json: """ + {"turnId":"turn-1","streamId":"stream-1","status":"accepted", + "message":{"id":"message-1","role":"user","text":"", + "attachments":[{"id":"\(attachmentID)","name":"notes.md", + "mimeType":"text/markdown","kind":"text","size":7}], + "createdAt":"2026-08-19T07:00:00.000Z"}} + """ + ) + case 3: + XCTAssertEqual(request.httpMethod, "DELETE") + XCTAssertEqual(request.url?.path, "/api/aiden/v1/chats/chat-1/attachments/\(attachmentID)") + return (HTTPURLResponse(url: request.url!, statusCode: 204, httpVersion: nil, headerFields: nil)!, Data()) + default: + XCTFail("Unexpected attachment request") + return Self.response(for: request, status: 500, json: "{}") + } + } + + let reference = try await client.uploadAttachment( + chatId: "chat-1", + upload: .text(name: "notes.md", mimeType: "text/markdown", text: "# Notes") + ) + let turn = try await client.startTurn( + chatId: "chat-1", + request: .init(text: "", attachmentIds: [reference.id]) + ) + XCTAssertEqual(turn.message.attachments?.first?.id, attachmentID) + XCTAssertEqual(turn.message.attachments?.first?.size, 7) + try await client.removeAttachment(chatId: "chat-1", attachmentId: attachmentID) + XCTAssertEqual(step, 3) + } + + func testAttachmentContentUsesAuthenticatedBoundedRawImageRoute() async throws { + let client = makeClient() + let attachmentID = "att_\(String(repeating: "I", count: 43))" + let imageData = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8)).pngData { context in + UIColor.systemPurple.setFill() + context.fill(CGRect(x: 0, y: 0, width: 8, height: 8)) + } + AidenRemoteMockURLProtocol.handler = { request in + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual( + request.url?.path, + "/api/aiden/v1/chats/chat-1/attachments/\(attachmentID)/content" + ) + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer device-credential") + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "image/jpeg, image/png") + return ( + HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "image/png", "Content-Length": "\(imageData.count)"] + )!, + imageData + ) + } + + let content = try await client.attachmentContent(chatId: "chat-1", attachmentId: attachmentID) + XCTAssertEqual(content.mimeType, "image/png") + XCTAssertEqual(content.data, imageData) + } + + func testAttachmentContentRejectsNonImageResponses() async { + let client = makeClient() + AidenRemoteMockURLProtocol.handler = { request in + Self.response(for: request, status: 200, json: "{}") + } + + do { + _ = try await client.attachmentContent(chatId: "chat-1", attachmentId: "attachment-1") + XCTFail("Expected a non-image content type to fail closed.") + } catch { + XCTAssertTrue(error is AidenRemoteClientError) + } + } + + func testPhysicalDevicePairingAndWorkspaceCRUDWhenConfigured() async throws { + let environment = ProcessInfo.processInfo.environment + guard let payloadValue = environment["AIDEN_PHASE6_PAIRING_PAYLOAD"] else { + throw XCTSkip("Set the Phase 6 pairing payload in the physical-device xctestrun file.") + } + let payload = try AidenRemoteJSONDecoder.decodePairingPayload( + from: Data(payloadValue.utf8) + ).validated() + let exchange = try await AidenRemoteClient.pair( + payload: payload, + deviceName: "Physical iPhone 13 Pro", + deviceType: .iphone, + clientVersion: "1.0" + ) + let installation = AidenInstallation( + exchange: exchange, + pairingTrust: payload.trust, + name: "Physical Aiden Agent" + ) + let client = try AidenRemoteClient( + installation: installation, + credential: exchange.credential + ) + + let server = try await client.server() + XCTAssertEqual(server.instanceId, exchange.instanceId) + let expectedConnectionMode = environment["AIDEN_PHASE6_EXPECTED_CONNECTION_MODE"] + .flatMap(AidenConnectionMode.init(rawValue:)) ?? .lan + XCTAssertEqual(server.connectionMode, expectedConnectionMode) + let initialWorkspaces = try await client.workspaces() + XCTAssertEqual(initialWorkspaces, []) + + let folderless = try await client.createWorkspace(.folderless(name: "Phone Context")) + XCTAssertFalse(folderless.hasFolder) + XCTAssertEqual(folderless.permission, .ask) + let updated = try await client.updateWorkspace( + id: folderless.id, + revision: folderless.revision, + patch: AidenWorkspacePatch(name: "Renamed Context", permission: .full) + ) + XCTAssertEqual(updated.name, "Renamed Context") + XCTAssertEqual(updated.permission, .full) + try await client.removeWorkspace(id: updated.id, revision: updated.revision) + + let scratch = try await client.createWorkspace(.scratch) + XCTAssertTrue(scratch.hasFolder) + try await client.removeWorkspace(id: scratch.id, revision: scratch.revision) + + let roots = try await client.browserRoots() + let root = try XCTUnwrap(roots.first) + let page = try await client.browserChildren(location: root.location) + let approvedFolder = try XCTUnwrap(page.entries.first) + XCTAssertEqual(approvedFolder.name, "aiden-agent") + let selection = try await client.createWorkspaceSelection(location: approvedFolder.location) + let selected = try await client.createWorkspace( + .selectedFolder(selection: selection.selection, name: "Selected Project") + ) + XCTAssertTrue(selected.hasFolder) + XCTAssertEqual(selected.name, "Selected Project") + do { + _ = try await client.createWorkspace( + .selectedFolder(selection: selection.selection, name: "Replay") + ) + XCTFail("A selected-folder nonce must be single use.") + } catch let error as AidenRemoteClientError { + guard case .server(let statusCode, let body) = error else { + return XCTFail("Expected a typed server error for selection replay.") + } + XCTAssertEqual(statusCode, 409) + XCTAssertEqual(body.code.rawValue, "handle_invalid") + } + try await client.removeWorkspace(id: selected.id, revision: selected.revision) + let finalWorkspaces = try await client.workspaces() + XCTAssertEqual(finalWorkspaces, []) + } + + func testPhysicalDeviceChatStreamingWhenConfigured() async throws { + let environment = ProcessInfo.processInfo.environment + guard let payloadValue = environment["AIDEN_PHASE7_PAIRING_PAYLOAD"] else { + throw XCTSkip("Set the Phase 7 pairing payload in the physical-device xctestrun file.") + } + let payload = try AidenRemoteJSONDecoder.decodePairingPayload(from: Data(payloadValue.utf8)).validated() + let exchange = try await AidenRemoteClient.pair( + payload: payload, + deviceName: "Physical iPhone 13 Pro", + deviceType: .iphone, + clientVersion: "1.0" + ) + let client = try AidenRemoteClient( + installation: AidenInstallation( + exchange: exchange, + pairingTrust: payload.trust, + name: "Physical Aiden Agent" + ), + credential: exchange.credential + ) + + print("AIDEN_PHASE7_PHYSICAL checkpoint=workspace") + let workspace = try await client.createWorkspace(.folderless(name: "Phase 7 Chat")) + let created = try await client.createChat(workspaceId: workspace.id) + let renamed = try await client.updateChat( + id: created.id, + revision: created.revision, + title: "Physical Stream Proof" + ) + print("AIDEN_PHASE7_PHYSICAL checkpoint=models") + let catalog = try await client.modelCatalog() + XCTAssertEqual(catalog.defaults["modelId"], "gpt-5.6") + + print("AIDEN_PHASE7_PHYSICAL checkpoint=attachment-upload") + let attachment = try await client.uploadAttachment( + chatId: renamed.id, + upload: .text( + name: "physical-proof.md", + mimeType: "text/markdown", + text: "# Physical attachment" + ) + ) + XCTAssertTrue(attachment.isValid()) + let discardedAttachment = try await client.uploadAttachment( + chatId: renamed.id, + upload: .text(name: "discard.txt", mimeType: "text/plain", text: "discard") + ) + try await client.removeAttachment(chatId: renamed.id, attachmentId: discardedAttachment.id) + + let turn = try await client.startTurn( + chatId: renamed.id, + request: .init( + text: "Prove reconnect and approval", + providerId: "openai", + modelId: "gpt-5.6", + thinkingLevel: "max", + attachmentIds: [attachment.id] + ) + ) + XCTAssertEqual(turn.message.attachments?.first?.id, attachment.id) + XCTAssertEqual(turn.message.attachments?.first?.name, "physical-proof.md") + XCTAssertEqual(turn.message.attachments?.first?.mimeType, "text/markdown") + do { + _ = try await client.startTurn( + chatId: renamed.id, + request: .init(text: "Replay consumed attachment", attachmentIds: [attachment.id]) + ) + XCTFail("An attachment reference must be single use.") + } catch let error as AidenRemoteClientError { + guard case .server(let statusCode, let body) = error else { + return XCTFail("Expected a typed server error for attachment replay.") + } + XCTAssertEqual(statusCode, 409) + XCTAssertEqual(body.code.rawValue, "handle_invalid") + } + print("AIDEN_PHASE7_PHYSICAL checkpoint=first-stream") + var firstConnection: [AidenRemoteStreamEvent] = [] + for try await event in client.streamEvents(id: turn.streamId, after: 0) { + firstConnection.append(event) + } + XCTAssertEqual(firstConnection.map(\.sequence), [1, 2]) + XCTAssertEqual(firstConnection.last?.payload?.text, "Hello ") + + print("AIDEN_PHASE7_PHYSICAL checkpoint=replay-stream") + var replayConnection: [AidenRemoteStreamEvent] = [] + for try await event in client.streamEvents(id: turn.streamId, after: 2) { + replayConnection.append(event) + } + XCTAssertEqual(replayConnection.map(\.sequence), [3, 4, 5, 6, 7]) + let approval = try XCTUnwrap(replayConnection.last?.payload?.approvalId) + let waitingStatus = try await client.streamStatus(id: turn.streamId) + XCTAssertEqual(waitingStatus.state, .waitingForApproval) + _ = try await client.respondToApproval(id: approval, decision: .allow) + do { + _ = try await client.respondToApproval(id: approval, decision: .allow) + XCTFail("A resolved approval must reject duplicate decisions.") + } catch let error as AidenRemoteClientError { + guard case .server(let statusCode, let body) = error else { + return XCTFail("Expected a typed server error for duplicate approval.") + } + XCTAssertEqual(statusCode, 409) + XCTAssertEqual(body.code.rawValue, "approval_expired") + } + + print("AIDEN_PHASE7_PHYSICAL checkpoint=terminal-stream") + var terminalConnection: [AidenRemoteStreamEvent] = [] + for try await event in client.streamEvents(id: turn.streamId, after: 7) { + terminalConnection.append(event) + } + XCTAssertEqual(terminalConnection.map(\.sequence), [8, 9, 10]) + XCTAssertEqual(terminalConnection.last?.type, .done) + let authoritative = try await client.chat(id: renamed.id) + XCTAssertEqual(authoritative.messages.last?.role, .assistant) + XCTAssertEqual(authoritative.messages.last?.text, "Hello from Aiden.") + let authoritativeUserMessage = try XCTUnwrap( + authoritative.messages.first(where: { $0.role == .user }) + ) + XCTAssertEqual(authoritativeUserMessage.attachments?.first?.id, attachment.id) + XCTAssertEqual(authoritativeUserMessage.attachments?.first?.kind, .text) + + print("AIDEN_PHASE7_PHYSICAL checkpoint=cancel") + let cancelledTurn = try await client.startTurn( + chatId: authoritative.id, + request: .init(text: "Please cancel this turn") + ) + let cancelled = try await client.cancelStream(id: cancelledTurn.streamId) + XCTAssertEqual(cancelled.state, .cancelled) + var cancellationEvents: [AidenRemoteStreamEvent] = [] + for try await event in client.streamEvents(id: cancelledTurn.streamId, after: 0) { + cancellationEvents.append(event) + } + XCTAssertEqual(cancellationEvents.last?.type, .cancelled) + + let finalChat = try await client.chat(id: authoritative.id) + try await client.removeChat(id: finalChat.id, revision: finalChat.revision) + try await client.removeWorkspace(id: workspace.id, revision: workspace.revision) + let finalChats = try await client.chats(workspaceId: workspace.id) + XCTAssertEqual(finalChats, []) + } + + func testPhysicalDeviceServerRestartWhenConfigured() async throws { + let environment = ProcessInfo.processInfo.environment + guard let payloadValue = environment["AIDEN_PHASE12_RESTART_PAIRING_PAYLOAD"], + let repairPayloadValue = environment["AIDEN_PHASE12_REPAIR_PAIRING_PAYLOAD"] else { + throw XCTSkip("Set both Phase 12 pairing payloads in the physical-device xctestrun file.") + } + let payload = try AidenRemoteJSONDecoder.decodePairingPayload( + from: Data(payloadValue.utf8) + ).validated() + let repairPayload = try AidenRemoteJSONDecoder.decodePairingPayload( + from: Data(repairPayloadValue.utf8) + ).validated() + XCTAssertEqual(repairPayload.bootstrap.instanceId, payload.bootstrap.instanceId) + XCTAssertEqual(repairPayload.bootstrap.endpoint, payload.bootstrap.endpoint) + XCTAssertEqual(repairPayload.bootstrap.serverSpkiSha256, payload.bootstrap.serverSpkiSha256) + XCTAssertEqual(repairPayload.trust, payload.trust) + XCTAssertNotEqual(repairPayload.bootstrap.secret, payload.bootstrap.secret) + let exchange = try await AidenRemoteClient.pair( + payload: payload, + deviceName: "Physical iPhone 13 Pro Restart Proof", + deviceType: .iphone, + clientVersion: "1.0" + ) + let installation = AidenInstallation( + exchange: exchange, + pairingTrust: payload.trust, + name: "Physical Aiden Agent Restart Proof" + ) + let client = try AidenRemoteClient(installation: installation, credential: exchange.credential) + + let beforeRestart = try await client.server() + XCTAssertEqual(beforeRestart.instanceId, "phase7-physical-device-spike") + print("AIDEN_PHASE12_PHYSICAL checkpoint=restart-ready") + try await Task.sleep(for: .seconds(10)) + + var lastError: Error? + for _ in 0..<20 { + do { + let reconnectedClient = try AidenRemoteClient( + installation: installation, + credential: exchange.credential, + waitsForConnectivity: false, + requestTimeout: 2 + ) + let afterRestart = try await reconnectedClient.server() + XCTAssertEqual(afterRestart.instanceId, beforeRestart.instanceId) + lastError = nil + print("AIDEN_PHASE12_PHYSICAL checkpoint=reconnected") + break + } catch { + lastError = error + try await Task.sleep(for: .milliseconds(500)) + } + } + if lastError != nil { + throw try XCTUnwrap(lastError) + } + + print("AIDEN_PHASE12_PHYSICAL checkpoint=revocation-ready") + try await Task.sleep(for: .seconds(30)) + do { + let revokedClient = try AidenRemoteClient( + installation: installation, + credential: exchange.credential, + waitsForConnectivity: false, + requestTimeout: 2 + ) + _ = try await revokedClient.server() + XCTFail("The revoked credential must stop authenticating immediately.") + } catch let error as AidenRemoteClientError { + guard case .server(let statusCode, let body) = error else { + return XCTFail("Expected a typed server error after credential revocation.") + } + XCTAssertEqual(statusCode, 403) + XCTAssertEqual(body.code.rawValue, "credential_revoked") + print("AIDEN_PHASE12_PHYSICAL checkpoint=revoked") + } + + print("AIDEN_PHASE12_PHYSICAL checkpoint=repair-ready") + try await Task.sleep(for: .seconds(30)) + let repairExchange = try await AidenRemoteClient.pair( + payload: repairPayload, + deviceName: "Physical iPhone 13 Pro Re-pair Proof", + deviceType: .iphone, + clientVersion: "1.0" + ) + XCTAssertEqual(repairExchange.instanceId, exchange.instanceId) + XCTAssertNotEqual(repairExchange.deviceId, exchange.deviceId) + XCTAssertNotEqual(repairExchange.credential, exchange.credential) + let repairedInstallation = AidenInstallation( + exchange: repairExchange, + pairingTrust: repairPayload.trust, + name: "Physical Aiden Agent Re-pair Proof" + ) + let repairedClient = try AidenRemoteClient( + installation: repairedInstallation, + credential: repairExchange.credential, + waitsForConnectivity: false, + requestTimeout: 2 + ) + let repairedServer = try await repairedClient.server() + XCTAssertEqual(repairedServer.instanceId, beforeRestart.instanceId) + do { + _ = try await AidenRemoteClient.pair( + payload: repairPayload, + deviceName: "Physical iPhone 13 Pro Re-pair Replay", + deviceType: .iphone, + clientVersion: "1.0" + ) + XCTFail("The repair pairing secret must be one-use.") + } catch let error as AidenRemoteClientError { + guard case .server(let statusCode, let body) = error else { + return XCTFail("Expected a typed server error after repair pairing replay.") + } + XCTAssertEqual(statusCode, 401) + XCTAssertEqual(body.code.rawValue, "pairing_closed") + } + print("AIDEN_PHASE12_PHYSICAL checkpoint=repaired") + + print("AIDEN_PHASE12_PHYSICAL checkpoint=repair-restart-ready") + try await Task.sleep(for: .seconds(30)) + let repairedAfterRestart = try AidenRemoteClient( + installation: repairedInstallation, + credential: repairExchange.credential, + waitsForConnectivity: false, + requestTimeout: 2 + ) + let serverAfterRepairRestart = try await repairedAfterRestart.server() + XCTAssertEqual(serverAfterRepairRestart.instanceId, beforeRestart.instanceId) + do { + let oldAfterRepair = try AidenRemoteClient( + installation: installation, + credential: exchange.credential, + waitsForConnectivity: false, + requestTimeout: 2 + ) + _ = try await oldAfterRepair.server() + XCTFail("The replaced credential must remain invalid after re-pair and restart.") + } catch let error as AidenRemoteClientError { + guard case .server(let statusCode, let body) = error else { + return XCTFail("Expected a typed server error for the replaced credential.") + } + XCTAssertEqual(statusCode, 403) + XCTAssertEqual(body.code.rawValue, "credential_revoked") + } + print("AIDEN_PHASE12_PHYSICAL checkpoint=repair-restarted") + } + + @MainActor + func testRePairingAtomicallyReplacesInstallationScopedCredential() throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + let initial = makeExchange( + instanceId: "instance-repair", + deviceId: "device-before", + credential: String(repeating: "A", count: 43) + ) + let repaired = makeExchange( + instanceId: "instance-repair", + deviceId: "device-after", + credential: String(repeating: "B", count: 43) + ) + let createdAt = Date(timeIntervalSince1970: 1_787_100_000) + let first = try store.savePairing(initial, trust: makeSystemTrust(), name: "Aiden Mac", now: createdAt) + let second = try store.savePairing( + repaired, + trust: makeSystemTrust(), + name: "Aiden Mac", + now: createdAt.addingTimeInterval(60) + ) + + XCTAssertEqual(store.installations.count, 1) + XCTAssertEqual(store.activeInstallationId, "instance-repair") + XCTAssertEqual(second.createdAt, first.createdAt) + XCTAssertEqual(second.deviceId, "device-after") + XCTAssertEqual(try store.credential(for: second), repaired.credential) + XCTAssertEqual( + keychain.scoped[KeychainStore.scopedKey(.remoteCredential, scope: second.credentialScope)], + repaired.credential + ) + XCTAssertFalse(keychain.scoped.values.contains(initial.credential)) + } + + @MainActor + func testRePairingSnapshotFailureKeepsPreviousVersionedCredentialCoherentAfterRestart() throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + let initial = makeExchange( + instanceId: "instance-atomic", + deviceId: "device-before", + credential: String(repeating: "A", count: 43) + ) + let repaired = makeExchange( + instanceId: "instance-atomic", + deviceId: "device-after", + credential: String(repeating: "B", count: 43) + ) + let previous = try store.savePairing(initial, trust: makeSystemTrust(), name: "Aiden Mac") + keychain.failingSaveKeys.insert(.remoteInstallations) + keychain.failingScopedDeleteScopes.insert("instance-atomic:device-after") + + XCTAssertThrowsError( + try store.savePairing(repaired, trust: makeSystemTrust(), name: "Aiden Mac") + ) + keychain.failingSaveKeys.remove(.remoteInstallations) + let restarted = AidenInstallationStore(keychain: keychain) + let active = try XCTUnwrap(restarted.activeInstallation) + XCTAssertEqual(active.deviceId, previous.deviceId) + XCTAssertEqual(active.credentialScope, previous.credentialScope) + XCTAssertEqual(try restarted.credential(for: active), initial.credential) + XCTAssertNotEqual(try restarted.credential(for: active), repaired.credential) + } + + @MainActor + func testSameInstallationDeviceReplacementInvalidatesRetainedRequestContext() throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + let initial = makeExchange( + instanceId: "instance-repair-context", + deviceId: "device-before", + credential: String(repeating: "A", count: 43) + ) + let repaired = makeExchange( + instanceId: "instance-repair-context", + deviceId: "device-after", + credential: String(repeating: "B", count: 43) + ) + _ = try store.savePairing(initial, trust: makeSystemTrust(), name: "Aiden Mac") + let coordinator = AidenRemoteCoordinator(installationStore: store) + let admittedContext = try coordinator.requestContext() + + _ = try store.savePairing(repaired, trust: makeSystemTrust(), name: "Aiden Mac") + + XCTAssertFalse(coordinator.isRetained(admittedContext)) + XCTAssertFalse(coordinator.isCurrent(admittedContext)) + XCTAssertThrowsError(try coordinator.remoteClient(for: admittedContext)) { error in + guard case AidenRemoteClientError.installationChanged = error else { + return XCTFail("Expected the replaced device context to fail closed.") + } + } + } + + @MainActor + func testInstallationStoreKeepsCredentialsScopedAndSwitchesWithoutLeakage() throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + let now = Date(timeIntervalSince1970: 1_787_100_000) + let first = makeExchange(instanceId: "instance-1", deviceId: "device-1", credential: String(repeating: "A", count: 43)) + let second = makeExchange(instanceId: "instance-2", deviceId: "device-2", credential: String(repeating: "B", count: 43)) + + let firstInstallation = try store.savePairing(first, trust: makeSystemTrust(), name: "Home Mac", now: now) + let secondInstallation = try store.savePairing(second, trust: makeSystemTrust(), name: "Studio Mac", now: now) + XCTAssertEqual(store.activeInstallationId, "instance-2") + XCTAssertEqual(try store.credential(for: firstInstallation), first.credential) + XCTAssertEqual(try store.credential(for: secondInstallation), second.credential) + + try store.setActive("instance-1") + XCTAssertEqual(store.activeInstallationId, "instance-1") + XCTAssertEqual(try store.credential(for: store.activeInstallation!), first.credential) + + try store.remove("instance-1") + XCTAssertNil(keychain.scoped[KeychainStore.scopedKey(.remoteCredential, scope: firstInstallation.credentialScope)]) + XCTAssertEqual( + keychain.scoped[KeychainStore.scopedKey(.remoteCredential, scope: secondInstallation.credentialScope)], + second.credential + ) + } + + @MainActor + func testRepeatedLANAndTailscaleMacSwitchingKeepsEndpointAndCredentialIdentityScoped() throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + let lan = makeExchange( + instanceId: "instance-lan", + deviceId: "device-lan", + credential: String(repeating: "L", count: 43), + endpoint: URL(string: "https://home.local:49220/api/aiden/v1")! + ) + let tailscale = makeExchange( + instanceId: "instance-tailscale", + deviceId: "device-tailscale", + credential: String(repeating: "T", count: 43), + endpoint: URL(string: "https://studio.tailnet.ts.net/api/aiden/v1")! + ) + _ = try store.savePairing(lan, trust: makeSystemTrust(), name: "Mac") + let tailscaleInstallation = try store.savePairing(tailscale, trust: makeSystemTrust(), name: "Mac") + + for installationID in ["instance-lan", "instance-tailscale", "instance-lan", "instance-tailscale"] { + try store.setActive(installationID) + let active = try XCTUnwrap(store.activeInstallation) + XCTAssertEqual(active.id, installationID) + if installationID == "instance-lan" { + XCTAssertEqual(active.endpoint, lan.endpoint) + XCTAssertEqual(try store.credential(for: active), lan.credential) + XCTAssertEqual(AidenInstallationPresentation.endpointType(active.endpoint), "Local Network") + } else { + XCTAssertEqual(active.endpoint, tailscale.endpoint) + XCTAssertEqual(try store.credential(for: active), tailscale.credential) + XCTAssertEqual(AidenInstallationPresentation.endpointType(active.endpoint), "Tailscale") + } + } + + try store.remove("instance-tailscale") + let remaining = try XCTUnwrap(store.activeInstallation) + XCTAssertEqual(remaining.id, "instance-lan") + XCTAssertEqual(remaining.endpoint, lan.endpoint) + XCTAssertEqual(try store.credential(for: remaining), lan.credential) + XCTAssertNil(keychain.scoped[ + KeychainStore.scopedKey(.remoteCredential, scope: tailscaleInstallation.credentialScope) + ]) + } + + @MainActor + func testSameNamedInstallationsRemainDistinctAndServerRenamePreservesIdentity() throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + let first = makeExchange( + instanceId: "instance-a", + deviceId: "device-a", + credential: String(repeating: "A", count: 43) + ) + let second = makeExchange( + instanceId: "instance-b", + deviceId: "device-b", + credential: String(repeating: "B", count: 43) + ) + let firstInstallation = try store.savePairing(first, trust: makeSystemTrust(), name: "Studio Mac") + let secondInstallation = try store.savePairing(second, trust: makeSystemTrust(), name: "Studio Mac") + + XCTAssertNil(firstInstallation.lastConnectedAt, "Credential exchange is not an authenticated server read.") + XCTAssertNil(secondInstallation.lastConnectedAt, "Credential exchange is not an authenticated server read.") + XCTAssertEqual(store.installations.map(\.id), ["instance-a", "instance-b"]) + XCTAssertEqual(Set(store.installations.map(\.name)), ["Studio Mac"]) + XCTAssertEqual(try store.credential(for: firstInstallation), first.credential) + XCTAssertEqual(try store.credential(for: secondInstallation), second.credential) + + try store.updateServer(AidenServer( + protocolVersion: 1, + instanceId: "instance-a", + name: "Home Mac", + appVersion: "1.0", + capabilities: [.serverRead], + connectionMode: .lan, + minimumClientVersion: nil, + serverTime: Date() + )) + XCTAssertEqual(store.installations.first(where: { $0.id == "instance-a" })?.name, "Home Mac") + XCTAssertNotNil(store.installations.first(where: { $0.id == "instance-a" })?.lastConnectedAt) + XCTAssertEqual(try store.credential(for: firstInstallation), first.credential) + XCTAssertEqual(try store.credential(for: secondInstallation), second.credential) + } + + @MainActor + func testServerRenamePersistenceFailureRestoresEntireSortedRegistry() throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + _ = try store.savePairing( + makeExchange(instanceId: "instance-a", deviceId: "device-a", credential: "credential-a"), + trust: makeSystemTrust(), + name: "Alpha" + ) + _ = try store.savePairing( + makeExchange(instanceId: "instance-z", deviceId: "device-z", credential: "credential-z"), + trust: makeSystemTrust(), + name: "Zulu" + ) + let before = store.installations + keychain.failingSaveKeys = [.remoteInstallations] + + XCTAssertThrowsError(try store.updateServer(AidenServer( + protocolVersion: 1, + instanceId: "instance-a", + name: "ZZ Top", + appVersion: "1.0", + capabilities: [.serverRead], + connectionMode: .lan, + minimumClientVersion: nil, + serverTime: Date() + ))) + XCTAssertEqual(store.installations, before) + } + + func testDiscoveryAndInstallationPresentationUsePublicIdentityWithoutEndpointConfusion() throws { + let txt = NetService.data(fromTXTRecord: [ + "v": Data("1".utf8), + "instance": Data("instance_studio_1".utf8), + ]) + XCTAssertEqual( + AidenDiscoveryIdentity.instanceID(fromTXTRecord: txt), + "instance_studio_1" + ) + XCTAssertNil(AidenDiscoveryIdentity.instanceID(fromTXTRecord: NetService.data( + fromTXTRecord: ["instance": Data("bad instance".utf8)] + ))) + XCTAssertEqual( + AidenInstallationPresentation.endpointType( + URL(string: "https://studio.tailnet.ts.net/api/aiden/v1")! + ), + "Tailscale" + ) + XCTAssertEqual( + AidenInstallationPresentation.endpointType( + URL(string: "https://studio.local:49220/api/aiden/v1")! + ), + "Local Network" + ) + XCTAssertEqual( + AidenInstallationPresentation.reachability( + installationID: "instance-a", + activeInstallationID: "instance-b", + connectionState: .connected + ), + "Not checked" + ) + XCTAssertEqual(AidenInstallationPresentation.identitySuffix("instance_studio_abcdef"), "abcdef") + XCTAssertEqual( + AidenInstallationPresentation.accessibilityValue( + installationID: "instance-a", + activeInstallationID: "instance-a", + connectionState: .connected, + endpoint: URL(string: "https://studio.local:49220/api/aiden/v1")!, + lastConnectedAt: nil + ), + "Selected, Connected, Local Network, Never connected" + ) + } + + @MainActor + func testInstallationTrustPersistsAndLegacyMetadataFailsClosed() throws { + let keychain = AidenRemoteMemoryKeychain() + let firstStore = AidenInstallationStore(keychain: keychain) + let exchange = makeExchange( + instanceId: "instance-trusted", + deviceId: "device-trusted", + credential: "credential-trusted" + ) + _ = try firstStore.savePairing( + exchange, + trust: makeSystemTrust(), + name: "Trusted Mac" + ) + let restoredStore = AidenInstallationStore(keychain: keychain) + XCTAssertEqual(restoredStore.activeInstallation?.pairingTrust, makeSystemTrust()) + + let legacySnapshot = """ + {"installations":[{"instanceId":"legacy-instance","deviceId":"legacy-device", + "name":"Legacy Mac","endpoint":"https://aiden.test/api/aiden/v1", + "serverSpkiSha256":"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "capabilities":["server:read"],"createdAt":0}], + "activeInstallationId":"legacy-instance"} + """ + keychain.values[.remoteInstallations] = legacySnapshot + let legacyStore = AidenInstallationStore(keychain: keychain) + let legacyInstallation = try XCTUnwrap(legacyStore.activeInstallation) + XCTAssertNil(legacyInstallation.pairingTrust) + XCTAssertThrowsError(try AidenRemoteClient( + installation: legacyInstallation, + credential: "legacy-credential" + )) { error in + guard let clientError = error as? AidenRemoteClientError, + case .missingTrustConfiguration = clientError else { + return XCTFail("Legacy metadata must require secure re-pairing.") + } + } + } + + @MainActor + func testCoordinatorConnectsAndAppliesWorkspaceCRUDWithoutLosingAuthoritativeState() async throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + let exchange = makeExchange( + instanceId: "instance-1", + deviceId: "device-1", + credential: "credential-one" + ) + _ = try store.savePairing(exchange, trust: makeSystemTrust(), name: "Unverified Mac") + + let session = makeSession() + var workspaceListRequests = 0 + AidenRemoteMockURLProtocol.handler = { request in + switch (request.httpMethod, request.url?.path) { + case ("GET", "/api/aiden/v1/server"): + return Self.response( + for: request, + status: 200, + json: """ + {"protocolVersion":1,"instanceId":"instance-1","name":"Home Mac", + "appVersion":"1.0.0","capabilities":["server:read","workspace:read","workspace:manage"], + "connectionMode":"lan","serverTime":"2026-08-19T07:00:00.000Z"} + """ + ) + case ("GET", "/api/aiden/v1/workspaces"): + workspaceListRequests += 1 + return Self.response( + for: request, + status: 200, + json: """ + {"workspaces":[ + {"id":"workspace-z","name":"Zulu","permission":"ask","hasFolder":false, + "isManagedWorktree":false,"createdAt":"2026-08-19T07:00:00.000Z", + "updatedAt":"2026-08-19T07:00:00.000Z","revision":"rev-z"}, + {"id":"workspace-a","name":"Alpha","permission":"ask","hasFolder":false, + "isManagedWorktree":false,"createdAt":"2026-08-19T07:00:00.000Z", + "updatedAt":"2026-08-19T07:00:00.000Z","revision":"rev-a"}]} + """ + ) + case ("POST", "/api/aiden/v1/workspaces"): + return Self.workspaceResponse(for: request, status: 201, revision: "rev-created") + case ("PATCH", "/api/aiden/v1/workspaces/workspace-1"): + return Self.workspaceResponse(for: request, status: 200, revision: "rev-updated") + case ("DELETE", "/api/aiden/v1/workspaces/workspace-1"): + return ( + HTTPURLResponse(url: request.url!, statusCode: 204, httpVersion: nil, headerFields: nil)!, + Data() + ) + default: + XCTFail("Unexpected coordinator request: \(request.httpMethod ?? "nil") \(request.url?.path ?? "nil")") + return Self.response(for: request, status: 500, json: "{}") + } + } + + let coordinator = AidenRemoteCoordinator( + installationStore: store, + clientFactory: { installation, credential in + AidenRemoteClient(endpoint: installation.endpoint, credential: credential, session: session) + } + ) + await coordinator.start() + + XCTAssertEqual(coordinator.connectionState, .connected) + XCTAssertEqual(coordinator.server?.name, "Home Mac") + XCTAssertEqual(store.activeInstallation?.name, "Home Mac") + XCTAssertEqual(coordinator.workspaces.map(\.name), ["Zulu", "Alpha"]) + + let createdResult = await coordinator.createWorkspace(.folderless(name: "New Workspace")) + let created = try XCTUnwrap(createdResult) + XCTAssertTrue(coordinator.workspaces.contains(where: { $0.id == created.id })) + let updatedResult = await coordinator.updateWorkspace(created, permission: .full) + let updated = try XCTUnwrap(updatedResult) + XCTAssertEqual(updated.revision, "rev-updated") + let removed = await coordinator.removeWorkspace(updated) + XCTAssertTrue(removed) + XCTAssertFalse(coordinator.workspaces.contains(where: { $0.id == updated.id })) + XCTAssertEqual(workspaceListRequests, 2, "Confirmed removal should reload the canonical registry in case the Mac seeded a default workspace") + } + + @MainActor + func testFailedStagedPairingCannotReplaceTheWorkingInstallation() async throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + let previousExchange = makeExchange( + instanceId: "instance-1", + deviceId: "device-working", + credential: "credential-working" + ) + let previousInstallation = try store.savePairing( + previousExchange, + trust: makeSystemTrust(), + name: "Working Mac" + ) + let stagedExchange = makeExchange( + instanceId: "instance-1", + deviceId: "device-staged", + credential: "credential-staged" + ) + let payload = makePairingPayload( + bootstrap: makeBootstrap(now: Date(timeIntervalSince1970: 1_787_100_000)) + ) + let session = makeSession() + AidenRemoteMockURLProtocol.handler = { request in + switch request.url?.path { + case "/api/aiden/v1/server": + return Self.response( + for: request, + status: 200, + json: """ + {"protocolVersion":1,"instanceId":"instance-impostor","name":"Wrong Mac", + "appVersion":"1.0.0","capabilities":["server:read","workspace:read"], + "connectionMode":"lan","serverTime":"2026-08-19T07:00:00.000Z"} + """ + ) + case "/api/aiden/v1/workspaces": + return Self.response(for: request, status: 200, json: "{\"workspaces\":[]}") + default: + XCTFail("Unexpected staged-pairing request: \(request.url?.path ?? "nil")") + return Self.response(for: request, status: 500, json: "{}") + } + } + let coordinator = AidenRemoteCoordinator( + installationStore: store, + clientFactory: { installation, credential in + AidenRemoteClient(endpoint: installation.endpoint, credential: credential, session: session) + } + ) + + do { + try await coordinator.activatePairing(payload: payload, exchange: stagedExchange) + XCTFail("A staged Mac with a mismatched authenticated identity was promoted.") + } catch { + XCTAssertEqual(error as? AidenRemoteContractError, .invalidPairingExchange) + } + + let active = try XCTUnwrap(store.activeInstallation) + XCTAssertEqual(active.deviceId, previousInstallation.deviceId) + XCTAssertEqual(active.credentialScope, previousInstallation.credentialScope) + XCTAssertEqual(try store.credential(for: active), previousExchange.credential) + XCTAssertFalse(keychain.scoped.values.contains(stagedExchange.credential)) + } + + @MainActor + func testRejectedWorkspaceRemovalReconcilesWithoutLosingDeviceArchiveState() async throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + _ = try store.savePairing( + makeExchange(instanceId: "instance-1", deviceId: "device-1", credential: "credential-one"), + trust: makeSystemTrust(), + name: "Home Mac" + ) + let suiteName = "AidenRejectedRemovalArchiveTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let archiveStore = AidenWorkspaceArchiveStore(defaults: defaults) + archiveStore.archive(workspaceID: "workspace-a", instanceID: "instance-1") + + let session = makeSession() + AidenRemoteMockURLProtocol.handler = { request in + switch (request.httpMethod, request.url?.path) { + case ("GET", "/api/aiden/v1/server"): + return Self.response( + for: request, + status: 200, + json: """ + {"protocolVersion":1,"instanceId":"instance-1","name":"Home Mac", + "appVersion":"1.0.0","capabilities":["server:read","workspace:read","workspace:manage"], + "connectionMode":"lan","serverTime":"2026-08-19T07:00:00.000Z"} + """ + ) + case ("GET", "/api/aiden/v1/workspaces"): + return Self.response( + for: request, + status: 200, + json: """ + {"workspaces":[ + {"id":"workspace-a","name":"Archive Me","permission":"ask","hasFolder":true, + "isManagedWorktree":false,"createdAt":"2026-08-19T07:00:00.000Z", + "updatedAt":"2026-08-19T07:01:00.000Z","revision":"rev-current"}, + {"id":"workspace-b","name":"Keep Me","permission":"ask","hasFolder":true, + "isManagedWorktree":false,"createdAt":"2026-08-19T07:00:00.000Z", + "updatedAt":"2026-08-19T07:01:00.000Z","revision":"rev-b"}]} + """ + ) + case ("DELETE", "/api/aiden/v1/workspaces/workspace-a"): + return Self.response( + for: request, + status: 409, + json: """ + {"error":{"code":"revision_conflict","message":"The workspace changed.", + "requestId":"request-conflict","retryable":false}} + """ + ) + default: + XCTFail("Unexpected rejected-removal request: \(request.httpMethod ?? "nil") \(request.url?.path ?? "nil")") + return Self.response(for: request, status: 500, json: "{}") + } + } + + let coordinator = AidenRemoteCoordinator( + installationStore: store, + workspaceArchiveStore: archiveStore, + clientFactory: { installation, credential in + AidenRemoteClient(endpoint: installation.endpoint, credential: credential, session: session) + } + ) + await coordinator.start() + let workspace = try XCTUnwrap(coordinator.workspaces.first(where: { $0.id == "workspace-a" })) + + let removalOutcome = await coordinator.removeWorkspaceOutcome(workspace) + XCTAssertTrue(removalOutcome.isDefinitiveFailure) + XCTAssertNil(removalOutcome.value) + XCTAssertTrue(coordinator.workspaces.contains(where: { $0.id == workspace.id })) + XCTAssertTrue(archiveStore.isArchived(workspaceID: workspace.id, instanceID: "instance-1")) + XCTAssertEqual(coordinator.connectionState, .connected) + XCTAssertEqual(coordinator.workspaceSnapshotRevision, 2) + XCTAssertEqual(coordinator.presentedError, "The workspace changed.") + } + + @MainActor + func testAuthoritativeEmptyWorkspaceSnapshotPrunesStaleDeviceArchives() async throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + _ = try store.savePairing( + makeExchange(instanceId: "instance-empty", deviceId: "device-empty", credential: "credential-empty"), + trust: makeSystemTrust(), + name: "Empty Mac" + ) + let suiteName = "AidenEmptySnapshotArchiveTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let archiveStore = AidenWorkspaceArchiveStore(defaults: defaults) + archiveStore.archive(workspaceID: "stale", instanceID: "instance-empty") + + let session = makeSession() + AidenRemoteMockURLProtocol.handler = { request in + if request.url?.path == "/api/aiden/v1/server" { + return Self.response( + for: request, + status: 200, + json: """ + {"protocolVersion":1,"instanceId":"instance-empty","name":"Empty Mac", + "appVersion":"1.0.0","capabilities":["server:read","workspace:read"], + "connectionMode":"lan","serverTime":"2026-08-19T07:00:00.000Z"} + """ + ) + } + return Self.response(for: request, status: 200, json: "{\"workspaces\":[]}") + } + let coordinator = AidenRemoteCoordinator( + installationStore: store, + workspaceArchiveStore: archiveStore, + clientFactory: { installation, credential in + AidenRemoteClient(endpoint: installation.endpoint, credential: credential, session: session) + } + ) + + await coordinator.start() + + XCTAssertEqual(coordinator.workspaceSnapshotRevision, 1) + XCTAssertEqual(archiveStore.archivedWorkspaceIDs(for: "instance-empty"), []) + } + + @MainActor + func testSlowerPreviousInstallationLoadCannotOverwriteNewActiveMac() async throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + _ = try store.savePairing( + makeExchange(instanceId: "instance-1", deviceId: "device-1", credential: "credential-one"), + trust: makeSystemTrust(), + name: "Slow Mac" + ) + _ = try store.savePairing( + makeExchange(instanceId: "instance-2", deviceId: "device-2", credential: "credential-two"), + trust: makeSystemTrust(), + name: "Fast Mac" + ) + try store.setActive("instance-1") + + let session = makeSession() + AidenRemoteMockURLProtocol.handler = { request in + let isSlowMac = request.value(forHTTPHeaderField: "Authorization") == "Bearer credential-one" + let instanceID = isSlowMac ? "instance-1" : "instance-2" + if request.url?.path == "/api/aiden/v1/server" { + return Self.response( + for: request, + status: 200, + json: """ + {"protocolVersion":1,"instanceId":"\(instanceID)","name":"\(isSlowMac ? "Slow Mac" : "Fast Mac")", + "appVersion":"1.0.0","capabilities":["server:read","workspace:read"], + "connectionMode":"lan","serverTime":"2026-08-19T07:00:00.000Z"} + """ + ) + } + if isSlowMac { Thread.sleep(forTimeInterval: 0.2) } + return Self.response( + for: request, + status: 200, + json: """ + {"workspaces":[{"id":"workspace-\(instanceID)","name":"Workspace \(instanceID)", + "permission":"ask","hasFolder":false,"isManagedWorktree":false, + "createdAt":"2026-08-19T07:00:00.000Z","updatedAt":"2026-08-19T07:00:00.000Z", + "revision":"rev-\(instanceID)"}]} + """ + ) + } + let coordinator = AidenRemoteCoordinator( + installationStore: store, + clientFactory: { installation, credential in + AidenRemoteClient(endpoint: installation.endpoint, credential: credential, session: session) + } + ) + let slowLoad = Task { await coordinator.connectActiveInstallation() } + try await Task.sleep(for: .milliseconds(25)) + let firstActivation = try coordinator.requestContext() + await coordinator.switchInstallation(to: "instance-2") + await slowLoad.value + + XCTAssertEqual(coordinator.activeInstanceId, "instance-2") + XCTAssertEqual(coordinator.server?.instanceId, "instance-2") + XCTAssertEqual(coordinator.workspaces.map(\.id), ["workspace-instance-2"]) + XCTAssertEqual(coordinator.connectionState, .connected) + XCTAssertThrowsError(try coordinator.requestContext(for: "instance-1")) { error in + guard case AidenRemoteClientError.installationChanged = error else { + return XCTFail("A stale feature must not borrow the newly active Mac client.") + } + } + + await coordinator.switchInstallation(to: "instance-1") + XCTAssertFalse(coordinator.isCurrent(firstActivation), "A -> B -> A must invalidate the first A activation lease.") + XCTAssertThrowsError(try coordinator.remoteClient(for: firstActivation)) { error in + guard case AidenRemoteClientError.installationChanged = error else { + return XCTFail("An ABA-stale feature must not regain access to the current Mac client.") + } + } + } + + @MainActor + func testStaleFolderBrowserLeaseNeverSendsOpaqueLocationToAnotherMacOrABAActivation() async throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + _ = try store.savePairing( + makeExchange(instanceId: "instance-1", deviceId: "device-1", credential: "credential-one"), + trust: makeSystemTrust(), + name: "First Mac" + ) + _ = try store.savePairing( + makeExchange(instanceId: "instance-2", deviceId: "device-2", credential: "credential-two"), + trust: makeSystemTrust(), + name: "Second Mac" + ) + try store.setActive("instance-1") + let session = makeSession() + var browserRequests = 0 + var scheduledRequests = 0 + AidenRemoteMockURLProtocol.handler = { request in + let instanceID = request.value(forHTTPHeaderField: "Authorization") == "Bearer credential-one" + ? "instance-1" : "instance-2" + if request.url?.path.contains("/browser/") == true { + browserRequests += 1 + return Self.response(for: request, status: 500, json: "{}") + } + if request.url?.path.contains("/scheduled-tasks") == true { + scheduledRequests += 1 + return Self.response(for: request, status: 500, json: "{}") + } + if request.url?.path == "/api/aiden/v1/server" { + return Self.response(for: request, status: 200, json: """ + {"protocolVersion":1,"instanceId":"\(instanceID)","name":"Mac", + "appVersion":"1.0","capabilities":["server:read","workspace:read","workspace:browse","workspace:manage"], + "connectionMode":"lan","serverTime":"2026-08-19T07:00:00.000Z"} + """) + } + return Self.response(for: request, status: 200, json: "{\"workspaces\":[]}") + } + let coordinator = AidenRemoteCoordinator( + installationStore: store, + clientFactory: { installation, credential in + AidenRemoteClient(endpoint: installation.endpoint, credential: credential, session: session) + } + ) + await coordinator.start() + let firstLease = try coordinator.requestContext() + let retainedScheduledModel = AidenScheduledTasksModel(coordinator: coordinator) + var staleDraft = AidenScheduledTaskDraft() + staleDraft.name = "Must stay on First Mac" + staleDraft.schedule = "0 9 * * *" + staleDraft.prompt = "Run the stale draft" + await coordinator.switchInstallation(to: "instance-2") + XCTAssertFalse(retainedScheduledModel.isConnected) + await retainedScheduledModel.loadScripts(workspaceId: nil) + let savedOnSecondMac = await retainedScheduledModel.save(staleDraft, replacing: nil) + XCTAssertFalse(savedOnSecondMac) + let afterSwitch = await coordinator.createSelectedFolderWorkspace( + context: firstLease, + location: "loc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + name: nil + ) + XCTAssertNil(afterSwitch) + await coordinator.switchInstallation(to: "instance-1") + XCTAssertFalse(retainedScheduledModel.isConnected) + await retainedScheduledModel.loadScripts(workspaceId: nil) + let savedAfterABA = await retainedScheduledModel.save(staleDraft, replacing: nil) + XCTAssertFalse(savedAfterABA) + let afterABA = await coordinator.createSelectedFolderWorkspace( + context: firstLease, + location: "loc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + name: nil + ) + XCTAssertNil(afterABA) + XCTAssertEqual(browserRequests, 0) + XCTAssertEqual(scheduledRequests, 0) + } + + @MainActor + func testCoordinatorRevocationRemovesOnlyAffectedInstallationAndConnectsNextMac() async throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + let cacheRoot = FileManager.default.temporaryDirectory + .appending(path: "aiden-revocation-cache-\(UUID().uuidString)", directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: cacheRoot) } + let chatCache = AidenChatCache(root: cacheRoot.appending(path: "chats")) + let scheduledCache = AidenScheduledTaskCache(root: cacheRoot.appending(path: "schedules")) + let environmentCache = AidenWorkspaceEnvironmentCache( + directory: cacheRoot.appending(path: "environment") + ) + let suiteName = "AidenRevocationArchiveTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let archiveStore = AidenWorkspaceArchiveStore(defaults: defaults) + let first = makeExchange( + instanceId: "instance-1", + deviceId: "device-1", + credential: "credential-one" + ) + let second = makeExchange( + instanceId: "instance-2", + deviceId: "device-2", + credential: "credential-two" + ) + let firstInstallation = try store.savePairing(first, trust: makeSystemTrust(), name: "Revoked Mac") + let secondInstallation = try store.savePairing(second, trust: makeSystemTrust(), name: "Backup Mac") + try store.setActive("instance-1") + try await chatCache.saveActiveStream( + .init(deviceId: "device-1", streamId: "stream-1", turnId: "turn-1", lastSequence: 2), + instanceId: "instance-1", + chatId: "chat-1" + ) + try await chatCache.saveActiveStream( + .init(deviceId: "device-2", streamId: "stream-2", turnId: "turn-2", lastSequence: 4), + instanceId: "instance-2", + chatId: "chat-2" + ) + try await scheduledCache.store(instanceId: "instance-1", tasks: [], settings: nil) + try await scheduledCache.store(instanceId: "instance-2", tasks: [], settings: nil) + archiveStore.archive(workspaceID: "workspace-1", instanceID: "instance-1") + archiveStore.archive(workspaceID: "workspace-2", instanceID: "instance-2") + + let session = makeSession() + AidenRemoteMockURLProtocol.handler = { request in + if request.value(forHTTPHeaderField: "Authorization") == "Bearer credential-one" { + return Self.response( + for: request, + status: 401, + json: """ + {"error":{"code":"credential_revoked","message":"Pair this device again.", + "requestId":"request-revoked","retryable":false}} + """ + ) + } + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer credential-two") + if request.url?.path == "/api/aiden/v1/server" { + return Self.response( + for: request, + status: 200, + json: """ + {"protocolVersion":1,"instanceId":"instance-2","name":"Backup Mac", + "appVersion":"1.0.0","capabilities":["server:read","workspace:read"], + "connectionMode":"lan","serverTime":"2026-08-19T07:00:00.000Z"} + """ + ) + } + return Self.response( + for: request, + status: 200, + json: """ + {"workspaces":[{"id":"workspace-2","name":"Backup Workspace", + "permission":"ask","hasFolder":false,"isManagedWorktree":false, + "createdAt":"2026-08-19T07:00:00.000Z","updatedAt":"2026-08-19T07:00:00.000Z", + "revision":"rev-workspace-2"}]} + """ + ) + } + + let coordinator = AidenRemoteCoordinator( + installationStore: store, + workspaceArchiveStore: archiveStore, + chatCache: chatCache, + scheduledTaskCache: scheduledCache, + workspaceEnvironmentCache: environmentCache, + clientFactory: { installation, credential in + AidenRemoteClient(endpoint: installation.endpoint, credential: credential, session: session) + } + ) + await coordinator.start() + + XCTAssertEqual(coordinator.connectionState, .connected) + XCTAssertEqual(store.activeInstallationId, "instance-2") + XCTAssertNil(keychain.scoped[KeychainStore.scopedKey(.remoteCredential, scope: firstInstallation.credentialScope)]) + XCTAssertEqual( + keychain.scoped[KeychainStore.scopedKey(.remoteCredential, scope: secondInstallation.credentialScope)], + "credential-two" + ) + XCTAssertTrue(coordinator.presentedError?.contains("revoked") == true) + let removedStream = await chatCache.loadActiveStream(instanceId: "instance-1", chatId: "chat-1") + let retainedStream = await chatCache.loadActiveStream(instanceId: "instance-2", chatId: "chat-2") + let removedSchedule = await scheduledCache.load(instanceId: "instance-1") + let retainedSchedule = await scheduledCache.load(instanceId: "instance-2") + XCTAssertNil(removedStream) + XCTAssertNotNil(retainedStream) + XCTAssertNil(removedSchedule) + XCTAssertNotNil(retainedSchedule) + XCTAssertEqual(archiveStore.archivedWorkspaceIDs(for: "instance-1"), []) + XCTAssertEqual(archiveStore.archivedWorkspaceIDs(for: "instance-2"), ["workspace-2"]) + } + + @MainActor + func testRemovalSerializesAgainstAcceptedTurnWritesAndPurgesTheLateCommit() async throws { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + let exchange = makeExchange( + instanceId: "instance-race", + deviceId: "device-race", + credential: String(repeating: "R", count: 43) + ) + _ = try store.savePairing(exchange, trust: makeSystemTrust(), name: "Race Mac") + let root = FileManager.default.temporaryDirectory + .appending(path: "aiden-removal-race-\(UUID().uuidString)", directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: root) } + let cache = AidenChatCache(root: root) + let coordinator = AidenRemoteCoordinator(installationStore: store, chatCache: cache) + let context = try coordinator.requestContext() + let probe = AidenInstallationDataRaceProbe() + + let acceptedCommit = Task { @MainActor in + await coordinator.withRetainedInstallationData(for: context) { + await probe.markEntered() + await probe.waitForRelease() + try? await cache.saveActiveStream( + .init( + deviceId: "device-race", + streamId: "stream-race", + turnId: "turn-race", + lastSequence: 0 + ), + instanceId: "instance-race", + chatId: "chat-race" + ) + } + } + await probe.waitUntilEntered() + let removal = Task { @MainActor in + await coordinator.removeInstallation("instance-race") + } + for _ in 0..<20 where !store.installations.isEmpty { + await Task.yield() + } + XCTAssertTrue(store.installations.isEmpty) + await probe.release() + _ = await acceptedCommit.value + await removal.value + + let restored = await cache.loadActiveStream( + instanceId: "instance-race", + chatId: "chat-race" + ) + XCTAssertNil(restored) + } + + private func makeClient() -> AidenRemoteClient { + AidenRemoteClient( + endpoint: URL(string: "https://aiden.test/api/aiden/v1")!, + credential: "device-credential", + session: makeSession() + ) + } + + private func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AidenRemoteMockURLProtocol.self] + return URLSession(configuration: configuration) + } + + private func makeBootstrap(now: Date) -> AidenRemoteContractFixture.PairingBootstrap { + AidenRemoteContractFixture.PairingBootstrap( + protocolVersion: 1, + instanceId: "instance-1", + endpoint: URL(string: "https://aiden.test/api/aiden/v1")!, + serverSpkiSha256: "sha256/\(Data(repeating: 7, count: 32).base64EncodedString())", + secret: String(repeating: "B", count: 43), + expiresAt: now.addingTimeInterval(120) + ) + } + + private func makeSystemTrust() -> AidenRemoteContractFixture.PairingTrust { + AidenRemoteContractFixture.PairingTrust(mode: .system) + } + + private func makePairingPayload( + bootstrap: AidenRemoteContractFixture.PairingBootstrap + ) -> AidenRemoteContractFixture.PairingPayload { + AidenRemoteContractFixture.PairingPayload( + bootstrap: bootstrap, + trust: makeSystemTrust() + ) + } + + private func makeExchange( + instanceId: String, + deviceId: String, + credential: String, + endpoint: URL = URL(string: "https://aiden.test/api/aiden/v1")! + ) -> AidenRemoteContractFixture.PairingExchange { + AidenRemoteContractFixture.PairingExchange( + protocolVersion: 1, + instanceId: instanceId, + deviceId: deviceId, + credential: credential, + capabilities: [.serverRead, .workspaceRead], + endpoint: endpoint, + serverSpkiSha256: "sha256/\(Data(repeating: 7, count: 32).base64EncodedString())" + ) + } + + private static func jsonBody(_ request: URLRequest) throws -> [String: Any] { + let data = try bodyData(request) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private static func bodyData(_ request: URLRequest) throws -> Data { + if let body = request.httpBody { return body } + let stream = try XCTUnwrap(request.httpBodyStream) + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 4_096) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count >= 0 else { throw stream.streamError ?? URLError(.cannotDecodeRawData) } + if count == 0 { break } + data.append(buffer, count: count) + } + return data + } + + private static func workspaceResponse( + for request: URLRequest, + status: Int, + revision: String + ) -> (HTTPURLResponse, Data) { + response( + for: request, + status: status, + json: """ + {"id":"workspace-1","name":"New Workspace","permission":"full", + "hasFolder":false,"isManagedWorktree":false, + "createdAt":"2026-08-19T07:00:00.000Z", + "updatedAt":"2026-08-19T07:01:00.000Z","revision":"\(revision)"} + """ + ) + } + + private static func chatResponse( + for request: URLRequest, + status: Int, + revision: String, + title: String = "New Chat" + ) -> (HTTPURLResponse, Data) { + response( + for: request, + status: status, + json: """ + {"id":"chat-1","workspaceId":"workspace-1","title":"\(title)","messages":[], + "createdAt":"2026-08-19T07:00:00.000Z","updatedAt":"2026-08-19T07:00:01.000Z", + "revision":"\(revision)"} + """ + ) + } + + private static func streamStatusResponse( + for request: URLRequest, + state: String + ) -> (HTTPURLResponse, Data) { + response( + for: request, + status: state == "cancelled" ? 202 : 200, + json: """ + {"streamId":"stream-1","chatId":"chat-1","turnId":"turn-1","state":"\(state)", + "lastSequence":3,"updatedAt":"2026-08-19T07:00:01.000Z"} + """ + ) + } + + private static func response( + for request: URLRequest, + status: Int, + json: String + ) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, Data(json.utf8)) + } +} + +private actor AidenInstallationDataRaceProbe { + private var entered = false + private var released = false + private var enteredWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func markEntered() { + entered = true + let waiters = enteredWaiters + enteredWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + + func waitUntilEntered() async { + guard !entered else { return } + await withCheckedContinuation { enteredWaiters.append($0) } + } + + func waitForRelease() async { + guard !released else { return } + await withCheckedContinuation { releaseWaiters.append($0) } + } + + func release() { + released = true + let waiters = releaseWaiters + releaseWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } +} + +private final class AidenRemoteMockURLProtocol: URLProtocol, @unchecked Sendable { + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = Self.handler else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +private final class AidenRemoteMemoryKeychain: KeychainStoring { + var values: [KeychainStore.Key: String] = [:] + var scoped: [String: String] = [:] + var failingSaveKeys: Set = [] + var failingScopedSaveScopes: Set = [] + var failingScopedDeleteScopes: Set = [] + + func save(_ value: String, forKey key: KeychainStore.Key) throws { + if failingSaveKeys.contains(key) { throw CocoaError(.fileWriteUnknown) } + values[key] = value + } + func load(_ key: KeychainStore.Key) throws -> String? { values[key] } + func delete(_ key: KeychainStore.Key) throws { values[key] = nil } + + func save(_ value: String, forKey key: KeychainStore.Key, scope: String) throws { + if failingScopedSaveScopes.contains(scope) { throw CocoaError(.fileWriteUnknown) } + scoped[KeychainStore.scopedKey(key, scope: scope)] = value + } + + func load(_ key: KeychainStore.Key, scope: String) throws -> String? { + scoped[KeychainStore.scopedKey(key, scope: scope)] + } + + func delete(_ key: KeychainStore.Key, scope: String) throws { + if failingScopedDeleteScopes.contains(scope) { throw CocoaError(.fileWriteUnknown) } + scoped[KeychainStore.scopedKey(key, scope: scope)] = nil + } +} diff --git a/ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift b/ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift new file mode 100644 index 00000000..415500c7 --- /dev/null +++ b/ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift @@ -0,0 +1,909 @@ +import CryptoKit +import Foundation +import Security +import XCTest +@testable import AidenOnTheGo + +final class AidenRemotePhase0Tests: XCTestCase { + private let approvedKeychainService = "sbtbiswas.AidenOnTheGo.pairing" + private let caCertificateDER = "MIIBpzCCAU6gAwIBAgIUIHmU6u43BGrkVPj4FQ5phcJ7K8EwCgYIKoZIzj0EAwIwIDEeMBwGA1UEAwwVQWlkZW4tUGhhc2UwLUxvY2FsLUNBMB4XDTI2MDgxODIwNTgwM1oXDTM2MDgxNTIwNTgwM1owIDEeMBwGA1UEAwwVQWlkZW4tUGhhc2UwLUxvY2FsLUNBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZwqoZbvPSf8paC937p5+TnciNpAxHE/4fwll/5YlUGW6xkSUmvFj7CpD3IPvY0PRgN+sZl/CzBFzn+wv9atnkaNmMGQwHQYDVR0OBBYEFK2vesnPv0ymHuSE6yQ9EoM+B7EYMB8GA1UdIwQYMBaAFK2vesnPv0ymHuSE6yQ9EoM+B7EYMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMCA0cAMEQCIHawuTBf/AOiSWTY+XpLIUzSxxFdKmTZl1Vol4HRJQ5VAiBpYlpHpxEzMd2j/VK8fUfZ8DU6y7XKme2iJFS8M7d1lw==" + private let originalCertificateDER = "MIIB4jCCAYmgAwIBAgIULA5eC0u0KgewVf5FJD89CeRl5icwCgYIKoZIzj0EAwIwIDEeMBwGA1UEAwwVQWlkZW4tUGhhc2UwLUxvY2FsLUNBMB4XDTI2MDgxODIwNTgwM1oXDTI2MDkxNzIwNTgwM1owJDEiMCAGA1UEAwwZYWlkZW4tcGhhc2UwLmV4YW1wbGUudGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABI5u4+Ne8MXXQeyVvmFDduB1soFoJQvIv296OVjGuty9Z0VyUpKn2+oBKTSuD0GNooaSlIHqptxLFT/cpEYxrRqjgZwwgZkwJAYDVR0RBB0wG4IZYWlkZW4tcGhhc2UwLmV4YW1wbGUudGVzdDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNVHQ4EFgQUnnUkXuqHkoGw2LKXeyU7bjyCVYcwHwYDVR0jBBgwFoAUra96yc+/TKYe5ITrJD0Sgz4HsRgwCgYIKoZIzj0EAwIDRwAwRAIgd2WNDX68uxSxGQYJsDiUXohxKlBeEjXESlgHx6WRrJgCIFJN5ineCyCIYL17DW2sJ/9h2qA3GdOo/aiUWc+e6FCV" + private let renewedCertificateDER = "MIIB9TCCAZugAwIBAgIULA5eC0u0KgewVf5FJD89CeRl5igwCgYIKoZIzj0EAwIwIDEeMBwGA1UEAwwVQWlkZW4tUGhhc2UwLUxvY2FsLUNBMB4XDTI2MDgxODIwNTgwM1oXDTI2MDkxNzIwNTgwM1owNjEiMCAGA1UEAwwZYWlkZW4tcGhhc2UwLmV4YW1wbGUudGVzdDEQMA4GA1UECwwHcmVuZXdlZDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABI5u4+Ne8MXXQeyVvmFDduB1soFoJQvIv296OVjGuty9Z0VyUpKn2+oBKTSuD0GNooaSlIHqptxLFT/cpEYxrRqjgZwwgZkwJAYDVR0RBB0wG4IZYWlkZW4tcGhhc2UwLmV4YW1wbGUudGVzdDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNVHQ4EFgQUnnUkXuqHkoGw2LKXeyU7bjyCVYcwHwYDVR0jBBgwFoAUra96yc+/TKYe5ITrJD0Sgz4HsRgwCgYIKoZIzj0EAwIDSAAwRQIhANjQ3dAkNt/zT66IhAfodEWh75Ig5XmAju3MYn2sLSicAiBUomVIhfwnYjxs54zSiHzuyGpPmRKVCBHjzadb+U9MTw==" + private let rotatedCertificateDER = "MIIB9TCCAZugAwIBAgIULA5eC0u0KgewVf5FJD89CeRl5ikwCgYIKoZIzj0EAwIwIDEeMBwGA1UEAwwVQWlkZW4tUGhhc2UwLUxvY2FsLUNBMB4XDTI2MDgxODIwNTgwM1oXDTI2MDkxNzIwNTgwM1owNjEiMCAGA1UEAwwZYWlkZW4tcGhhc2UwLmV4YW1wbGUudGVzdDEQMA4GA1UECwwHcm90YXRlZDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABMFeD3fbzVvwD6XsOV0zTS9/afPUm0BzWjsDlPoPKR+s5dlo3aAIe1B0tsMvEOgdHb0BV8D9RQcOg3H+/qqKYLKjgZwwgZkwJAYDVR0RBB0wG4IZYWlkZW4tcGhhc2UwLmV4YW1wbGUudGVzdDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNVHQ4EFgQU6Vpq2GlIaP7m9AHtEE+fve8bWDYwHwYDVR0jBBgwFoAUra96yc+/TKYe5ITrJD0Sgz4HsRgwCgYIKoZIzj0EAwIDSAAwRQIhAKdQOzcQ+qJZP/gpVvT8uDz+DvRx9JAEWTLmuUqoJ9ujAiABuU2rRJ/ivMhm5lGDDFrunkN9Kk04oUPjJ1w8CplkQg==" + + private func trust(for certificate: SecCertificate, ca: SecCertificate, host: String) throws -> SecTrust { + var value: SecTrust? + let status = SecTrustCreateWithCertificates( + [certificate, ca] as CFArray, + SecPolicyCreateSSL(true, host as CFString), + &value + ) + XCTAssertEqual(status, errSecSuccess) + return try XCTUnwrap(value) + } + + private func assertTransportLifecycleFixtures() throws { + let original = try XCTUnwrap(SecCertificateCreateWithData(nil, try XCTUnwrap(Data(base64Encoded: originalCertificateDER)) as CFData)) + let renewed = try XCTUnwrap(SecCertificateCreateWithData(nil, try XCTUnwrap(Data(base64Encoded: renewedCertificateDER)) as CFData)) + let rotated = try XCTUnwrap(SecCertificateCreateWithData(nil, try XCTUnwrap(Data(base64Encoded: rotatedCertificateDER)) as CFData)) + let ca = try XCTUnwrap(SecCertificateCreateWithData(nil, try XCTUnwrap(Data(base64Encoded: caCertificateDER)) as CFData)) + let privateCAPolicy = AidenServerTrustPolicy.privateCA(SecCertificateCopyData(ca) as Data) + let originalPin = try AidenServerTrust.spkiSHA256(certificate: original) + XCTAssertEqual(try AidenServerTrust.spkiSHA256(certificate: renewed), originalPin) + XCTAssertNotEqual(try AidenServerTrust.spkiSHA256(certificate: rotated), originalPin) + let validDate = try XCTUnwrap(ISO8601DateFormatter().date(from: "2026-08-19T00:00:00Z")) + XCTAssertNoThrow(try AidenServerTrust.evaluate(serverTrust: trust(for: original, ca: ca, host: "aiden-phase0.example.test"), expectedHost: "aiden-phase0.example.test", expectedFingerprint: originalPin, policy: privateCAPolicy, verificationDate: validDate)) + XCTAssertNoThrow(try AidenServerTrust.evaluate(serverTrust: trust(for: renewed, ca: ca, host: "aiden-phase0.example.test"), expectedHost: "aiden-phase0.example.test", expectedFingerprint: originalPin, policy: privateCAPolicy, verificationDate: validDate)) + XCTAssertThrowsError(try AidenServerTrust.evaluate(serverTrust: trust(for: original, ca: ca, host: "aiden-phase0.example.test"), expectedHost: "aiden-phase0.example.test", expectedFingerprint: originalPin, policy: .system, verificationDate: validDate)) + XCTAssertThrowsError(try AidenServerTrust.evaluate(serverTrust: trust(for: original, ca: ca, host: "aiden-phase0.example.test"), expectedHost: "aiden-phase0.example.test", expectedFingerprint: originalPin, policy: .privateCA(SecCertificateCopyData(rotated) as Data), verificationDate: validDate)) + XCTAssertThrowsError(try AidenServerTrust.evaluate(serverTrust: trust(for: rotated, ca: ca, host: "aiden-phase0.example.test"), expectedHost: "aiden-phase0.example.test", expectedFingerprint: originalPin, policy: privateCAPolicy, verificationDate: validDate)) + XCTAssertThrowsError(try AidenServerTrust.evaluate(serverTrust: trust(for: original, ca: ca, host: "wrong.example.test"), expectedHost: "wrong.example.test", expectedFingerprint: originalPin, policy: privateCAPolicy, verificationDate: validDate)) + let expiredDate = try XCTUnwrap(ISO8601DateFormatter().date(from: "2040-01-01T00:00:00Z")) + XCTAssertThrowsError(try AidenServerTrust.evaluate(serverTrust: trust(for: original, ca: ca, host: "aiden-phase0.example.test"), expectedHost: "aiden-phase0.example.test", expectedFingerprint: originalPin, policy: privateCAPolicy, verificationDate: expiredDate)) + } + private var sharedContractFixtureURL: URL? { + Bundle(for: Self.self).url(forResource: "contract", withExtension: "json") + } + + func testLocalNetworkPrivacyAndBonjourContractIsDeclared() throws { + let bundle = Bundle.main + let usageDescription = try XCTUnwrap( + bundle.object(forInfoDictionaryKey: "NSLocalNetworkUsageDescription") as? String + ) + XCTAssertFalse(usageDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + let services = try XCTUnwrap(bundle.object(forInfoDictionaryKey: "NSBonjourServices") as? [String]) + XCTAssertEqual(services, ["_aiden-agent._tcp"]) + } + + func testBuiltAppHasNoInsecureHTTPOrArbitraryLoadATSException() throws { + let bundle = Bundle.main + guard let transportSecurity = bundle.object( + forInfoDictionaryKey: "NSAppTransportSecurity" + ) as? [String: Any] else { + return + } + + XCTAssertNotEqual(transportSecurity["NSAllowsArbitraryLoads"] as? Bool, true) + XCTAssertNotEqual(transportSecurity["NSAllowsArbitraryLoadsForMedia"] as? Bool, true) + XCTAssertNotEqual(transportSecurity["NSAllowsArbitraryLoadsInWebContent"] as? Bool, true) + XCTAssertNotEqual(transportSecurity["NSAllowsLocalNetworking"] as? Bool, true) + XCTAssertNil(transportSecurity["NSExceptionDomains"]) + } + + func testAidenKeychainServiceResolvesToApprovedIdentity() throws { + XCTAssertEqual( + Bundle.main.object(forInfoDictionaryKey: "AidenKeychainService") as? String, + approvedKeychainService + ) + } + + func testBuiltAppUsesOnlyApprovedAidenProductIdentity() throws { + let bundle = Bundle.main + XCTAssertEqual(bundle.bundleIdentifier, "sbtbiswas.AidenOnTheGo") + XCTAssertEqual( + bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String, + "Aiden On The Go" + ) + XCTAssertEqual( + bundle.object(forInfoDictionaryKey: "AidenAppGroupIdentifier") as? String, + "group.sbtbiswas.AidenOnTheGo" + ) + XCTAssertEqual( + bundle.object(forInfoDictionaryKey: "AidenURLScheme") as? String, + "aiden-otg" + ) + + let urlTypes = try XCTUnwrap( + bundle.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]] + ) + let schemes = urlTypes.flatMap { $0["CFBundleURLSchemes"] as? [String] ?? [] } + XCTAssertEqual(schemes, ["aiden-otg"]) + XCTAssertFalse( + schemes.contains { scheme in + let value = scheme.lowercased() + return value.contains("hermes") || value.contains("hermex") + } + ) + } + + func testSignedDeviceAidenKeychainIsolationWhenConfigured() throws { + guard ProcessInfo.processInfo.environment["AIDEN_PHASE0_KEYCHAIN_PROOF"] == "1" else { + throw XCTSkip("Set AIDEN_PHASE0_KEYCHAIN_PROOF=1 only for a normally signed device run.") + } + + let scope = "aiden-phase0-\(UUID().uuidString)" + let probe = UUID().uuidString + let store = KeychainStore() + let otherStore = KeychainStore(service: "\(approvedKeychainService).phase0-isolation") + defer { + try? store.delete(.remoteCredential, scope: scope) + try? otherStore.delete(.remoteCredential, scope: scope) + } + + XCTAssertNil(try store.load(.remoteCredential, scope: scope)) + try store.save(probe, forKey: .remoteCredential, scope: scope) + XCTAssertEqual(try store.load(.remoteCredential, scope: scope), probe) + XCTAssertNil(try otherStore.load(.remoteCredential, scope: scope)) + try store.delete(.remoteCredential, scope: scope) + XCTAssertNil(try store.load(.remoteCredential, scope: scope)) + } + + func testSharedContractFixtureDecodesAndSequencesAreTerminalSafe() throws { + let fixtureURL = try XCTUnwrap(sharedContractFixtureURL) + let data = try Data(contentsOf: fixtureURL) + let fixture = try AidenRemoteJSONDecoder.decode( + AidenRemoteContractFixture.self, + from: data + ) + + XCTAssertEqual(fixture.contractRevision, 6) + XCTAssertEqual(fixture.protocolVersion, AidenRemoteProtocol.version) + XCTAssertTrue(fixture.health.ok) + XCTAssertEqual(fixture.health.protocolVersion, AidenRemoteProtocol.version) + XCTAssertEqual(Set(fixture.capabilities), Set(AidenRemoteCapability.v1Known)) + XCTAssertEqual(Set(fixture.events.map(\.type)), Set(AidenRemoteEventType.v1Known)) + XCTAssertEqual(fixture.pairingBootstrap.protocolVersion, AidenRemoteProtocol.version) + XCTAssertEqual(fixture.pairingBootstrap.endpoint.scheme, "https") + XCTAssertGreaterThanOrEqual(fixture.pairingBootstrap.secret.count, 32) + XCTAssertTrue(fixture.pairingBootstrap.serverSpkiSha256.hasPrefix("sha256/")) + let fixtureReferenceDate = try XCTUnwrap( + ISO8601DateFormatter().date(from: "2026-08-18T19:00:00Z") + ) + XCTAssertNoThrow(try fixture.pairingBootstrap.validated(at: fixtureReferenceDate)) + XCTAssertNoThrow(try fixture.pairingExchange.validated(against: fixture.pairingBootstrap)) + XCTAssertEqual(fixture.pairingExchange.displayName, "Fixture Aiden") + XCTAssertEqual(fixture.streamStatus.state, .waitingForApproval) + XCTAssertNil(Mirror(reflecting: fixture.streamStatus).children.first { $0.label == "approval" }) + XCTAssertEqual(fixture.streamApproval.approval?.approvalId, "approval_fixture_01") + XCTAssertEqual(fixture.streamApproval.approval?.canAllow, true) + + var lastSequence: [String: Int] = [:] + var terminalStreams = Set() + for event in fixture.events { + XCTAssertFalse(terminalStreams.contains(event.streamId)) + XCTAssertEqual(event.sequence, (lastSequence[event.streamId] ?? 0) + 1) + lastSequence[event.streamId] = event.sequence + if event.terminal { terminalStreams.insert(event.streamId) } + } + } + + func testHealthResponseRequiresExactSuccessfulV1Contract() throws { + let valid = Data(#"{"ok":true,"protocolVersion":1}"#.utf8) + XCTAssertNoThrow(try AidenRemoteJSONDecoder.decode(AidenRemoteContractFixture.Health.self, from: valid)) + + let invalidResponses = [ + Data(#"{"ok":true,"protocolVersion":1,"unexpected":true}"#.utf8), + Data(#"{"protocolVersion":1}"#.utf8), + Data(#"{"ok":true}"#.utf8), + Data(#"{"ok":false,"protocolVersion":1}"#.utf8), + Data(#"{"ok":true,"protocolVersion":2}"#.utf8), + ] + for response in invalidResponses { + XCTAssertThrowsError( + try AidenRemoteJSONDecoder.decode(AidenRemoteContractFixture.Health.self, from: response) + ) + } + } + + func testSharedFixtureContainsNoForbiddenWireFieldsOrMachinePaths() throws { + let fixtureURL = try XCTUnwrap(sharedContractFixtureURL) + let data = try Data(contentsOf: fixtureURL) + let object = try JSONSerialization.jsonObject(with: data) + let forbidden = Set([ + "authorization", "credentialDigest", "providerFingerprint", + "mcpServerBindings", "folderPath", "repositoryPath", "worktreePath", + "worktreeGitDir", "ownershipToken", "worktreeDevice", "worktreeInode", + "createdFromHead", "canonicalPath", "absolutePath", "scriptPath", + "environment", "stdout", "stderr", + ]) + + func inspect(_ value: Any) throws { + if let dictionary = value as? [String: Any] { + XCTAssertTrue(forbidden.isDisjoint(with: dictionary.keys)) + for child in dictionary.values { try inspect(child) } + } else if let array = value as? [Any] { + for child in array { try inspect(child) } + } else if let string = value as? String { + XCTAssertFalse(string.contains("/Users/")) + XCTAssertFalse(string.contains("BEGIN PRIVATE KEY")) + } + } + try inspect(object) + } + + func testP256SPKIFingerprintMatchesIndependentFixture() throws { + let rawKey = Data([0x04] + Array(repeating: UInt8(0), count: 64)) + XCTAssertEqual( + try AidenServerTrust.spkiSHA256(p256ExternalRepresentation: rawKey), + "sha256/FhPubfxu6YoU7IG0Hq45pUOLUPvLv4oAgUflVyabRMs=" + ) + } + + func testCertificateRenewalRotationWrongHostAndExpiryFailClosed() throws { + try assertTransportLifecycleFixtures() + } + + func testP256SPKIFingerprintRejectsMalformedOrWrongCurveRepresentations() { + XCTAssertThrowsError( + try AidenServerTrust.spkiSHA256(p256ExternalRepresentation: Data(repeating: 0, count: 65)) + ) + XCTAssertThrowsError( + try AidenServerTrust.spkiSHA256(p256ExternalRepresentation: Data([0x04])) + ) + } + + func testPairingBootstrapValidationFailsClosed() throws { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let valid = AidenRemoteContractFixture.PairingBootstrap( + protocolVersion: 1, + instanceId: "instance_fixture", + endpoint: try XCTUnwrap(URL(string: "https://aiden.example.test/api/aiden/v1")), + serverSpkiSha256: "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + secret: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + expiresAt: now.addingTimeInterval(60) + ) + XCTAssertNoThrow(try valid.validated(at: now)) + let validWithExplicitPort = AidenRemoteContractFixture.PairingBootstrap( + protocolVersion: valid.protocolVersion, + instanceId: valid.instanceId, + endpoint: try XCTUnwrap(URL(string: "https://aiden.example.test:7443/api/aiden/v1")), + serverSpkiSha256: valid.serverSpkiSha256, + secret: valid.secret, + expiresAt: valid.expiresAt + ) + XCTAssertNoThrow(try validWithExplicitPort.validated(at: now)) + for encodedPath in [ + "/api/aiden%2Fv1", + "/api/aiden%2fv1", + "/%61pi/aiden/v1", + ] { + let encodedEndpoint = AidenRemoteContractFixture.PairingBootstrap( + protocolVersion: valid.protocolVersion, + instanceId: valid.instanceId, + endpoint: try XCTUnwrap(URL(string: "https://aiden.example.test\(encodedPath)")), + serverSpkiSha256: valid.serverSpkiSha256, + secret: valid.secret, + expiresAt: valid.expiresAt + ) + XCTAssertThrowsError(try encodedEndpoint.validated(at: now)) + } + for invalidEndpoint in [ + "https://:443/api/aiden/v1", + "https://:65536/api/aiden/v1", + "https://aiden.example.test:0/api/aiden/v1", + "https://aiden.example.test:65536/api/aiden/v1", + "https://aiden.example.test/api/aiden/v1?", + "https://aiden.example.test/api/aiden/v1#", + ] { + let endpoint = AidenRemoteContractFixture.PairingBootstrap( + protocolVersion: valid.protocolVersion, + instanceId: valid.instanceId, + endpoint: try XCTUnwrap(URL(string: invalidEndpoint)), + serverSpkiSha256: valid.serverSpkiSha256, + secret: valid.secret, + expiresAt: valid.expiresAt + ) + XCTAssertThrowsError(try endpoint.validated(at: now), invalidEndpoint) + } + XCTAssertThrowsError(try AidenRemoteContractFixture.PairingBootstrap( + protocolVersion: valid.protocolVersion, + instanceId: String(repeating: "i", count: 129), + endpoint: valid.endpoint, + serverSpkiSha256: valid.serverSpkiSha256, + secret: valid.secret, + expiresAt: valid.expiresAt + ).validated(at: now)) + XCTAssertThrowsError(try AidenRemoteContractFixture.PairingBootstrap( + protocolVersion: 1, + instanceId: valid.instanceId, + endpoint: try XCTUnwrap(URL(string: "http://aiden.example.test/api/aiden/v1")), + serverSpkiSha256: valid.serverSpkiSha256, + secret: valid.secret, + expiresAt: valid.expiresAt + ).validated(at: now)) + XCTAssertThrowsError(try AidenRemoteContractFixture.PairingBootstrap( + protocolVersion: 1, + instanceId: valid.instanceId, + endpoint: valid.endpoint, + serverSpkiSha256: "sha256/not-a-digest", + secret: valid.secret, + expiresAt: valid.expiresAt + ).validated(at: now)) + XCTAssertThrowsError(try AidenRemoteContractFixture.PairingBootstrap( + protocolVersion: 1, + instanceId: valid.instanceId, + endpoint: valid.endpoint, + serverSpkiSha256: valid.serverSpkiSha256, + secret: "too-short", + expiresAt: valid.expiresAt + ).validated(at: now)) + XCTAssertThrowsError(try AidenRemoteContractFixture.PairingBootstrap( + protocolVersion: 1, + instanceId: valid.instanceId, + endpoint: valid.endpoint, + serverSpkiSha256: valid.serverSpkiSha256, + secret: valid.secret, + expiresAt: now + ).validated(at: now)) + XCTAssertThrowsError(try AidenRemoteContractFixture.PairingBootstrap( + protocolVersion: 1, + instanceId: valid.instanceId, + endpoint: valid.endpoint, + serverSpkiSha256: valid.serverSpkiSha256, + secret: valid.secret, + expiresAt: now.addingTimeInterval(301) + ).validated(at: now)) + } + + func testEndpointAuthorityGrammarMatchesDesktopVectors() throws { + // Keep this authority vector byte-for-byte aligned with + // main/services/aiden-remote-protocol.test.ts. + let endpointAuthorityVectors: [(String, Bool)] = [ + ("aiden.example.test", true), + ("localhost", true), + ("aiden-lan.local", true), + ("192.168.1.42", true), + ("192.0.2.1:443", true), + ("aiden.0", false), + ("aiden.123", false), + ("aiden.example.test:1", true), + ("aiden.example.test:65535", true), + ("[::]", true), + ("[::1]", true), + ("[2001:db8::1]:443", true), + ("[::ffff:192.0.2.1]", true), + ("aiden.example.test:0443", false), + ("aiden.example.test:00001", false), + ("aiden.example.test:0", false), + ("aiden.example.test:65536", false), + ("aiden.example.test:abc", false), + ("aiden.example.test:", false), + (":443", false), + ("aiden.example.test:1:2", false), + ("aiden.example.test%2eexample.test", false), + ("aiden.example.test%25", false), + ("aiden.example.test", false), + ("aiden\u{0301}.example.test", false), + ("aiden.example.test\u{0009}", false), + ("aiden.example.test\u{001f}", false), + ("aiden.example.test\u{007f}", false), + ("aiden..example.test", false), + ("-aiden.example.test", false), + ("aiden-.example.test", false), + ("aiden_example.test", false), + ("123", false), + ("192.168.001.1", false), + ("256.1.1.1", false), + ("[fe80::1%25en0]", false), + ("[v1.fe]", false), + ("[::1", false), + ("[::1]x", false), + ("::1", false), + ("[::1]:00001", false), + ("[::1]:65536", false), + ("[2001:db8::1::2]", false), + ("[192.0.2.1::]", false), + ("[::ffff:192.000.2.1]", false), + ("[2001:db8:0:0:0:0:0]", false), + ] + let now = Date(timeIntervalSince1970: 2_000_000_000) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expiry = formatter.string(from: now.addingTimeInterval(60)) + + for (authority, valid) in endpointAuthorityVectors { + let fields: [String: Any] = [ + "protocolVersion": 1, + "instanceId": "instance_fixture", + "endpoint": "https://\(authority)/api/aiden/v1", + "serverSpkiSha256": "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "secret": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "expiresAt": expiry, + ] + let data = try JSONSerialization.data(withJSONObject: fields) + if valid { + let bootstrap = try AidenRemoteJSONDecoder.decodePairingBootstrap(from: data) + XCTAssertNoThrow(try bootstrap.validated(at: now), authority) + } else { + XCTAssertThrowsError( + try AidenRemoteJSONDecoder.decodePairingBootstrap(from: data), + authority + ) + } + } + } + + func testStrictRFC3339DatesRejectPermissivePairingAndEventForms() throws { + let pairingFields: [String: Any] = [ + "protocolVersion": 1, + "instanceId": "instance_fixture", + "endpoint": "https://aiden.example.test/api/aiden/v1", + "serverSpkiSha256": "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "secret": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "expiresAt": "2026-08-18T19:05:00.000Z", + ] + let eventFields: [String: Any] = [ + "protocolVersion": 1, + "streamId": "stream-1", + "sequence": 1, + "timestamp": "2026-08-18T19:01:01.000Z", + "type": "heartbeat", + "terminal": false, + "payload": [:], + ] + let malformedDates = [ + "2026-08-18", + "2026-08-18T19:05:00.000+0000", + "2026-02-30T19:05:00.000Z", + ] + + for date in malformedDates { + var pairing = pairingFields + pairing["expiresAt"] = date + XCTAssertThrowsError( + try AidenRemoteJSONDecoder.decodePairingBootstrap( + from: JSONSerialization.data(withJSONObject: pairing) + ) + ) + + var event = eventFields + event["timestamp"] = date + XCTAssertThrowsError( + try AidenRemoteJSONDecoder.decodeSSEEvent( + from: JSONSerialization.data(withJSONObject: event) + ) + ) + } + } + + func testCanonicalPairingPayloadRequiresExactKindAndTrustShape() throws { + let now = Date(timeIntervalSince1970: 2_000_000_000) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let bootstrap: [String: Any] = [ + "protocolVersion": 1, + "instanceId": "instance_fixture", + "endpoint": "https://aiden.example.test/api/aiden/v1", + "serverSpkiSha256": "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "secret": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "expiresAt": formatter.string(from: now.addingTimeInterval(60)), + ] + let valid: [String: Any] = [ + "kind": "aiden-pairing-v1", + "bootstrap": bootstrap, + "trust": [ + "mode": "private-ca", + "caCertificateDerBase64": caCertificateDER, + ], + ] + let payload = try AidenRemoteJSONDecoder.decodePairingPayload( + from: JSONSerialization.data(withJSONObject: valid) + ) + XCTAssertNoThrow(try payload.validated(at: now)) + XCTAssertEqual(payload.trust.caCertificateDER?.base64EncodedString(), caCertificateDER) + + var wrongKind = valid + wrongKind["kind"] = "future-pairing" + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decodePairingPayload( + from: JSONSerialization.data(withJSONObject: wrongKind) + )) + + var unknownOuterKey = valid + unknownOuterKey["endpoint"] = "https://attacker.invalid" + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decodePairingPayload( + from: JSONSerialization.data(withJSONObject: unknownOuterKey) + )) + + for invalidTrust in [ + ["mode": "system", "caCertificateDerBase64": caCertificateDER], + ["mode": "private-ca"], + ["mode": "private-ca", "caCertificateDerBase64": "not-base64"], + ["mode": "system", "future": "field"], + ] { + var invalid = valid + invalid["trust"] = invalidTrust + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decodePairingPayload( + from: JSONSerialization.data(withJSONObject: invalid) + )) + } + + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decodePairingPayload( + from: Data(repeating: 0x20, count: AidenRemoteProtocol.maxPairingPayloadBytes + 1) + )) { error in + XCTAssertEqual(error as? AidenRemoteContractError, .payloadTooLarge) + } + } + + func testStrictRFC3339DatePreservesFractionAndUTCOffsetInstant() throws { + let utcEvent = try AidenRemoteJSONDecoder.decodeSSEEvent( + from: JSONSerialization.data(withJSONObject: [ + "protocolVersion": 1, + "streamId": "stream-utc", + "sequence": 1, + "timestamp": "2026-08-18T13:31:01.250Z", + "type": "heartbeat", + "terminal": false, + "payload": [:], + ]) + ) + let offsetEvent = try AidenRemoteJSONDecoder.decodeSSEEvent( + from: JSONSerialization.data(withJSONObject: [ + "protocolVersion": 1, + "streamId": "stream-offset", + "sequence": 1, + "timestamp": "2026-08-18T19:01:01.25+05:30", + "type": "heartbeat", + "terminal": false, + "payload": [:], + ]) + ) + XCTAssertEqual(offsetEvent.timestamp, utcEvent.timestamp) + } + + func testErrorEnvelopeRejectsUnknownFieldsCodesAndBounds() throws { + let unknownField = Data(#"{"error":{"code":"internal_error","message":"safe","requestId":"request-1","retryable":false,"unexpected":true}}"#.utf8) + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decode(AidenRemoteErrorEnvelope.self, from: unknownField)) + + let unknownCode = Data(#"{"error":{"code":"future_error","message":"safe","requestId":"request-1","retryable":false}}"#.utf8) + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decode(AidenRemoteErrorEnvelope.self, from: unknownCode)) + + let oversizedRequestID = try JSONSerialization.data(withJSONObject: [ + "error": [ + "code": "internal_error", + "message": "safe", + "requestId": String(repeating: "r", count: 129), + "retryable": false, + ], + ]) + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decode(AidenRemoteErrorEnvelope.self, from: oversizedRequestID)) + + let invalidDetails = try JSONSerialization.data(withJSONObject: [ + "error": [ + "code": "internal_error", + "message": "safe", + "requestId": "request-1", + "retryable": false, + "details": ["retryAfterSeconds": 86_401], + ], + ]) + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decode(AidenRemoteErrorEnvelope.self, from: invalidDetails)) + } + + func testNullMembersAreNotTreatedAsAbsent() throws { + let nullEventPayloads = [ + Data(#"{"protocolVersion":1,"streamId":"stream-1","sequence":1,"timestamp":"2026-08-18T19:00:00Z","type":"heartbeat","terminal":false,"payload":{"text":null}}"#.utf8), + Data(#"{"protocolVersion":1,"streamId":"stream-1","sequence":1,"timestamp":"2026-08-18T19:00:00Z","type":"status","terminal":false,"payload":{"state":null}}"#.utf8), + Data(#"{"protocolVersion":1,"streamId":"stream-1","sequence":1,"timestamp":"2026-08-18T19:00:00Z","type":"snapshot","terminal":false,"payload":{"chatId":"chat-1","turnId":"turn-1","nextSequence":null}}"#.utf8), + Data(#"{"protocolVersion":1,"streamId":"stream-1","sequence":1,"timestamp":"2026-08-18T19:00:00Z","type":"heartbeat","terminal":false,"payload":{"future":null}}"#.utf8), + ] + for event in nullEventPayloads { + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decodeSSEEvent(from: event)) + } + + let nullErrorMembers = [ + Data(#"{"error":{"code":"internal_error","message":"safe","requestId":"request-1","retryable":false,"details":null}}"#.utf8), + Data(#"{"error":{"code":"internal_error","message":"safe","requestId":"request-1","retryable":false,"details":{"retryAfterSeconds":null}}}"#.utf8), + Data(#"{"error":{"code":"internal_error","message":"safe","requestId":"request-1","retryable":false,"details":{"currentRevision":null}}}"#.utf8), + Data(#"{"error":{"code":"internal_error","message":null,"requestId":"request-1","retryable":false}}"#.utf8), + Data(#"{"error":{"code":"internal_error","message":"safe","requestId":null,"retryable":false}}"#.utf8), + Data(#"{"error":{"code":"internal_error","message":"safe","requestId":"request-1","retryable":null}}"#.utf8), + ] + for envelope in nullErrorMembers { + XCTAssertThrowsError( + try AidenRemoteJSONDecoder.decode(AidenRemoteErrorEnvelope.self, from: envelope) + ) + } + } + + func testUnknownCapabilitiesAndEventsRemainForwardCompatibleButErrorsAreStrict() throws { + let capability = try AidenRemoteJSONDecoder.decode( + AidenRemoteCapability.self, + from: Data("\"future:read\"".utf8) + ) + let event = try AidenRemoteJSONDecoder.decode( + AidenRemoteEventType.self, + from: Data("\"future_event\"".utf8) + ) + XCTAssertEqual(capability.rawValue, "future:read") + XCTAssertEqual(event.rawValue, "future_event") + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decode( + AidenRemoteErrorCode.self, + from: Data("\"future_error\"".utf8) + )) + + let futureNonterminal = Data(#"{"protocolVersion":1,"streamId":"stream-1","sequence":1,"timestamp":"2026-08-18T19:00:00Z","type":"future_progress","terminal":false,"payload":{"future":"ignored"}}"#.utf8) + let ignored = try AidenRemoteJSONDecoder.decodeSSEEvent(from: futureNonterminal) + XCTAssertFalse(ignored.shouldApply) + XCTAssertNil(ignored.payload) + + let futureNonterminalWithoutPayload = Data(#"{"protocolVersion":1,"streamId":"stream-1","sequence":1,"timestamp":"2026-08-18T19:00:00Z","type":"future_progress","terminal":false}"#.utf8) + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decodeSSEEvent(from: futureNonterminalWithoutPayload)) + + let unsafeUnknown = Data(#"{"protocolVersion":1,"streamId":"stream-1","sequence":1,"timestamp":"2026-08-18T19:00:00Z","type":"future_progress","terminal":false,"payload":{"absolutePath":"/private/secret"}}"#.utf8) + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decodeSSEEvent(from: unsafeUnknown)) + + var oversizedUnknownPayload: [String: Any] = [:] + for index in 0...32 { + oversizedUnknownPayload["future_\(index)"] = true + } + let oversizedUnknown = try JSONSerialization.data(withJSONObject: [ + "protocolVersion": 1, + "streamId": "stream-1", + "sequence": 1, + "timestamp": "2026-08-18T19:00:00Z", + "type": "future_progress", + "terminal": false, + "payload": oversizedUnknownPayload, + ]) + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decodeSSEEvent(from: oversizedUnknown)) + + let futureTerminal = Data(#"{"protocolVersion":1,"streamId":"stream-1","sequence":1,"timestamp":"2026-08-18T19:00:00Z","type":"future_terminal","terminal":true,"payload":{}}"#.utf8) + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decodeSSEEvent(from: futureTerminal)) + + let unknownErrorCode = Data(#"{"protocolVersion":1,"streamId":"stream-1","sequence":1,"timestamp":"2026-08-18T19:00:00Z","type":"error","terminal":true,"payload":{"code":"future_error","message":"safe"}}"#.utf8) + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decodeSSEEvent(from: unknownErrorCode)) + + let oversizedErrorMessage = try JSONSerialization.data(withJSONObject: [ + "protocolVersion": 1, + "streamId": "stream-1", + "sequence": 1, + "timestamp": "2026-08-18T19:00:00Z", + "type": "error", + "terminal": true, + "payload": [ + "code": "internal_error", + "message": String(repeating: "x", count: 2_001), + ], + ]) + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decodeSSEEvent(from: oversizedErrorMessage)) + + let unsafeKnown = Data(#"{"protocolVersion":1,"streamId":"stream-1","sequence":1,"timestamp":"2026-08-18T19:00:00Z","type":"heartbeat","terminal":false,"payload":{"absolutePath":"/private/secret"}}"#.utf8) + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decodeSSEEvent(from: unsafeKnown)) + } + + func testPinnedSessionRedirectPolicyRejectsDowngradeAndAuthorityChanges() throws { + let delegate = AidenPinnedServerSessionDelegate( + expectedHost: "aiden.example.test", + expectedPort: 7443, + expectedFingerprint: "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + trustPolicy: .system + ) + XCTAssertTrue(delegate.allowsRedirect(to: try XCTUnwrap(URL(string: "https://aiden.example.test:7443/api/aiden/v1/health")))) + XCTAssertFalse(delegate.allowsRedirect(to: try XCTUnwrap(URL(string: "http://aiden.example.test:7443/api/aiden/v1/health")))) + XCTAssertFalse(delegate.allowsRedirect(to: try XCTUnwrap(URL(string: "https://other.example.test:7443/api/aiden/v1/health")))) + XCTAssertFalse(delegate.allowsRedirect(to: try XCTUnwrap(URL(string: "https://aiden.example.test:8443/api/aiden/v1/health")))) + + let defaultPortDelegate = AidenPinnedServerSessionDelegate( + expectedHost: "aiden.example.test", + expectedFingerprint: "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + trustPolicy: .system + ) + XCTAssertTrue(defaultPortDelegate.allowsRedirect(to: try XCTUnwrap(URL(string: "https://aiden.example.test/api/aiden/v1/health")))) + XCTAssertTrue(defaultPortDelegate.allowsRedirect(to: try XCTUnwrap(URL(string: "https://aiden.example.test:443/api/aiden/v1/health")))) + XCTAssertFalse(defaultPortDelegate.allowsRedirect(to: try XCTUnwrap(URL(string: "https://aiden.example.test:7443/api/aiden/v1/health")))) + XCTAssertTrue(delegate.responds(to: #selector(URLSessionTaskDelegate.urlSession(_:task:willPerformHTTPRedirection:newRequest:completionHandler:)))) + } + + func testStreamEnvelopeRejectsInvalidIdentityAndSequence() throws { + let invalidEvents = [ + #"{"protocolVersion":2,"streamId":"stream-1","sequence":1,"timestamp":"2026-08-18T19:00:00Z","type":"heartbeat","terminal":false,"payload":{}}"#, + #"{"protocolVersion":1,"streamId":"","sequence":1,"timestamp":"2026-08-18T19:00:00Z","type":"heartbeat","terminal":false,"payload":{}}"#, + #"{"protocolVersion":1,"streamId":"stream-1","sequence":0,"timestamp":"2026-08-18T19:00:00Z","type":"heartbeat","terminal":false,"payload":{}}"#, + #"{"protocolVersion":1,"streamId":"stream-1","sequence":1,"timestamp":"2026-08-18T19:00:00Z","type":"status","terminal":false,"payload":{"state":"done"}}"#, + ] + for event in invalidEvents { + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decodeSSEEvent(from: Data(event.utf8))) + } + + let reconciling = #"{"protocolVersion":1,"streamId":"stream-1","sequence":1,"timestamp":"2026-08-18T19:00:00Z","type":"status","terminal":false,"payload":{"state":"reconciling"}}"# + XCTAssertNoThrow(try AidenRemoteJSONDecoder.decodeSSEEvent(from: Data(reconciling.utf8))) + + let additiveEnvelope = #"{"protocolVersion":1,"streamId":"stream-1","sequence":1,"timestamp":"2026-08-18T19:00:00Z","type":"heartbeat","terminal":false,"payload":{},"futureEnvelopeMetadata":{"ignored":true}}"# + XCTAssertNoThrow(try AidenRemoteJSONDecoder.decodeSSEEvent(from: Data(additiveEnvelope.utf8))) + + var additiveEnvelopeFields: [String: Any] = [ + "protocolVersion": 1, + "streamId": "stream-additive", + "sequence": 1, + "timestamp": "2026-08-18T19:00:00Z", + "type": "heartbeat", + "terminal": false, + "payload": [:], + ] + for index in 0..<33 { + additiveEnvelopeFields["futureEnvelope_\(index)"] = true + } + XCTAssertNoThrow( + try AidenRemoteJSONDecoder.decodeSSEEvent( + from: JSONSerialization.data(withJSONObject: additiveEnvelopeFields) + ) + ) + + var wideNestedMetadata: [String: Any] = [:] + for index in 0..<33 { + wideNestedMetadata["futureNested_\(index)"] = true + } + var nestedMetadataEvent = additiveEnvelopeFields + nestedMetadataEvent["futureMetadata"] = wideNestedMetadata + XCTAssertNoThrow( + try AidenRemoteJSONDecoder.decodeSSEEvent( + from: JSONSerialization.data(withJSONObject: nestedMetadataEvent) + ) + ) + + func nestedMetadata(depth: Int) -> [String: Any] { + var value: [String: Any] = ["leaf": true] + for index in 0.. (HTTPURLResponse, Data) { + (HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, Data(json.utf8)) + } + + private static func body(_ request: URLRequest) throws -> [String: Any] { + let data: Data + if let body = request.httpBody { + data = body + } else { + let stream = try XCTUnwrap(request.httpBodyStream) + stream.open() + defer { stream.close() } + var received = Data() + var buffer = [UInt8](repeating: 0, count: 4_096) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count >= 0 else { throw stream.streamError ?? URLError(.cannotDecodeRawData) } + if count == 0 { break } + received.append(buffer, count: count) + } + data = received + } + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } +} + +private final class ScheduledRequestRecorder: @unchecked Sendable { + private let lock = NSLock() + private var stored: [URLRequest] = [] + func record(_ request: URLRequest) { lock.lock(); stored.append(request); lock.unlock() } + var requests: [URLRequest] { lock.lock(); defer { lock.unlock() }; return stored } +} + +private final class ScheduledMockURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + do { + let (response, data) = try Self.handler?(request) ?? { throw URLError(.badServerResponse) }() + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { client?.urlProtocol(self, didFailWithError: error) } + } + override func stopLoading() {} +} diff --git a/ios/AidenOnTheGoTests/AidenWorkspaceEnvironmentTests.swift b/ios/AidenOnTheGoTests/AidenWorkspaceEnvironmentTests.swift new file mode 100644 index 00000000..3882e673 --- /dev/null +++ b/ios/AidenOnTheGoTests/AidenWorkspaceEnvironmentTests.swift @@ -0,0 +1,305 @@ +import CryptoKit +import Foundation +import XCTest +@testable import AidenOnTheGo + +final class AidenWorkspaceEnvironmentTests: XCTestCase { + override func tearDown() { + EnvironmentMockURLProtocol.handler = nil + super.tearDown() + } + + func testFileAndGitDTOsRejectUnsafeOrUnboundServerData() throws { + let fileID = "file_\(String(repeating: "f", count: 43))" + let index = try AidenRemoteJSONDecoder.decode( + AidenWorkspaceFileIndex.self, + from: Data(""" + {"snapshotId":"files-1","entries":[{"id":"\(fileID)","displayPath":"Sources/App.swift","name":"App.swift","kind":"file","size":12,"language":"Swift"}],"truncated":false,"maxEntries":4000,"maxDepth":20} + """.utf8) + ) + XCTAssertNoThrow(try AidenWorkspaceEnvironmentValidation.validated(index)) + + let escaped = AidenWorkspaceFileIndex( + snapshotId: "files-2", + entries: [.init(id: fileID, displayPath: "../Secret", name: "Secret", kind: .file, size: nil, language: nil)], + truncated: false, + maxEntries: 4_000, + maxDepth: 20 + ) + XCTAssertThrowsError(try AidenWorkspaceEnvironmentValidation.validated(escaped)) + + XCTAssertThrowsError(try AidenRemoteJSONDecoder.decode( + AidenWorkspaceFileIndex.self, + from: Data(""" + {"snapshotId":"files-1","entries":[],"truncated":false,"maxEntries":4000,"maxDepth":20,"repositoryPath":"/private/project"} + """.utf8) + )) + } + + func testEnvironmentCacheIsInstallationAndWorkspaceScoped() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let cache = AidenWorkspaceEnvironmentCache(directory: directory) + let fileID = "file_\(String(repeating: "f", count: 43))" + let index = AidenWorkspaceFileIndex( + snapshotId: "files-1", + entries: [.init(id: fileID, displayPath: "App.swift", name: "App.swift", kind: .file, size: 14, language: "Swift")], + truncated: false, + maxEntries: 4_000, + maxDepth: 20 + ) + let document = AidenWorkspaceFileDocument( + id: fileID, + displayPath: "App.swift", + content: "let value = 1\n", + version: "version-1", + truncated: false, + warning: nil + ) + try await cache.store(index: index, instanceId: "instance-1", workspaceId: "workspace-1") + try await cache.store(document: document, instanceId: "instance-1", workspaceId: "workspace-1") + try await cache.store(index: index, instanceId: "instance-2", workspaceId: "workspace-1") + let loaded = await cache.load(instanceId: "instance-1", workspaceId: "workspace-1") + XCTAssertEqual(loaded?.index, index) + XCTAssertEqual(loaded?.documents[fileID], document) + let otherWorkspace = await cache.load(instanceId: "instance-1", workspaceId: "workspace-2") + let otherInstallation = await cache.load(instanceId: "instance-2", workspaceId: "workspace-1") + XCTAssertNil(otherWorkspace) + XCTAssertEqual(otherInstallation?.index, index) + + let legacySnapshot = AidenWorkspaceEnvironmentCache.Snapshot( + index: index, + documents: [:], + updatedAt: Date(timeIntervalSince1970: 1_000) + ) + let legacyData = try JSONEncoder().encode(legacySnapshot) + func legacyURL(instanceId: String, workspaceId: String) -> URL { + let digest = SHA256.hash(data: Data("\(instanceId)\u{0}\(workspaceId)".utf8)) + .map { String(format: "%02x", $0) } + .joined() + return directory.appending(path: "\(digest).json") + } + let legacyA = legacyURL(instanceId: "instance-1", workspaceId: "legacy-a") + let legacyB = legacyURL(instanceId: "instance-2", workspaceId: "legacy-b") + try legacyData.write(to: legacyA, options: .atomic) + try legacyData.write(to: legacyB, options: .atomic) + + await cache.purge(instanceId: "instance-1", knownWorkspaceIds: ["legacy-a"]) + let purged = await cache.load(instanceId: "instance-1", workspaceId: "workspace-1") + let retained = await cache.load(instanceId: "instance-2", workspaceId: "workspace-1") + XCTAssertNil(purged) + XCTAssertEqual(retained?.index, index) + XCTAssertFalse(FileManager.default.fileExists(atPath: legacyA.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: legacyB.path)) + } + + func testClientUsesOpaqueFileRoutesAndConfirmedGitMutationHeaders() async throws { + let fileID = "file_\(String(repeating: "f", count: 43))" + let snapshotID = "snap_\(String(repeating: "s", count: 43))" + let recorder = EnvironmentRequestRecorder() + EnvironmentMockURLProtocol.handler = { request in + recorder.record(request) + let path = request.url?.path ?? "" + switch (request.httpMethod, path) { + case ("GET", "/api/aiden/v1/workspaces/workspace-1/files"): + return Self.response(request, 200, """ + {"snapshotId":"files-1","entries":[{"id":"\(fileID)","displayPath":"App.swift","name":"App.swift","kind":"file","size":14}],"truncated":false,"maxEntries":4000,"maxDepth":20} + """) + case ("GET", "/api/aiden/v1/workspaces/workspace-1/files/\(fileID)"): + return Self.response(request, 200, """ + {"id":"\(fileID)","displayPath":"App.swift","content":"let value = 1\\n","version":"version-1","truncated":false} + """) + case ("PUT", "/api/aiden/v1/workspaces/workspace-1/files/\(fileID)"): + return Self.response(request, 200, """ + {"id":"\(fileID)","displayPath":"App.swift","content":"let value = 2\\n","version":"version-2","truncated":false} + """) + case ("GET", "/api/aiden/v1/workspaces/workspace-1/git/review"): + return Self.response(request, 200, """ + {"operationId":"op_review","status":"snapshot","snapshotId":"\(snapshotID)","result":{"kind":"review","branch":"main","uncommitted":1,"files":[{"id":"\(fileID)","displayPath":"App.swift","status":"modified","staged":false,"additions":1,"deletions":1}]}} + """) + case ("POST", "/api/aiden/v1/workspaces/workspace-1/git/worktrees"): + return Self.response(request, 202, """ + {"operationId":"op_create","status":"succeeded","result":{"kind":"mutation","message":"Created managed worktree.","branch":"feature/mobile","workspaceId":"workspace-2"}} + """) + case ("DELETE", "/api/aiden/v1/workspaces/workspace-2/git/managed-worktree"): + return Self.response(request, 202, """ + {"operationId":"op_delete","status":"succeeded","result":{"kind":"mutation","message":"Removed managed worktree.","workspaceId":"workspace-2"}} + """) + default: + throw URLError(.unsupportedURL) + } + } + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [EnvironmentMockURLProtocol.self] + let client = AidenRemoteClient( + endpoint: URL(string: "https://aiden.test/api/aiden/v1")!, + credential: "credential", + session: URLSession(configuration: configuration) + ) + + _ = try await client.workspaceFiles(workspaceId: "workspace-1") + let document = try await client.workspaceFile(workspaceId: "workspace-1", fileId: fileID) + _ = try await client.writeWorkspaceFile( + workspaceId: "workspace-1", + fileId: fileID, + content: "let value = 2\n", + expectedVersion: document.version + ) + _ = try await client.gitReview(workspaceId: "workspace-1") + _ = try await client.createGitWorktree( + workspaceId: "workspace-1", + branch: "feature/mobile", + name: "Mobile" + ) + _ = try await client.deleteManagedGitWorktree(workspaceId: "workspace-2", revision: "revision-2") + + let requests = recorder.requests + let write = try XCTUnwrap(requests.first { $0.httpMethod == "PUT" }) + XCTAssertEqual(try Self.jsonBody(write)["expectedVersion"] as? String, "version-1") + let create = try XCTUnwrap(requests.first { $0.httpMethod == "POST" && $0.url?.path.hasSuffix("/git/worktrees") == true }) + XCTAssertNotNil(create.value(forHTTPHeaderField: "Idempotency-Key")) + XCTAssertEqual(try Self.jsonBody(create)["confirmedForeground"] as? Bool, true) + let remove = try XCTUnwrap(requests.first { $0.httpMethod == "DELETE" }) + XCTAssertEqual(remove.value(forHTTPHeaderField: "If-Match"), "revision-2") + XCTAssertNotNil(remove.value(forHTTPHeaderField: "Idempotency-Key")) + XCTAssertEqual(try Self.jsonBody(remove)["confirmedForeground"] as? Bool, true) + XCTAssertFalse(requests.contains { ($0.url?.absoluteString ?? "").contains("private") }) + } + + @MainActor + func testGitMutationReconnectReusesOriginalIdempotencyKey() async throws { + let snapshotID = "snap_\(String(repeating: "s", count: 43))" + let recorder = EnvironmentRequestRecorder() + let attempts = EnvironmentAttemptCounter() + EnvironmentMockURLProtocol.handler = { request in + recorder.record(request) + let path = request.url?.path ?? "" + if request.httpMethod == "POST", path.hasSuffix("/git/commit") { + if attempts.increment() == 1 { throw URLError(.networkConnectionLost) } + return Self.response(request, 202, """ + {"operationId":"op_commit","status":"succeeded","result":{"kind":"mutation","message":"Committed reviewed changes.","branch":"main","commitId":"abc"}} + """) + } + if request.httpMethod == "GET", path.hasSuffix("/git/review") { + return Self.response(request, 200, """ + {"operationId":"op_review","status":"snapshot","snapshotId":"\(snapshotID)","result":{"kind":"review","branch":"main","uncommitted":0,"files":[]}} + """) + } + if request.httpMethod == "GET", path.hasSuffix("/git/branches") { + return Self.response(request, 200, """ + {"operationId":"op_branches","status":"snapshot","snapshotId":"\(snapshotID)","result":{"kind":"branches","current":"main","branches":["main"]}} + """) + } + if request.httpMethod == "GET", path.hasSuffix("/git/worktrees") { + return Self.response(request, 200, """ + {"operationId":"op_worktrees","status":"snapshot","result":{"kind":"worktrees","worktrees":[]}} + """) + } + throw URLError(.unsupportedURL) + } + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [EnvironmentMockURLProtocol.self] + let client = AidenRemoteClient( + endpoint: URL(string: "https://aiden.test/api/aiden/v1")!, + credential: "credential", + session: URLSession(configuration: configuration) + ) + let model = AidenWorkspaceGitModel() + model.reviewSnapshotId = snapshotID + await model.commit( + client: client, + workspaceId: "workspace-1", + message: "Update", + stagedOnly: false, + isCurrent: { true } + ) + XCTAssertTrue(model.canRetryPendingMutation) + await model.retryPendingMutation( + client: client, + workspaceId: "workspace-1", + isCurrent: { true } + ) + XCTAssertFalse(model.canRetryPendingMutation) + XCTAssertEqual(model.lastMessage, "Committed reviewed changes.") + + let keys = recorder.requests + .filter { $0.httpMethod == "POST" && $0.url?.path.hasSuffix("/git/commit") == true } + .compactMap { $0.value(forHTTPHeaderField: "Idempotency-Key") } + XCTAssertEqual(keys.count, 2) + XCTAssertEqual(Set(keys).count, 1) + } + + private static func response(_ request: URLRequest, _ status: Int, _ json: String) -> (HTTPURLResponse, Data) { + (HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!, Data(json.utf8)) + } + + private static func jsonBody(_ request: URLRequest) throws -> [String: Any] { + let data: Data + if let body = request.httpBody { + data = body + } else { + let stream = try XCTUnwrap(request.httpBodyStream) + stream.open() + defer { stream.close() } + var value = Data() + var buffer = [UInt8](repeating: 0, count: 4_096) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count <= 0 { break } + value.append(buffer, count: count) + } + data = value + } + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } +} + +private final class EnvironmentRequestRecorder: @unchecked Sendable { + private let lock = NSLock() + private var stored: [URLRequest] = [] + + func record(_ request: URLRequest) { + lock.lock() + stored.append(request) + lock.unlock() + } + + var requests: [URLRequest] { + lock.lock() + defer { lock.unlock() } + return stored + } +} + +private final class EnvironmentAttemptCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func increment() -> Int { + lock.lock() + defer { lock.unlock() } + value += 1 + return value + } +} + +private final class EnvironmentMockURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + do { + let (response, data) = try Self.handler?(request) ?? { throw URLError(.badServerResponse) }() + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md new file mode 100644 index 00000000..34ae3a04 --- /dev/null +++ b/ios/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +## Unreleased + +- Converted the imported SwiftUI foundation into the Aiden On The Go product identity and signed application shell. +- Added canonical Aiden Remote pairing, pinned transport, multiple installations, Keychain credential isolation, workspace CRUD, and approved-root browsing foundations. +- Removed the imported Hermes WebUI product surface and compatibility configuration from the build. +- Completed the Hermex-informed Aiden shell with managed-scratch New Agent creation, reliable connected-state navigation handoff, explicit handoff-chat dismissal, and Aiden-logo starting/thinking states across chat and Live Activity UI. +- Rendered completed and streaming assistant replies with bounded block Markdown, full transcript width, and content-sized literal user prompts. +- Matched Hermex's external Photos/Files picker presentation, preserved uploaded attachment references through optimistic composer clearing, and removed the root Markdown sizing constraint that could crop a reply's first glyph. +- Replaced the New Agent confirmation sheet with a CustomTB-inspired anchored glass popover for existing, reusable, and managed-scratch workspaces, and upgraded the home search/settings capsule to Hermex-style native interactive Liquid Glass with accessible fallbacks. +- Matched Aiden Agent's compact approval hierarchy with a shield badge, smaller title/helper/summary typography, and Liquid Glass Deny and Allow Once actions that retain accessible tap targets. +- Added device-only workspace archiving with a first-use privacy explanation, a searchable archived directory, Hermex-style long-press and swipe actions, hidden archived chats/App Intent choices, generation-bound server-authoritative rename, and canonically reconciled safe removal from Aiden Agent without deleting Mac files or chats. +- Rebuilt Mac Usage as an Aiden-themed aggregate dashboard with real daily/model data, streaks, a 30-day token heatmap, token/activity breakdowns, and top models; replaced the home list's transparent tail row with a palette-backed safe-area inset to remove the bottom white band. +- Mirrored Aiden Agent's complete provider-logo catalog in the iOS asset catalog, added provider identity to Usage, chat, and scheduled-task model controls, and added Hermex-style raw-Markdown Copy actions to completed and streaming assistant replies. +- Repaired approval visibility across Mac and iOS with an authenticated resumable approval snapshot, exact stream-identity fencing, deny-only mobile handling for privileged operations, stale-refresh protection, deterministic cancellation, and bounded crash-safe journals. diff --git a/ios/CODE_OF_CONDUCT.md b/ios/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..1ca7786b --- /dev/null +++ b/ios/CODE_OF_CONDUCT.md @@ -0,0 +1,131 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances + of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported privately to the owner of the Aiden Agent repository. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/ios/CONTEXT.md b/ios/CONTEXT.md new file mode 100644 index 00000000..1fd0bf78 --- /dev/null +++ b/ios/CONTEXT.md @@ -0,0 +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. +- **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. +- **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/CONTRACT_TESTS.md b/ios/CONTRACT_TESTS.md new file mode 100644 index 00000000..d1128d45 --- /dev/null +++ b/ios/CONTRACT_TESTS.md @@ -0,0 +1,5 @@ +# Aiden Remote contract tests + +The normative API is [`../protocol/aiden-remote/v1/openapi.json`](../protocol/aiden-remote/v1/openapi.json). Cross-platform fixtures live under `../protocol/aiden-remote/v1/fixtures/`. + +Desktop and Swift tests must agree on protocol versioning, pairing, error envelopes, bounds, opaque handles, revision/idempotency preconditions, SSE sequence and terminal behavior, and forbidden private fields. A passing decoder test is not permission to add an undocumented endpoint or payload. diff --git a/ios/CONTRIBUTING.md b/ios/CONTRIBUTING.md new file mode 100644 index 00000000..b20e90cb --- /dev/null +++ b/ios/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# Contributing to Aiden On The Go + +Follow [`AGENTS.md`](AGENTS.md), the phase plan, the normative remote API, and the repository-wide test requirements. Preserve the imported MIT license and bundled third-party notices. + +Do not commit personal signing overrides. Put local identity overrides in `Config/Local.xcconfig`, which is ignored. Do not add a third-party dependency or a new remote endpoint without explicit approval and corresponding contract coverage. + +Every behavior change needs focused XCTest coverage and physical-device verification proportional to risk. Never use a simulator as a substitute for signed Keychain, Local Network, Bonjour, or production trust evidence. diff --git a/ios/Config/Shared.xcconfig b/ios/Config/Shared.xcconfig new file mode 100644 index 00000000..fe352b8a --- /dev/null +++ b/ios/Config/Shared.xcconfig @@ -0,0 +1,17 @@ +// Shared app-identity and signing defaults, attached to the project as the +// base configuration for both build configurations (Debug and Release). +// +// Contributors: never edit this file or project.pbxproj to sign with your own +// Apple team. Create Config/Local.xcconfig (gitignored) instead — see +// CONTRIBUTING.md ("Code signing for contributors"). + +DEVELOPMENT_TEAM = 5WP229CBB8 +APP_IDENTIFIER_SUFFIX = +APP_BUNDLE_IDENTIFIER = sbtbiswas.AidenOnTheGo$(APP_IDENTIFIER_SUFFIX) +APP_GROUP_IDENTIFIER = group.sbtbiswas.AidenOnTheGo$(APP_IDENTIFIER_SUFFIX) +KEYCHAIN_SERVICE = sbtbiswas.AidenOnTheGo.pairing$(APP_IDENTIFIER_SUFFIX) +APP_URL_SCHEME = aiden-otg$(APP_URL_SCHEME_SUFFIX) + +// Optional per-machine overrides. This include must stay last: later +// assignments win, so Local.xcconfig values override the defaults above. +#include? "Local.xcconfig" diff --git a/ios/DEVELOPMENT.md b/ios/DEVELOPMENT.md new file mode 100644 index 00000000..c9a6c7c1 --- /dev/null +++ b/ios/DEVELOPMENT.md @@ -0,0 +1,9 @@ +# iOS development + +Requirements: current Xcode, iOS 18 or newer, and access to this repository's configured Apple development team for signed hardware runs. + +Build and test the `AidenOnTheGo` scheme from `AidenOnTheGo.xcodeproj`. Use an explicit physical-device destination for acceptance evidence. Do not place credentials, pairing payloads, LAN addresses, certificates, or generated test-run files in source control. + +The mobile client implements only `/api/aiden/v1`. When a wire shape changes, update the OpenAPI contract, shared fixture, desktop parser/tests, Swift parser/tests, and protocol documentation together. + +Per-phase evidence belongs under `../docs/testing/aiden-on-the-go/`. See [`AGENTS.md`](AGENTS.md) for the working agreement and safety rules. diff --git a/ios/LICENSE b/ios/LICENSE new file mode 100644 index 00000000..48b4aa2f --- /dev/null +++ b/ios/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sambit Biswas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ios/PROJECT_INTENT.md b/ios/PROJECT_INTENT.md new file mode 100644 index 00000000..11a7f5f6 --- /dev/null +++ b/ios/PROJECT_INTENT.md @@ -0,0 +1,13 @@ +# 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. + +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. +- Keep credentials in Keychain and bounded offline presentation state on device. +- Use native SwiftUI controls and adaptive Apple navigation. + +The normative scope is in [`PROJECT_SPEC.md`](PROJECT_SPEC.md). diff --git a/ios/PROJECT_SPEC.md b/ios/PROJECT_SPEC.md new file mode 100644 index 00000000..67cca381 --- /dev/null +++ b/ios/PROJECT_SPEC.md @@ -0,0 +1,122 @@ +# Aiden On The Go — iOS/iPadOS Project Specification + +Status: Approved implementation specification +Protocol: Aiden Remote API v1 (`/api/aiden/v1`) +Product: Aiden On The Go +Platforms: iPhone and iPad, iOS/iPadOS 18+ +Authority: `docs/plans/aiden-on-the-go-plan.md` and `docs/aiden-remote-api-v1.md` + +## 1. Product contract + +Aiden On The Go is the native mobile control plane for a user-owned Aiden Agent desktop installation. Aiden Agent remains the execution and persistence authority. The phone connects over an explicitly enabled local-network or Tailscale transport, pairs to one installation, and authenticates every command and event stream with a revocable per-device credential. + +The app is not a Hermes WebUI client, a hosted service, a web view, or an agent runtime. Imported Hermex code is an implementation foundation only. New code must use Aiden Remote API v1 DTOs and events; do not add or preserve Hermes endpoints as a compatibility layer. + +## 2. Confirmed scope + +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 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. +- Aiden scheduled-task list/create/edit/remove/pause/resume/run-now/preview/history/settings. +- Cache-only App Intents, app-driven Live Activities, on-device dictation, and local read-aloud. +- Offline read-only display of previously fetched data. Mutations are disabled while disconnected. + +Remove Kanban, Hermes projects/profiles/personalities, Skills/Memory/Insights panels, Cloudflare-specific onboarding, server TTS/transcription, voice-note upload, terminal, Computer Use, and every control without an Aiden service contract. Share Extension and cloud push remain deferred. + +## 3. Security invariants + +- Remote Access is off by default and has no listener until enabled on the Mac. +- 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. +- 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. +- Workspace selection consumption and workspace creation are atomic and idempotent. Filesystem identity and canonical root membership are revalidated immediately before mutation. +- Remote turns honor the workspace's saved `full`, `ask`, or `none` permission. The transport cannot mint Assistant/unattended modes or enable Computer Use. +- A dropped TCP/SSE connection does not resend a prompt or cancel server-owned work. The client reconciles by stable turn, stream, operation, and run IDs. + +## 4. Navigation and interaction + +Root destinations: + +- Chats +- Workspaces +- Scheduled Tasks +- Settings / paired installations + +Files and Git are workspace-scoped destinations, not global tabs. iPhone uses native navigation stacks. iPad uses `NavigationSplitView` and must remain useful in split view, rotation, and Stage Manager sizes. + +The conversation toolbar has a top-right ellipsis. For a workspace-backed chat it opens Workspace Settings and workspace-scoped destinations. Workspace Settings contains name, permission, folder/worktree display state, Files, Git, and management links. The composer contains only message/on-device voice input, attachments, model/thinking controls, send, and stop. + +Use retained Hermex native SwiftUI components where their behavior maps 1:1. Do not invent custom interaction systems for branding. Apply Aiden semantic tokens and native menus, lists, forms, sheets, alerts, toolbars, and split navigation. + +## 5. Protocol source of truth + +Normative contract documents and cross-platform fixtures live at repository root: + +- `docs/aiden-remote-api-v1.md` +- `protocol/aiden-remote/v1/openapi.json` +- `protocol/aiden-remote/v1/fixtures/contract.json` + +TypeScript and Swift tests must decode the same checked-in fixtures. Codable models are tolerant of unknown response fields but strict about required identity, ownership, sequence, and mutation-precondition fields. Unknown SSE event types are ignored and logged without including payload secrets; unknown terminal semantics fail closed and trigger snapshot reconciliation. + +## 6. Streaming rules + +- A turn starts through one atomic command and returns `turnId`, `streamId`, accepted state, and the canonical user message. +- Every SSE event has protocol version, stream ID, monotonically increasing sequence, timestamp, type, and typed payload. +- Initial events: `snapshot`, `status`, `text_delta`, `reasoning_delta`, `tool_started`, `tool_finished`, `timeline`, `approval_required`, `done`, `error`, `cancelled`, and `heartbeat`. +- Reconnect sends `Last-Event-ID` or `after`; duplicate sequences are ignored. +- The phone never retries turn creation solely because its stream disconnected. +- Approval IDs are device/stream bound and responses are idempotent. Only `allow` and `deny` are supported initially. +- Provider failure, cancellation, interruption, completion, and revocation are explicit terminal states. + +## 7. Apple identity + +Derived from the owner's Contact Sheet Generator project: + +- Signing style: Automatic +- Apple team: `5WP229CBB8` +- Main bundle: `sbtbiswas.AidenOnTheGo` +- Unit tests: `sbtbiswas.AidenOnTheGoTests` +- UI tests: `sbtbiswas.AidenOnTheGoUITests` +- Live Activity widget: `sbtbiswas.AidenOnTheGo.LiveActivityWidget` +- App Group: `group.sbtbiswas.AidenOnTheGo` +- Keychain service: `sbtbiswas.AidenOnTheGo.pairing` +- URL scheme: `aiden-otg` +- App Store SKU: `aiden-on-the-go-ios` +- Device families: iPhone and iPad + +The installed Apple Development identity currently belongs to team `7EK65FX44E`. Do not claim release/signing readiness until a physical-device build provisions the identifiers above under team `5WP229CBB8`. + +## 8. App Intents, Live Activities, and voice + +- App Intents use current open/navigation protocols and App Group-cached installation/workspace IDs only. The intent process performs no network or Keychain access and never sends a prompt or mutation. +- Live Activity state is under 4 KB and excludes response text by default, paths, tool arguments, approval details, provider errors, and credentials. With no push relay, it shows honest last-known/stale state while the app is terminated and reconciles only when the authenticated app next runs. +- Voice is on-device dictation into an editable draft plus optional on-device read-aloud. Remove server STT, audio upload, voice-note attachments, and hold-to-record. If on-device recognition is unavailable, fall back to text rather than uploading audio. + +## 9. Testing gates + +Every phase adds or updates tests before review. Before a phase can advance: + +1. Its focused TypeScript/XCTest suites pass. +2. Desktop type-check and applicable full tests pass. +3. Signed physical-device build/launch passes for iOS behavior changes; simulator use remains excluded by owner direction. +4. Physical-device verification runs when the phase acceptance explicitly requires hardware, LAN, microphone, signing, or ActivityKit behavior. +5. A direct source-and-test audit is completed without subagent/reviewer delegation while the owner's no-subagents direction remains active. +6. Every P0 and P1 finding is fixed and the affected tests are rerun. + +Phase acceptance evidence belongs in `docs/testing/aiden-on-the-go/`. Evidence must distinguish automated proof, physical-device proof, and anything not yet verified. + +## 10. Delivery order + +Follow `docs/plans/aiden-on-the-go-plan.md` phases 0 through 12 in order. Do not expose production chat/workspace endpoints during Phase 0. Do not advance merely because code compiles: satisfy the phase acceptance gate and record evidence first. + +## 11. Attribution + +Preserve the imported Hermex upstream MIT license, copyright, and third-party notices while removing Hermes/Hermex product identity. New Aiden protocol and product documentation is owned by this repository. diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 00000000..7b578224 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,24 @@ +# 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. + +The product and protocol sources of truth are: + +- [`PROJECT_SPEC.md`](PROJECT_SPEC.md) +- [`../docs/plans/aiden-on-the-go-plan.md`](../docs/plans/aiden-on-the-go-plan.md) +- [`../docs/aiden-remote-api-v1.md`](../docs/aiden-remote-api-v1.md) +- [`../protocol/aiden-remote/v1/openapi.json`](../protocol/aiden-remote/v1/openapi.json) + +Open `AidenOnTheGo.xcodeproj` and use the `AidenOnTheGo` scheme. Hardware verification is performed with terminal-based `xcodebuild` and `xcrun` commands against an explicitly selected physical device. + +To verify ActivityKit persistence and authenticated reconciliation across two real app-host processes without reinstalling the app between phases, run: + +```sh +npm run ios:activitykit-process-proof -- \ + --xcode-device-id \ + --core-device-id +``` + +The command rejects simulated or mismatched destinations, builds once, starts a uniquely identified Live Activity, confirms the first test host has exited, relaunches the installed destination artifacts, reconciles through `AidenRemoteClient`, ends the activity, and moves its temporary result bundles to Trash. Its cleanup phase is best-effort even when a proof phase fails. + +Aiden On The Go is MIT-licensed under [`LICENSE`](LICENSE). The original Hermex MIT notice is retained under `AidenOnTheGo/Resources/ThirdPartyNotices/` for adapted implementation pieces; no Hermes WebUI compatibility layer or product identity is shipped. diff --git a/ios/SECURITY.md b/ios/SECURITY.md new file mode 100644 index 00000000..593f6b4a --- /dev/null +++ b/ios/SECURITY.md @@ -0,0 +1,5 @@ +# Security + +Report security issues privately through the sole repository's GitHub security-advisory flow. Do not include device credentials, provider keys, pairing payloads, prompts, filesystem paths, or private logs in a public issue. + +The mobile threat model is [`../docs/security/aiden-remote-threat-model.md`](../docs/security/aiden-remote-threat-model.md). Remote Access is off by default, uses per-device revocable credentials, and cannot widen Aiden workspace authority or enable Computer Use. diff --git a/ios/TESTFLIGHT.md b/ios/TESTFLIGHT.md new file mode 100644 index 00000000..82a3ea6a --- /dev/null +++ b/ios/TESTFLIGHT.md @@ -0,0 +1,89 @@ +# TestFlight readiness + +The approved production identity is: + +- App: `sbtbiswas.AidenOnTheGo` +- Live Activity widget: `sbtbiswas.AidenOnTheGo.LiveActivityWidget` +- App Group: `group.sbtbiswas.AidenOnTheGo` +- URL scheme: `aiden-otg` +- SKU: `aiden-on-the-go-ios` + +## Verified archive command + +Run from the repository root. This targets generic iOS hardware and does not use a simulator: + +```sh +xcodebuild archive \ + -project ios/AidenOnTheGo.xcodeproj \ + -scheme AidenOnTheGo \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath /tmp/AidenOnTheGo.xcarchive \ + DEVELOPMENT_TEAM=5WP229CBB8 \ + CODE_SIGN_STYLE=Automatic \ + -allowProvisioningUpdates +``` + +The Phase 12 audit produced a valid Release archive with the approved bundle IDs, team, App Group, Live Activity widget, App Intents metadata, privacy manifest, and third-party notices. The locally available signing identity produced an Apple Development archive with `get-task-allow=true`. That archive is useful for readiness validation but must not be exported or uploaded to TestFlight. + +Distribution signing is now provisioned. The local Apple Distribution identity belongs to team `5WP229CBB8`, and App Store profiles exist for both `sbtbiswas.AidenOnTheGo` and `sbtbiswas.AidenOnTheGo.LiveActivityWidget`. The exported internal IPA must still be checked for `get-task-allow=false`, exact bundle/App Group identifiers, and Apple Distribution signing before every upload. + +## Export configurations + +The checked-in export options under `ci/` are adapted from the Hermex release foundation: + +- `ci/TestFlightExportOptions.plist` is internal-TestFlight-only. +- `ci/ExternalTestFlightExportOptions.plist` can produce an external-capable App Store Connect upload after owner approval. + +Both pin team `5WP229CBB8`, use automatic signing, preserve the selected project version/build number, and upload symbols. The external configuration deliberately omits `testFlightInternalTestingOnly`; exporting a build does not invite testers or submit it for review. + +## Manual GitHub upload workflows + +The owner-gated upload workflows are adapted from Hermex's proven release structure: + +- `.github/workflows/aiden-on-the-go-internal-testflight.yml` requires a manual run from `main`, the exact confirmation `INTERNAL`, and the protected GitHub environment `aiden-on-the-go-internal-testflight`. It uses the internal-only export policy. +- `.github/workflows/aiden-on-the-go-external-testflight.yml` requires a manual run from `main`, the exact confirmation `EXTERNAL_REVIEW`, and the separate protected environment `aiden-on-the-go-external-testflight`. It uses the external-capable policy and refuses a marketing version whose App Store version train is already closed. + +Configure these three secrets independently in each protected environment before use: + +- `APP_STORE_CONNECT_KEY_ID` +- `APP_STORE_CONNECT_ISSUER_ID` +- `APP_STORE_CONNECT_PRIVATE_KEY` containing the `.p8` key text + +Both workflows query App Store Connect for the latest build in the current marketing version and select the next valid `CFBundleVersion`; the optional input can override it only with a greater one-to-three-component numeric version. Both archive generic iOS hardware and upload only. The external workflow does not assign a tester group, invite testers, or submit Beta App Review; those remain explicit App Store Connect actions. + +The workflows intentionally fail closed until the protected environments, App Store Connect app record, API-key access, and valid iOS distribution signing are owned and configured. Do not weaken the gates to make an unprovisioned run pass. Their deterministic policy and selector tests run with `npm run test:ios-release` and are registered in the repository's normal test command. + +Use the read-only and owner-operations guidance in `ASC_CLI.md` to inspect App Store Connect after upload, validate the exact version, and drive Codex status automations. The upload workflow deliberately does not depend on installing `asc` on a hosted runner. The distinct **Aiden On The Go** record is App ID `6803233275`; the legacy **Aiden - Quick AI** record is not reused. + +On 2026-08-22, version `0.1.0` build `16` was exported with the internal-only policy, uploaded through the explicitly selected owner-authorized ASC profile, processed as `VALID`, and assigned to the `Internal Testers` group (`3f90ffa7-29bb-429e-80a4-88422eb85b6d`). Exact App Store Connect build `4389a0ee-430b-46e7-97ca-bea553b6f335` reports `internalBuildState=IN_BETA_TESTING` and `externalBuildState=NOT_APPLICABLE`. Build `7` added Thinking Orbs and the refreshed composer/model controls; build `8` added Mac-owned model visibility, sanitized custom provider artwork sync, corrected iOS thinking-model rows, waveform-only listening status, and the home action overlay; build `9` added first-class Concentrate, the refined Your Activity/Total Tokens view, and bounded Mac-generated title reconciliation on iOS; build `10` added authenticated pairing completion and accepted multi-device, multi-Mac, listener, Tailscale-ownership, revocation, and cache-isolation hardening; build `11` added refreshed Pi-hosted models and first-class QR, local setup-code, private Tailscale setup-code, and payload pairing choices; build `14` repaired approval visibility and cancellation reconciliation across Mac and iOS while keeping privileged approval details host-only; build `15` aligned the mobile thinking/tool activity timeline with Mac; and build `16` adds sparse, scoped semantic interaction haptics with cancellation, replay, microphone, hardware, foreground, and dismissal gates. Build `1` was rejected before processing because its App Store icon contained alpha; builds `2`–`16` use the exact opaque RayChat Icon Composer artwork. + +The local `npm run ios:asc-monitor` command is the only approved Codex-automation entry point. It requires the exact numeric App ID, exact build ID where applicable, and an explicit Aiden Keychain profile; enforces telemetry-off strict authentication; uses only read operations; and redacts TestFlight tester/feedback/crash content into change-detection summaries. Its focused tests are registered in `npm run test:ios-release` and the normal repository test gate. + +For a deliberate local export after the owner installs valid iOS distribution signing and confirms the App Store Connect record, export the exact reviewed archive with one of these configurations: + +```sh +xcodebuild -exportArchive \ + -archivePath /tmp/AidenOnTheGo.xcarchive \ + -exportPath /tmp/AidenOnTheGoExport \ + -exportOptionsPlist ios/ci/TestFlightExportOptions.plist \ + -allowProvisioningUpdates +``` + +Use `ios/ci/ExternalTestFlightExportOptions.plist` only when the build is intentionally eligible for external TestFlight/Beta App Review. + +## Distribution gates + +Before export or upload: + +1. The owner selected version `0.1.0` for the first internal TestFlight train. Build `1` was rejected during upload because the App Store icon contained alpha; subsequent builds incrementally repaired the icon, compact navigation, native shell, cold-connect loading, scratch/activity branding, interaction/model UI, provider metadata, pairing, attachments, multi-device/Mac hardening, approval lifecycle reconciliation, outbound image sharing, activity presentation, and semantic interaction haptics. Build `16` is the current processed internal candidate. Increment the build number for later uploads; selecting the first public version remains a later product decision. +2. Configure iOS App Store distribution signing/profiles for the app, Live Activity widget, and App Group under team `5WP229CBB8`, then verify the exported app and extension have `get-task-allow=false` and the exact approved identifiers. +3. With the correct Aiden credential, inspect the existing **Aiden - Quick AI** TestFlight record and confirm whether Aiden On The Go reuses it or requires a distinct App Store Connect record and SKU. +4. Publish the mobile-specific privacy-policy additions, make a working support contact visible at the resolved support URL, then supply the age-rating answers and required iPhone/iPad screenshots. The public URLs, owner name, feedback email, and copyright are resolved in `APP_STORE_METADATA.md`. +5. Provide App Review with a reachable companion Aiden Agent and explicit pairing/review instructions. Do not submit placeholder credentials or a setup that only works on the developer's private LAN. +6. Complete the physical-iPad, real-Tailscale, background/reconnect, Siri, microphone/dictation, and Live Activity acceptance gates recorded in the implementation plan. +7. Re-run the full release checklist against the exact distribution-signed archive and exported IPA before upload. + +Draft product copy and the unresolved App Store fields live in `APP_STORE_METADATA.md`. + +Never upload an archive built with a personal/imported compatibility identifier, an Apple Development identity, or `get-task-allow=true`. diff --git a/ios/app-store/MOBILE_PRIVACY_SUPPORT_COPY.md b/ios/app-store/MOBILE_PRIVACY_SUPPORT_COPY.md new file mode 100644 index 00000000..0465e550 --- /dev/null +++ b/ios/app-store/MOBILE_PRIVACY_SUPPORT_COPY.md @@ -0,0 +1,43 @@ +# Mobile privacy and support copy + +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. + +## 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. + +## 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 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. + +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. + +## 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. Aiden On The Go requires on-device recognition and does not upload or retain a voice recording. If on-device recognition is unavailable or permission is denied, dictation is unavailable and the text composer remains usable. +- **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. + +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. + +## Contact replacement + +Questions about privacy or support for Aiden Agent and Aiden On The Go can be sent to `hey@sambitbiswas.com`. Include the Aiden version, device model, and a description of the issue, but do not send provider API keys, pairing credentials, private prompts, or confidential files. + +## Publication checklist + +- 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. +- 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/app-info/en-US.json b/ios/app-store/metadata/app-info/en-US.json new file mode 100644 index 00000000..b79f839a --- /dev/null +++ b/ios/app-store/metadata/app-info/en-US.json @@ -0,0 +1,5 @@ +{ + "name": "Aiden On The Go", + "subtitle": "Your Aiden Agent, anywhere", + "privacyPolicyUrl": "https://chatwithaiden.com/privacy" +} 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 new file mode 100644 index 00000000..5d381a34 --- /dev/null +++ b/ios/app-store/metadata/version/0.1.0/en-US.json @@ -0,0 +1,6 @@ +{ + "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 and read-aloud stay 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.", + "keywords": "AI assistant,agent,developer,Git,workspace,chat,automation,remote,Tailscale,Swift", + "marketingUrl": "https://chatwithaiden.com/", + "supportUrl": "https://chatwithaiden.com/" +} diff --git a/ios/ci/ExternalTestFlightExportOptions.plist b/ios/ci/ExternalTestFlightExportOptions.plist new file mode 100644 index 00000000..7836adf0 --- /dev/null +++ b/ios/ci/ExternalTestFlightExportOptions.plist @@ -0,0 +1,18 @@ + + + + + destination + upload + manageAppVersionAndBuildNumber + + method + app-store-connect + signingStyle + automatic + teamID + 5WP229CBB8 + uploadSymbols + + + diff --git a/ios/ci/TestFlightExportOptions.plist b/ios/ci/TestFlightExportOptions.plist new file mode 100644 index 00000000..e938eddf --- /dev/null +++ b/ios/ci/TestFlightExportOptions.plist @@ -0,0 +1,20 @@ + + + + + destination + upload + manageAppVersionAndBuildNumber + + method + app-store-connect + signingStyle + automatic + teamID + 5WP229CBB8 + testFlightInternalTestingOnly + + uploadSymbols + + + diff --git a/ios/ci/select_testflight_build_number.rb b/ios/ci/select_testflight_build_number.rb new file mode 100644 index 00000000..819da611 --- /dev/null +++ b/ios/ci/select_testflight_build_number.rb @@ -0,0 +1,295 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "base64" +require "json" +require "net/http" +require "openssl" +require "time" +require "uri" + +class TestFlightBuildNumberSelector + API_BASE = "https://api.appstoreconnect.apple.com" + BUILD_NUMBER_PATTERN = /\A\d+(?:\.\d+){0,2}\z/ + + # App Store version states in which Apple has approved the version and closed + # its pre-release train: any later upload must carry a higher + # CFBundleShortVersionString (ASC upload errors 90186/90062, hit on + # 2026-06-02 with 1.0 and again on 2026-08-04 with 1.4). + APPROVED_APP_STORE_STATES = %w[ + READY_FOR_SALE + READY_FOR_DISTRIBUTION + PENDING_DEVELOPER_RELEASE + PENDING_APPLE_RELEASE + PROCESSING_FOR_APP_STORE + ].freeze + + class SelectionError < StandardError; end + + def self.valid_build_number?(value) + BUILD_NUMBER_PATTERN.match?(value.to_s) + end + + def self.compare_build_numbers(left, right) + validate_build_number!(left, "left build number") + validate_build_number!(right, "right build number") + + left_parts = left.split(".").map(&:to_i) + right_parts = right.split(".").map(&:to_i) + max_length = [left_parts.length, right_parts.length].max + + max_length.times do |index| + left_value = left_parts[index] || 0 + right_value = right_parts[index] || 0 + return -1 if left_value < right_value + return 1 if left_value > right_value + end + + 0 + end + + def self.increment_build_number(value) + validate_build_number!(value, "latest build number") + + parts = value.split(".").map(&:to_i) + parts[-1] += 1 + parts.join(".") + end + + def self.select_build_number(requested_build_number:, latest_build_number:) + requested = requested_build_number.to_s.strip + + unless requested.empty? + validate_build_number!(requested, "requested build number") + + if latest_build_number && compare_build_numbers(requested, latest_build_number) <= 0 + raise SelectionError, + "Requested build number #{requested} must be greater than latest App Store Connect build #{latest_build_number}." + end + + return requested + end + + latest_build_number ? increment_build_number(latest_build_number) : "1" + end + + # Returns the approved App Store version that closes the train for + # marketing_version, or nil when the train is open. Approved version strings + # that are not plain dotted numerics cannot be compared and are ignored. + def self.closed_train_version(marketing_version:, approved_versions:) + validate_build_number!(marketing_version, "marketing version") + + approved_versions + .select { |value| valid_build_number?(value) } + .find { |value| compare_build_numbers(marketing_version, value) <= 0 } + end + + def self.validate_build_number!(value, label) + return if valid_build_number?(value) + + raise SelectionError, "#{label.capitalize} must contain one to three period-separated integers using only digits and dots. Received: #{value}" + end + + def initialize(env: ENV, now: Time.now) + @env = env + @now = now + @jwt_token = nil + end + + def run + bundle_id = required_env("BUNDLE_ID") + marketing_version = required_env("MARKETING_VERSION") + requested_build_number = @env.fetch("REQUESTED_BUILD_NUMBER", "") + + enforce_open_train!(bundle_id: bundle_id, marketing_version: marketing_version) if @env["ENFORCE_OPEN_TRAIN"] == "1" + + latest = latest_uploaded_build_number(bundle_id: bundle_id, marketing_version: marketing_version) + selected = self.class.select_build_number( + requested_build_number: requested_build_number, + latest_build_number: latest + ) + + if requested_build_number.to_s.strip.empty? + warn "Latest App Store Connect build for #{bundle_id} #{marketing_version}: #{latest || "none"}" + warn "Selected next build number: #{selected}" + else + warn "Latest App Store Connect build for #{bundle_id} #{marketing_version}: #{latest || "none"}" + warn "Using requested build number: #{selected}" + end + + selected + end + + private + + # Fails fast — before the ~15-minute archive step — when App Store Connect + # would reject the upload anyway because marketing_version's pre-release + # train is closed by an approved App Store version. + def enforce_open_train!(bundle_id:, marketing_version:) + blocking = self.class.closed_train_version( + marketing_version: marketing_version, + approved_versions: approved_app_store_versions(bundle_id: bundle_id) + ) + + if blocking + raise SelectionError, + "The #{marketing_version} pre-release train is closed: App Store version #{blocking} is already approved. " \ + "Bump MARKETING_VERSION in ios/AidenOnTheGo.xcodeproj/project.pbxproj above #{blocking}, land it on main, " \ + "and re-run this workflow (see ios/TESTFLIGHT.md)." + end + + warn "Pre-release train #{marketing_version} is open: no approved App Store version at or above it." + end + + def approved_app_store_versions(bundle_id:) + app_id = app_id_for_bundle_id(bundle_id) + versions = fetch_paginated_json( + "/v1/apps/#{app_id}/appStoreVersions", + "fields[appStoreVersions]" => "versionString,appStoreState", + "limit" => "200" + ) + + versions + .select { |item| APPROVED_APP_STORE_STATES.include?(item.dig("attributes", "appStoreState")) } + .map { |item| item.dig("attributes", "versionString") } + .compact + end + + def latest_uploaded_build_number(bundle_id:, marketing_version:) + app_id = app_id_for_bundle_id(bundle_id) + builds = fetch_paginated_json( + "/v1/builds", + "filter[app]" => app_id, + "filter[preReleaseVersion.version]" => marketing_version, + "fields[builds]" => "version,uploadedDate", + "limit" => "200" + ) + + build_numbers = builds.map { |item| item.dig("attributes", "version") }.compact + invalid_build_numbers = build_numbers.reject { |value| self.class.valid_build_number?(value) } + unless invalid_build_numbers.empty? + raise SelectionError, + "Cannot auto-select after non-numeric App Store Connect build numbers: #{invalid_build_numbers.uniq.join(", ")}" + end + + build_numbers.max { |left, right| self.class.compare_build_numbers(left, right) } + end + + # Memoized: the train preflight and build-number selection both need it. + def app_id_for_bundle_id(bundle_id) + @app_ids ||= {} + cached = @app_ids[bundle_id] + return cached if cached + + @app_ids[bundle_id] = uncached_app_id_for_bundle_id(bundle_id) + end + + def uncached_app_id_for_bundle_id(bundle_id) + apps = fetch_paginated_json( + "/v1/apps", + "filter[bundleId]" => bundle_id, + "fields[apps]" => "bundleId,name,sku", + "limit" => "10" + ) + + app = apps.find { |item| item.dig("attributes", "bundleId") == bundle_id } + raise SelectionError, "No App Store Connect app found for bundle ID #{bundle_id}." unless app + + app.fetch("id") + end + + def fetch_paginated_json(path, params) + url = URI.join(API_BASE, path) + url.query = URI.encode_www_form(params) + items = [] + + loop do + response = fetch_json(url) + data = response.fetch("data") + raise SelectionError, "Expected App Store Connect data array from #{url}." unless data.is_a?(Array) + + items.concat(data) + next_url = response.dig("links", "next") + break if next_url.to_s.empty? + + url = URI(next_url) + end + + items + end + + def fetch_json(url) + request = Net::HTTP::Get.new(url) + request["Authorization"] = "Bearer #{jwt_token}" + request["Accept"] = "application/json" + + response = Net::HTTP.start( + url.hostname, + url.port, + use_ssl: url.scheme == "https", + open_timeout: 10, + read_timeout: 30 + ) do |http| + http.request(request) + end + + unless response.is_a?(Net::HTTPSuccess) + raise SelectionError, "App Store Connect request failed with HTTP #{response.code}: #{response.body}" + end + + JSON.parse(response.body) + rescue JSON::ParserError => error + raise SelectionError, "App Store Connect returned invalid JSON: #{error.message}" + end + + def jwt_token + @jwt_token ||= begin + issued_at = @now.to_i - 60 + header = { + alg: "ES256", + kid: required_env("APP_STORE_CONNECT_KEY_ID"), + typ: "JWT" + } + payload = { + iss: required_env("APP_STORE_CONNECT_ISSUER_ID"), + iat: issued_at, + exp: issued_at + (20 * 60), + aud: "appstoreconnect-v1" + } + + signing_input = [base64url(header.to_json), base64url(payload.to_json)].join(".") + signature = base64url(es256_signature(signing_input)) + "#{signing_input}.#{signature}" + end + end + + def es256_signature(signing_input) + key = OpenSSL::PKey.read(File.read(required_env("APP_STORE_CONNECT_KEY_PATH"))) + der_signature = key.sign(OpenSSL::Digest::SHA256.new, signing_input) + sequence = OpenSSL::ASN1.decode(der_signature) + + r = sequence.value[0].value.to_i + s = sequence.value[1].value.to_i + hex_signature = [r.to_s(16).rjust(64, "0"), s.to_s(16).rjust(64, "0")].join + [hex_signature].pack("H*") + end + + def base64url(value) + Base64.strict_encode64(value).tr("+/", "-_").delete("=") + end + + def required_env(name) + value = @env[name].to_s + raise SelectionError, "Missing required environment variable: #{name}" if value.empty? + + value + end +end +if $PROGRAM_NAME == __FILE__ + begin + puts TestFlightBuildNumberSelector.new.run + rescue TestFlightBuildNumberSelector::SelectionError => error + warn error.message + exit 1 + end +end diff --git a/ios/ci/select_testflight_build_number_test.rb b/ios/ci/select_testflight_build_number_test.rb new file mode 100644 index 00000000..4fcd2c2e --- /dev/null +++ b/ios/ci/select_testflight_build_number_test.rb @@ -0,0 +1,255 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "minitest/autorun" +require "tempfile" +require_relative "select_testflight_build_number" + +class TestFlightBuildNumberSelectorTest < Minitest::Test + def test_selects_one_when_app_store_connect_has_no_builds_for_version + assert_equal( + "1", + TestFlightBuildNumberSelector.select_build_number( + requested_build_number: "", + latest_build_number: nil + ) + ) + end + + def test_selects_next_integer_build_number + assert_equal( + "19", + TestFlightBuildNumberSelector.select_build_number( + requested_build_number: "", + latest_build_number: "18" + ) + ) + end + + def test_selects_next_dotted_build_number + assert_equal( + "1.4", + TestFlightBuildNumberSelector.select_build_number( + requested_build_number: "", + latest_build_number: "1.3" + ) + ) + end + + def test_accepts_requested_build_number_above_latest + assert_equal( + "20", + TestFlightBuildNumberSelector.select_build_number( + requested_build_number: "20", + latest_build_number: "19" + ) + ) + end + + def test_rejects_requested_build_number_equal_to_latest + error = assert_raises(TestFlightBuildNumberSelector::SelectionError) do + TestFlightBuildNumberSelector.select_build_number( + requested_build_number: "19", + latest_build_number: "19" + ) + end + + assert_includes(error.message, "must be greater") + end + + def test_rejects_requested_build_number_below_latest + error = assert_raises(TestFlightBuildNumberSelector::SelectionError) do + TestFlightBuildNumberSelector.select_build_number( + requested_build_number: "18", + latest_build_number: "19" + ) + end + + assert_includes(error.message, "must be greater") + end + + def test_rejects_malformed_requested_build_number + error = assert_raises(TestFlightBuildNumberSelector::SelectionError) do + TestFlightBuildNumberSelector.select_build_number( + requested_build_number: "build-20", + latest_build_number: "19" + ) + end + + assert_includes(error.message, "digits and dots") + end + + def test_rejects_more_than_three_build_number_components + error = assert_raises(TestFlightBuildNumberSelector::SelectionError) do + TestFlightBuildNumberSelector.select_build_number( + requested_build_number: "1.2.3.4", + latest_build_number: "1.2.3" + ) + end + + assert_includes(error.message, "one to three") + end + + def test_rejects_empty_build_number_components + [".1", "1.", "1..2"].each do |value| + assert_raises(TestFlightBuildNumberSelector::SelectionError) do + TestFlightBuildNumberSelector.select_build_number( + requested_build_number: value, + latest_build_number: nil + ) + end + end + end + + def test_comparison_treats_omitted_trailing_zero_components_as_equal + assert_equal(0, TestFlightBuildNumberSelector.compare_build_numbers("1", "1.0")) + assert_equal(0, TestFlightBuildNumberSelector.compare_build_numbers("1.0", "1.0.0")) + end + + def test_train_is_open_when_marketing_version_is_above_all_approved_versions + assert_nil( + TestFlightBuildNumberSelector.closed_train_version( + marketing_version: "1.5", + approved_versions: ["1.4", "1.0.1", "1.0"] + ) + ) + end + + def test_train_is_closed_when_marketing_version_equals_an_approved_version + assert_equal( + "1.4", + TestFlightBuildNumberSelector.closed_train_version( + marketing_version: "1.4", + approved_versions: ["1.4", "1.0.1"] + ) + ) + end + + def test_train_is_closed_when_marketing_version_is_below_an_approved_version + assert_equal( + "1.4", + TestFlightBuildNumberSelector.closed_train_version( + marketing_version: "1.3.9", + approved_versions: ["1.4"] + ) + ) + end + + def test_train_comparison_is_numeric_not_lexicographic + assert_nil( + TestFlightBuildNumberSelector.closed_train_version( + marketing_version: "1.10", + approved_versions: ["1.9"] + ) + ) + end + + def test_train_check_ignores_non_numeric_approved_version_strings + assert_nil( + TestFlightBuildNumberSelector.closed_train_version( + marketing_version: "1.5", + approved_versions: ["2.0-beta", "1.4"] + ) + ) + end + + def test_train_check_with_no_approved_versions_is_open + assert_nil( + TestFlightBuildNumberSelector.closed_train_version( + marketing_version: "1.0", + approved_versions: [] + ) + ) + end + + def test_approved_versions_fetch_filters_to_approved_states + selector = TestFlightBuildNumberSelector.new(env: {}) + captured = [] + + selector.define_singleton_method(:app_id_for_bundle_id) { |_bundle_id| "app-123" } + selector.define_singleton_method(:fetch_paginated_json) do |path, params| + captured << [path, params] + [ + { "attributes" => { "versionString" => "1.4", "appStoreState" => "READY_FOR_SALE" } }, + { "attributes" => { "versionString" => "1.5", "appStoreState" => "PREPARE_FOR_SUBMISSION" } }, + { "attributes" => { "versionString" => "1.0.1", "appStoreState" => "REPLACED_WITH_NEW_VERSION" } }, + { "attributes" => { "appStoreState" => "READY_FOR_SALE" } } + ] + end + + versions = selector.send(:approved_app_store_versions, bundle_id: "sbtbiswas.AidenOnTheGo") + + assert_equal(["1.4"], versions) + assert_equal("/v1/apps/app-123/appStoreVersions", captured.first.first) + end + + def test_enforce_open_train_raises_with_actionable_message_when_closed + selector = TestFlightBuildNumberSelector.new(env: {}) + selector.define_singleton_method(:approved_app_store_versions) { |bundle_id:| ["1.4"] } + + error = assert_raises(TestFlightBuildNumberSelector::SelectionError) do + selector.send(:enforce_open_train!, bundle_id: "sbtbiswas.AidenOnTheGo", marketing_version: "1.4") + end + + assert_includes(error.message, "train is closed") + assert_includes(error.message, "Bump MARKETING_VERSION") + end + + def test_app_lookup_uses_supported_bundle_id_filter_only + selector = TestFlightBuildNumberSelector.new(env: {}) + captured = [] + + selector.define_singleton_method(:fetch_paginated_json) do |path, params| + captured << [path, params] + [ + { + "id" => "app-123", + "attributes" => { + "bundleId" => "sbtbiswas.AidenOnTheGo" + } + } + ] + end + + assert_equal( + "app-123", + selector.send(:app_id_for_bundle_id, "sbtbiswas.AidenOnTheGo") + ) + + assert_equal("/v1/apps", captured.first.first) + assert_equal("sbtbiswas.AidenOnTheGo", captured.first.last.fetch("filter[bundleId]")) + refute_includes(captured.first.last.keys, "filter[platform]") + end + + def test_jwt_uses_raw_es256_signature_format + key = OpenSSL::PKey::EC.generate("prime256v1") + + Tempfile.create("app-store-connect-key") do |file| + file.write(key.to_pem) + file.flush + + selector = TestFlightBuildNumberSelector.new( + env: { + "APP_STORE_CONNECT_KEY_ID" => "KEY123", + "APP_STORE_CONNECT_ISSUER_ID" => "00000000-0000-0000-0000-000000000000", + "APP_STORE_CONNECT_KEY_PATH" => file.path + }, + now: Time.at(1_700_000_000) + ) + + token_parts = selector.send(:jwt_token).split(".") + payload = JSON.parse(base64url_decode(token_parts[1])) + + assert_equal(3, token_parts.length) + assert_equal(20 * 60, payload.fetch("exp") - payload.fetch("iat")) + assert_equal(64, base64url_decode(token_parts[2]).bytesize) + end + end + + private + + def base64url_decode(value) + padding = (4 - (value.length % 4)) % 4 + Base64.urlsafe_decode64(value + ("=" * padding)) + end +end diff --git a/ios/scripts/check-swift-file-sizes b/ios/scripts/check-swift-file-sizes new file mode 100755 index 00000000..8783dea3 --- /dev/null +++ b/ios/scripts/check-swift-file-sizes @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +raw_limit="${AIDEN_SWIFT_FILE_SIZE_LIMIT:-500}" +scan_root="$repo_root/AidenOnTheGo" + +if [[ ! "$raw_limit" =~ ^[0-9]+$ ]]; then + echo "AIDEN_SWIFT_FILE_SIZE_LIMIT must be a positive integer. Received: $raw_limit" >&2 + exit 2 +fi + +limit="$((10#$raw_limit))" + +if (( limit <= 0 )); then + echo "AIDEN_SWIFT_FILE_SIZE_LIMIT must be a positive integer. Received: $raw_limit" >&2 + exit 2 +fi + +is_exempt_file() { + local relative_path="$1" + local basename="${relative_path##*/}" + + case "$relative_path" in + */Previews/*|*/Preview\ Content/*) + return 0 + ;; + esac + + case "$basename" in + *_Previews.swift|*.generated.swift|*.gen.swift|*Generated*.swift) + return 0 + ;; + esac + + return 1 +} + +warning_count=0 + +while IFS= read -r -d "" file_path; do + relative_path="${file_path#"$repo_root/"}" + + if is_exempt_file "$relative_path"; then + continue + fi + + line_count="$(wc -l < "$file_path" | tr -d "[:space:]")" + + if (( line_count > limit )); then + warning_count=$((warning_count + 1)) + + if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then + printf "::warning file=%s,line=1,title=Swift file size::%s has %s LOC, above the warning limit of %s. Split intentionally when practical.\n" \ + "$relative_path" "$relative_path" "$line_count" "$limit" + fi + + printf "warning: %s has %s LOC (limit %s)\n" "$relative_path" "$line_count" "$limit" + fi +done < <(find "$scan_root" -type f -name "*.swift" -print0 | sort -z) + +if (( warning_count > 0 )); then + printf "Swift file-size policy: warning-only. %s production app Swift file(s) exceed %s LOC.\n" "$warning_count" "$limit" + echo "Existing oversized files do not fail this check; use the output to make future drift visible." +else + printf "No production app Swift files exceed %s LOC.\n" "$limit" +fi diff --git a/main/handlers/aiden-remote-parse.ts b/main/handlers/aiden-remote-parse.ts new file mode 100644 index 00000000..163b40a9 --- /dev/null +++ b/main/handlers/aiden-remote-parse.ts @@ -0,0 +1,26 @@ +import type { AidenRemoteConnectionMode } from "../services/aiden-remote-state.js"; + +export function parseAidenRemoteConnectionMode(value: unknown): AidenRemoteConnectionMode { + if (value === "lan" || value === "tailscale" || value === "both") return value; + throw new Error("Invalid Aiden Remote connection mode."); +} + +export function parseAidenRemoteTransport(value: unknown): "lan" | "tailscale" { + if (value === "lan" || value === "tailscale") return value; + throw new Error("Invalid Aiden Remote pairing transport."); +} + +export function parseAidenRemoteTakeoverToken(value: unknown): string { + if (typeof value === "string" && /^[A-Za-z0-9_-]{32}$/u.test(value)) return value; + throw new Error("Invalid Tailscale takeover review token."); +} + +export function parseAidenRemoteScopedIdentifier(value: unknown): string { + if ( + typeof value === "string" && + value.length >= 1 && + value.length <= 128 && + /^[A-Za-z0-9._:-]+$/u.test(value) + ) return value; + throw new Error("Invalid Aiden Remote approval identifier."); +} diff --git a/main/handlers/aiden-remote.test.ts b/main/handlers/aiden-remote.test.ts new file mode 100644 index 00000000..13fc491d --- /dev/null +++ b/main/handlers/aiden-remote.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFile } from "node:fs/promises"; +import { + parseAidenRemoteConnectionMode, + parseAidenRemoteScopedIdentifier, + parseAidenRemoteTakeoverToken, + parseAidenRemoteTransport, +} from "./aiden-remote-parse.js"; + +test("Remote Access IPC parsers accept only exact transport values", () => { + assert.equal(parseAidenRemoteConnectionMode("lan"), "lan"); + assert.equal(parseAidenRemoteConnectionMode("tailscale"), "tailscale"); + assert.equal(parseAidenRemoteConnectionMode("both"), "both"); + assert.equal(parseAidenRemoteTransport("lan"), "lan"); + assert.equal(parseAidenRemoteTransport("tailscale"), "tailscale"); + for (const invalid of [undefined, null, true, "LAN", "both", ["lan"], { value: "lan" }]) { + assert.throws(() => parseAidenRemoteTransport(invalid), /invalid/iu); + } +}); + +test("Remote approval IPC identifiers are bounded and path-safe", () => { + for (const value of ["chat-1", "approval_1", "stream:one", "chat.fixture"]) { + assert.equal(parseAidenRemoteScopedIdentifier(value), value); + } + for (const invalid of ["", "../chat", "chat/one", "chat one", "A".repeat(129), null]) { + assert.throws(() => parseAidenRemoteScopedIdentifier(invalid), /invalid/iu); + } +}); + +test("Tailscale takeover IPC accepts only one exact opaque review token", () => { + const token = "A".repeat(32); + assert.equal(parseAidenRemoteTakeoverToken(token), token); + for (const invalid of [undefined, null, "A".repeat(31), "A".repeat(33), `${"A".repeat(31)}+`, { token }]) { + assert.throws(() => parseAidenRemoteTakeoverToken(invalid), /invalid/iu); + } +}); + +test("Remote approval IPC is bound to the current main-frame document across awaits", async () => { + const source = await readFile(new URL("./aiden-remote.ts", import.meta.url), "utf8"); + const approvalHandlers = source.slice( + source.indexOf('ipcMain.handle("remote:getPendingApproval"'), + source.indexOf('ipcMain.handle("remote:setEnabled"'), + ); + assert.match(approvalHandlers, /rendererDocumentOwner\(/u); + assert.match(approvalHandlers, /owner\.isDestroyed\(\)/u); + assert.doesNotMatch(approvalHandlers, /async \(_event/u); +}); diff --git a/main/handlers/aiden-remote.ts b/main/handlers/aiden-remote.ts new file mode 100644 index 00000000..2f6844fb --- /dev/null +++ b/main/handlers/aiden-remote.ts @@ -0,0 +1,207 @@ +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 { rendererDocumentOwner } from "../services/renderer-document-owner.js"; +import { + parseAidenRemoteConnectionMode, + parseAidenRemoteScopedIdentifier, + parseAidenRemoteTakeoverToken, + parseAidenRemoteTransport, +} from "./aiden-remote-parse.js"; + +function parseIdentifier(value: unknown, prefix: "device_" | "root_" | "pairing_"): string { + if ( + typeof value !== "string" || + !value.startsWith(prefix) || + value.length > 128 || + !/^[A-Za-z0-9_-]+$/u.test(value) + ) { + throw new Error("Invalid Aiden Remote identifier."); + } + return value; +} + +async function settingsSnapshot(): Promise { + const runtime = await getAidenRemoteRuntime(); + const state = await runtime.state.snapshot(); + const pairing = runtime.service.pairingStatus(); + return { + instanceId: state.instanceId, + displayName: state.displayName, + status: await runtime.service.status(), + devices: await runtime.state.listDevices(), + ...(pairing ? { pairing } : {}), + approvedRoots: state.approvedRoots.map((root) => ({ + id: root.id, + label: root.label, + folderPath: root.folderPath, + createdAt: root.createdAt, + })), + }; +} + +export function registerAidenRemoteHandlers(): void { + ipcMain.handle("remote:get", settingsSnapshot); + + ipcMain.handle("remote:getPendingApproval", async (event, chatId: unknown) => { + const owner = rendererDocumentOwner( + event, + () => new Error("Remote approvals require the active application document."), + ); + const approval = (await getAidenRemoteRuntime()).pendingApprovalForChat( + parseAidenRemoteScopedIdentifier(chatId), + ); + if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); + return approval; + }); + + ipcMain.handle( + "remote:respondApprovalFromHost", + async (event, chatId: unknown, approvalId: unknown, decision: unknown) => { + const owner = rendererDocumentOwner( + event, + () => new Error("Remote approvals require the active application document."), + ); + if (decision !== "allow" && decision !== "deny") { + throw new Error("Invalid Aiden Remote approval decision."); + } + const runtime = await getAidenRemoteRuntime(); + if (owner.isDestroyed()) throw new Error("The renderer document is no longer active."); + const resolved = runtime.respondApprovalFromHost( + parseAidenRemoteScopedIdentifier(chatId), + parseAidenRemoteScopedIdentifier(approvalId), + decision, + ); + if (!resolved) throw new Error("This approval is no longer available."); + return { resolved: true }; + }, + ); + + ipcMain.handle("remote:setEnabled", async (_event, enabled: unknown) => { + if (typeof enabled !== "boolean") throw new Error("Invalid Aiden Remote enabled state."); + await (await getAidenRemoteRuntime()).service.setEnabled(enabled); + return settingsSnapshot(); + }); + + ipcMain.handle("remote:setConnectionMode", async (_event, mode: unknown) => { + await (await getAidenRemoteRuntime()).service.setConnectionMode( + parseAidenRemoteConnectionMode(mode), + ); + return settingsSnapshot(); + }); + + ipcMain.handle("remote:setDisplayName", async (_event, displayName: unknown) => { + if (typeof displayName !== "string") { + throw new Error("Invalid Aiden Remote display name."); + } + await (await getAidenRemoteRuntime()).service.setDisplayName(displayName); + return settingsSnapshot(); + }); + + ipcMain.handle("remote:tailscaleConnect", async () => { + await (await getAidenRemoteRuntime()).service.connectTailscale(); + return settingsSnapshot(); + }); + + ipcMain.handle("remote:tailscaleDisconnect", async () => { + await (await getAidenRemoteRuntime()).service.disconnectTailscale(); + return settingsSnapshot(); + }); + + ipcMain.handle("remote:tailscaleReconcile", async () => { + await (await getAidenRemoteRuntime()).service.reconcileTailscale(); + return settingsSnapshot(); + }); + + ipcMain.handle("remote:tailscaleReviewTakeover", async () => { + return (await getAidenRemoteRuntime()).service.reviewTailscaleTakeover(); + }); + + ipcMain.handle("remote:tailscaleTakeOver", async (_event, token: unknown) => { + await (await getAidenRemoteRuntime()).service.takeOverTailscale( + parseAidenRemoteTakeoverToken(token), + ); + return settingsSnapshot(); + }); + + 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, + }; + }); + + ipcMain.handle("remote:closePairing", async (_event, sessionId: unknown) => { + const closed = await (await getAidenRemoteRuntime()).service.closePairing( + parseIdentifier(sessionId, "pairing_"), + ); + return { closed }; + }); + + ipcMain.handle("remote:revokeDevice", async (_event, deviceId: unknown) => { + const runtime = await getAidenRemoteRuntime(); + const revoked = await runtime.revokeDevice(parseIdentifier(deviceId, "device_")); + if (!revoked) throw new Error("This Aiden Remote device is already revoked or unavailable."); + return settingsSnapshot(); + }); + + ipcMain.handle("remote:addApprovedRoot", async (event) => { + const parent = BrowserWindow.fromWebContents(event.sender); + const selection = parent + ? await dialog.showOpenDialog(parent, { + title: "Approve a folder for Aiden On The Go", + buttonLabel: "Review Folder", + properties: ["openDirectory", "createDirectory"], + }) + : await dialog.showOpenDialog({ + title: "Approve a folder for Aiden On The Go", + buttonLabel: "Review Folder", + properties: ["openDirectory", "createDirectory"], + }); + const selectedPath = selection.filePaths[0]; + if (selection.canceled || !selectedPath) return settingsSnapshot(); + const runtime = await getAidenRemoteRuntime(); + try { + await runtime.approvedRoots.addLocalFolder(selectedPath); + } catch (error) { + if (!(error instanceof Error) || !error.message.includes("entire home directory")) throw error; + const warning = parent + ? await dialog.showMessageBox(parent, { + type: "warning", + title: "Approve your entire home folder?", + message: "A paired device could browse every non-hidden folder in your home directory.", + detail: "Approve a smaller project folder when possible. Provider credentials and hidden/system folders remain excluded by policy.", + buttons: ["Cancel", "Approve Home Folder"], + defaultId: 0, + cancelId: 0, + noLink: true, + }) + : await dialog.showMessageBox({ + type: "warning", + title: "Approve your entire home folder?", + message: "A paired device could browse every non-hidden folder in your home directory.", + detail: "Approve a smaller project folder when possible. Provider credentials and hidden/system folders remain excluded by policy.", + buttons: ["Cancel", "Approve Home Folder"], + defaultId: 0, + cancelId: 0, + noLink: true, + }); + if (warning.response !== 1) return settingsSnapshot(); + await runtime.approvedRoots.addLocalFolder(selectedPath, { confirmHomeDirectory: true }); + } + return settingsSnapshot(); + }); + + ipcMain.handle("remote:removeApprovedRoot", async (_event, rootId: unknown) => { + const runtime = await getAidenRemoteRuntime(); + const removed = await runtime.approvedRoots.removeLocalRoot(parseIdentifier(rootId, "root_")); + if (!removed) throw new Error("This approved root is no longer available."); + return settingsSnapshot(); + }); +} diff --git a/main/handlers/chat-create-params.test.ts b/main/handlers/chat-create-params.test.ts index 5bcba6ad..5d3fe66a 100644 --- a/main/handlers/chat-create-params.test.ts +++ b/main/handlers/chat-create-params.test.ts @@ -59,6 +59,10 @@ test("chat creation rejects excess keys lazily with constant errors", () => { test("the reserved Assistant workspace is minted only by the dedicated handler", () => { const handlers = readFileSync(new URL("./chats.ts", import.meta.url), "utf8"); + const applicationService = readFileSync( + new URL("../services/chat-application-service.ts", import.meta.url), + "utf8", + ); const renderer = readFileSync( new URL( "../../renderer/components/assistant/use-assistant-chat.ts", @@ -74,10 +78,14 @@ test("the reserved Assistant workspace is minted only by the dedicated handler", handlers.indexOf('"chats:createAssistant"'), handlers.indexOf('"chats:rename"'), ); - assert.match(publicCreate, /parsed\.workspaceId === ASSISTANT_WORKSPACE_ID/u); + assert.match(publicCreate, /chatApplicationService\.create\(parsed, owner\)/u); + assert.match( + applicationService, + /input\.workspaceId === ASSISTANT_WORKSPACE_ID/u, + ); assert.match( - publicCreate, - /configStore\.getWorkspace\(parsed\.workspaceId\)/u, + applicationService, + /configStore\.getWorkspace\(input\.workspaceId\)/u, ); assert.match(assistantCreate, /parseAssistantChatCreate\(input\)/u); assert.match(assistantCreate, /workspaceId: ASSISTANT_WORKSPACE_ID/u); @@ -117,16 +125,22 @@ test("public chat creation is aborted and drained across a workspace mutation", finishMutation(); const handlers = readFileSync(new URL("./chats.ts", import.meta.url), "utf8"); + const applicationService = readFileSync( + new URL("../services/chat-application-service.ts", import.meta.url), + "utf8", + ); const publicCreate = handlers.slice( handlers.indexOf('"chats:create"'), handlers.indexOf('"chats:createAssistant"'), ); + assert.match(publicCreate, /rendererDocumentOwner\(/u); + assert.match(publicCreate, /chatApplicationService\.create\(parsed, owner\)/u); assert.match( - publicCreate, - /workspaceMutationGate\.admit\(parsed\.workspaceId\)/u, + applicationService, + /workspaceMutationGate\.admit\(input\.workspaceId\)/u, ); - assert.match(publicCreate, /admitRendererOwnedWorkspaceOperation\(/u); - assert.match(publicCreate, /workspaceOperation\?\.signal\.aborted/u); - assert.match(publicCreate, /workspaceOperation\?\.release\(\)/u); - assert.match(publicCreate, /mutationAdmission\?\.release\(\)/u); + assert.match(applicationService, /admitOwnedWorkspaceOperation\(/u); + assert.match(applicationService, /workspaceOperation\?\.signal\.aborted/u); + assert.match(applicationService, /workspaceOperation\?\.release\(\)/u); + assert.match(applicationService, /mutationAdmission\.release\(\)/u); }); diff --git a/main/handlers/chats.append.contract.test.ts b/main/handlers/chats.append.contract.test.ts index 80f7ff24..00217264 100644 --- a/main/handlers/chats.append.contract.test.ts +++ b/main/handlers/chats.append.contract.test.ts @@ -9,6 +9,10 @@ import { } from "../../renderer/shared/chat-message-contract.js"; const source = fs.readFileSync(new URL("./chats.ts", import.meta.url), "utf8"); +const applicationServiceSource = fs.readFileSync( + new URL("../services/chat-application-service.ts", import.meta.url), + "utf8", +); const projectionSource = fs.readFileSync( new URL("../services/visible-chat-projection.ts", import.meta.url), "utf8", @@ -19,7 +23,8 @@ test("private canonical Pi protocol never crosses the renderer chat boundary", ( projectionSource, /const \{ pi: _privatePiProtocol, \.\.\.visible \} = message/u, ); - assert.match(source, /chat: chatForRenderer\(chat\)/u); + assert.match(source, /chatApplicationService\.get\(/u); + assert.match(applicationServiceSource, /chat: chatForRenderer\(chat\)/u); assert.match(source, /return chatForRenderer\(chat\)/u); assert.match(source, /return chatForRenderer\(copied\)/u); }); diff --git a/main/handlers/chats.ts b/main/handlers/chats.ts index 9b587265..fe8f8098 100644 --- a/main/handlers/chats.ts +++ b/main/handlers/chats.ts @@ -1,17 +1,15 @@ // Chat history CRUD IPC handlers. -import { BrowserWindow, dialog, ipcMain, logger } from "../platform.js"; +import { BrowserWindow, dialog, ipcMain } from "../platform.js"; import { chatStore } from "../services/chat-store.js"; +import { chatApplicationService } from "../services/chat-application-service-main.js"; import { chatTitleService } from "../services/chat-title.js"; import { configStore } from "../services/config-store.js"; import { computerUseStatus } from "../services/computer-use/status.js"; import { llmClient } from "../services/llm-client.js"; import { rendererDocumentOwner } from "../services/renderer-document-owner.js"; -import { subagentRunStore } from "../services/subagents/subagent-run-store.js"; import { persistedChatWorkspaceId } from "../../renderer/shared/chat-workspace.js"; import { isSafeSubagentIdentifier } from "../../renderer/shared/subagent-runs.js"; -import { piCompactionSessionStore } from "../services/pi-compaction-session-store.js"; -import { piRuntimeEffectStore } from "../services/pi-runtime-effect-store.js"; import { skillRegistry } from "../services/skill-registry-main.js"; import { commitSkillInvocationForAppend, @@ -58,35 +56,17 @@ export function registerChatHistoryHandlers(): void { let chatExportActive = false; ipcMain.handle("chats:activitySnapshot", () => chatActivityRegistry.snapshot()); ipcMain.handle("chats:list", async (_event, workspaceId?: unknown) => - chatStore.list( + chatApplicationService.list( typeof workspaceId === "string" && workspaceId ? workspaceId : undefined, ), ); - ipcMain.handle("chats:get", async (_event, id: unknown) => { - const chatId = asString(id, "id"); - let reconciliationRequired = false; - if (llmClient.isChatOwnedByInactiveRenderer(chatId)) { - reconciliationRequired = !(await llmClient.waitForChatIdle(chatId)); - } - const chat = await chatStore.get(chatId); - // The former renderer can be invalidated while this asynchronous read is - // already in flight. Mark that result provisional as well; the renderer - // retains a retry marker even if the one-shot settlement event was missed. - reconciliationRequired ||= llmClient.isChatOwnedByInactiveRenderer(chatId); - return { - chat: chatForRenderer(chat), - reconciliation: reconciliationRequired - ? { - chatId, - workspaceId: persistedChatWorkspaceId(chat?.workspaceId), - } - : null, - }; - }); + ipcMain.handle("chats:get", async (_event, id: unknown) => + chatApplicationService.get(asString(id, "id")), + ); ipcMain.handle("chats:waitUntilIdle", async (_event, id: unknown) => - llmClient.waitForChatIdle(asString(id, "id")), + chatApplicationService.waitUntilIdle(asString(id, "id")), ); ipcMain.handle("chats:create", async (event, input: unknown) => { @@ -94,65 +74,8 @@ export function registerChatHistoryHandlers(): void { event, () => new Error("Chats require the active application document."), ); - if (llmClient.requiresAppendReconciliation(owner.documentId)) { - throw new Error(appendReconciliationFailureMessage("blocked")); - } const parsed = parseChatCreate(input); - if (parsed.workspaceId === ASSISTANT_WORKSPACE_ID) { - throw new Error( - "Aiden Assistant chats require the Assistant chat creation path.", - ); - } - const mutationAdmission = parsed.workspaceId - ? workspaceMutationGate.admit(parsed.workspaceId) - : undefined; - let workspaceOperation: - ReturnType | undefined; - try { - workspaceOperation = parsed.workspaceId - ? admitRendererOwnedWorkspaceOperation( - workspaceOperationRegistry, - owner, - parsed.workspaceId, - ) - : undefined; - if ( - parsed.workspaceId && - !(await configStore.getWorkspace(parsed.workspaceId)) - ) { - throw new Error("The selected workspace is no longer available."); - } - const assertCurrent = () => { - if (owner.isDestroyed()) - throw new Error("The renderer document is no longer active."); - if ( - mutationAdmission?.signal.aborted || - workspaceOperation?.signal.aborted - ) { - throw new Error("The workspace changed before the chat was created."); - } - if (llmClient.requiresAppendReconciliation(owner.documentId)) { - throw new Error(appendReconciliationFailureMessage("blocked")); - } - }; - try { - return chatForRenderer( - await chatStore.create({ ...parsed, assertCurrent }), - ); - } catch (error) { - if (isChatCreateReconciliationRequiredError(error)) { - llmClient.markAppendReconciliationRequired(owner.documentId); - owner.onInvalidated(() => { - llmClient.clearAppendReconciliationRequired(owner.documentId); - }); - throw new Error(appendReconciliationFailureMessage("blocked")); - } - throw error; - } - } finally { - workspaceOperation?.release(); - mutationAdmission?.release(); - } + return chatApplicationService.create(parsed, owner); }); ipcMain.handle("chats:createAssistant", async (event, input: unknown) => { @@ -199,7 +122,7 @@ export function registerChatHistoryHandlers(): void { ipcMain.handle( "chats:rename", async (_event, id: unknown, title: unknown) => { - await chatStore.rename(asString(id, "id"), asString(title, "title")); + await chatApplicationService.rename(asString(id, "id"), asString(title, "title")); }, ); @@ -346,24 +269,10 @@ export function registerChatHistoryHandlers(): void { ipcMain.handle( "chats:moveEmptyToWorkspace", async (_event, id: unknown, workspaceId: unknown) => { - const chatId = asString(id, "id"); - const nextWorkspaceId = asString(workspaceId, "workspaceId"); - const finishMove = llmClient.beginChatWorkspaceChange(chatId); - if (!finishMove) { - throw new Error( - "Finish or stop the current response before changing workspaces.", - ); - } - try { - if (!(await configStore.getWorkspace(nextWorkspaceId))) { - throw new Error(`Workspace ${nextWorkspaceId} not found.`); - } - return chatForRenderer( - await chatStore.moveEmptyChatToWorkspace(chatId, nextWorkspaceId), - ); - } finally { - finishMove(); - } + return chatApplicationService.moveEmptyToWorkspace( + asString(id, "id"), + asString(workspaceId, "workspaceId"), + ); }, ); @@ -417,76 +326,9 @@ export function registerChatHistoryHandlers(): void { }, ); - ipcMain.handle("chats:remove", async (_event, id: unknown) => { - const chatId = asString(id, "id"); - const finishDeletion = llmClient.beginChatDeletion(chatId); - let releaseAdmission = false; - try { - await llmClient.cancelChat(chatId); - // Privacy data is removed first so a partial cross-store failure cannot - // leave orphaned inspector reports after the chat disappears from the UI. - try { - await subagentRunStore.deleteChat(chatId); - } catch (error) { - logger.error( - "subagents", - "Could not delete private subagent history.", - error, - ); - throw new Error("Aiden could not delete this chat's subagent history."); - } - try { - await piRuntimeEffectStore.deleteChat(chatId); - } catch (error) { - logger.error( - "pi", - "Could not delete private Pi effect history.", - error, - ); - throw new Error( - "Aiden could not delete this chat's tool-effect history.", - ); - } - try { - await piCompactionSessionStore.deleteChat(chatId); - } catch (error) { - logger.error( - "pi", - "Could not delete the private compaction journal.", - error, - ); - throw new Error( - "Aiden could not delete this chat's compaction history.", - ); - } - // remove() also reconciles an index entry whose payload is already - // missing or corrupt, while propagating real filesystem failures. - await chatStore.remove(chatId); - // Clear the crash-recovery intent only after both stores have crossed - // their durability barriers. - await subagentRunStore.completeChatDeletion(chatId); - releaseAdmission = true; - } finally { - if (!releaseAdmission) { - try { - releaseAdmission = !( - await subagentRunStore.pendingChatDeletions() - ).includes(chatId); - } catch (error) { - // An indeterminate durable state must keep generation admission - // closed until restart reconciliation can safely finish the delete. - logger.error( - "subagents", - "Could not inspect pending chat deletion state.", - error, - ); - } - } - // A durable but incomplete intent keeps admission closed for this - // process. Startup reconciliation finishes it before the next renderer. - if (releaseAdmission) finishDeletion(); - } - }); + ipcMain.handle("chats:remove", async (_event, id: unknown) => + chatApplicationService.remove(asString(id, "id")), + ); ipcMain.handle( "chats:appendMessage", diff --git a/main/handlers/index.ts b/main/handlers/index.ts index f02f01da..f97a3899 100644 --- a/main/handlers/index.ts +++ b/main/handlers/index.ts @@ -24,6 +24,7 @@ import { registerAssistantHandlers } from "./assistant.js"; import { registerShortcutHandlers } from "./shortcuts.js"; import { registerTelegramHandlers } from "./telegram.js"; import { registerSubagentHandlers } from "./subagents.js"; +import { registerAidenRemoteHandlers } from "./aiden-remote.js"; import { ipcMain, logger } from "../platform.js"; import { writeDevLog } from "../services/dev-log.js"; @@ -65,6 +66,7 @@ export function registerHandlers(): void { registerShortcutHandlers(); registerTelegramHandlers(); registerSubagentHandlers(); + registerAidenRemoteHandlers(); logger.info("handlers", "✓ IPC handlers registered"); diff --git a/main/handlers/providers.ts b/main/handlers/providers.ts index 10408721..675c603a 100644 --- a/main/handlers/providers.ts +++ b/main/handlers/providers.ts @@ -1,10 +1,15 @@ // Provider configuration + API key IPC handlers. Thin — logic lives in services. -import { ipcMain, logger } from "../platform.js"; +import { ipcMain } from "../platform.js"; import { configStore } from "../services/config-store.js"; import { canUseStoredProviderKey } from "../services/provider-key-policy.js"; import { secrets } from "../services/secrets.js"; -import { listModels, normalizeProviderBaseUrl, testConnection } from "../services/models.js"; +import { + assertOnboardingTailnetBaseUrl, + listModels, + normalizeProviderBaseUrl, + testConnection, +} from "../services/models.js"; import { parseProviderAuthProviderId, parseProviderAuthResponseRequest, @@ -13,6 +18,7 @@ import { import { providerAuthFlow } from "../services/provider-auth-flow.js"; import { providerAuthOwner } from "../services/provider-auth-owner.js"; import { providerRegistry } from "../services/provider-registry.js"; +import { projectPiCatalogRefreshErrors } from "../services/pi-catalog-refresh.js"; import { isCustomProviderId } from "../services/custom-provider-id.js"; import { canonicalGoogleProvider, @@ -23,7 +29,6 @@ import { parseAnthropicThinkingSelection } from "../services/anthropic-provider. import { assertMutableProviderId, forwardCodexProviderStatusChanges, - mergeCodexProvider, } from "../services/provider-list-core.js"; import { AppearancePreviewState } from "../services/appearance-preview-core.js"; import { @@ -35,7 +40,7 @@ import { normalizeProviderCredentialInput, providerConnectionSnapshot, } from "../services/provider-credential-rotation-core.js"; -import { listProvidersWithLegacyPiCredentialMigration } from "../services/legacy-pi-credential-migration.js"; +import { listConfiguredProviders } from "../services/provider-list-main.js"; import type { ProviderDeployment, ProviderKind, @@ -48,6 +53,9 @@ import { normalizeAppearanceConfig, parseAppearanceConfig, } from "../../renderer/shared/appearance.js"; +import { normalizeProviderArtwork } from "../../renderer/shared/provider-artwork.js"; +import { normalizeProviderArtworkInput } from "../services/provider-artwork.js"; +import { isGenerationThinkingLevel } from "../../renderer/shared/generation-thinking.js"; const appearancePreview = new AppearancePreviewState(); @@ -135,6 +143,7 @@ function parseProvider(value: unknown): StoredProvider { id: asProviderId(p.id), kind, label: asString(p.label, "label"), + artwork: normalizeProviderArtwork(p.artwork), baseUrl, models, modelMetadata, @@ -146,6 +155,9 @@ function parseProvider(value: unknown): StoredProvider { // a renderer payload that could redirect native credentials. isBuiltin: false, }; + if (provider.id === "custom:onboarding-tailscale") { + assertOnboardingTailnetBaseUrl(provider.baseUrl); + } return provider.id === GOOGLE_PROVIDER_ID ? canonicalGoogleProvider(provider) : provider; } @@ -196,17 +208,15 @@ async function saveProvider( } async function listProviders() { - const customProviders = await listProvidersWithLegacyPiCredentialMigration(); - const providers = [ - ...(await providerRegistry.listBuiltinProviders()), - ...customProviders.filter((provider) => !providerRegistry.isBuiltinProvider(provider.id)), - ]; - try { - return mergeCodexProvider(providers, await providerRegistry.codex.snapshot()); - } catch { - logger.warn("providers", "ChatGPT / Codex status was unavailable while listing providers."); - return mergeCodexProvider(providers, null); - } + return listConfiguredProviders(); +} + +async function refreshProviderCatalogs(providerIds?: readonly string[], force = true) { + const errors = await providerRegistry.refreshBuiltinCatalogs(providerIds, force); + return { + providers: await listProviders(), + errors: projectPiCatalogRefreshErrors(errors), + }; } export function registerProviderHandlers(): void { @@ -216,6 +226,10 @@ export function registerProviderHandlers(): void { ipcMain.handle("providers:list", listProviders); + ipcMain.handle("providers:normalizeArtwork", (_event, input: unknown) => + normalizeProviderArtworkInput(input), + ); + ipcMain.handle("providers:auth:status", async (_event, providerId: unknown) => providerAuthFlow.status(parseProviderAuthProviderId(providerId)), ); @@ -239,6 +253,23 @@ export function registerProviderHandlers(): void { return providerAuthFlow.logout(parseProviderAuthProviderId(providerId)); }); + ipcMain.handle( + "providers:validateOnboardingApiKey", + async (event, providerIdValue: unknown, keyValue: unknown) => { + const owner = providerAuthOwner(event); + if (providerIdValue !== "openai" && providerIdValue !== "anthropic") { + throw new Error("This provider does not support onboarding API-key validation."); + } + const key = normalizeProviderCredentialInput(keyValue); + if (!key) throw new Error("Enter an API key before validating the connection."); + return providerRegistry.validateAndStoreOnboardingApiKey( + providerIdValue, + key, + () => !owner.isDestroyed(), + ); + }, + ); + ipcMain.handle("providers:save", async (event, providerValue: unknown, keyOverride?: unknown) => { const owner = providerAuthOwner(event); return saveProvider(parseProvider(providerValue), keyOverride, () => !owner.isDestroyed()); @@ -308,16 +339,22 @@ export function registerProviderHandlers(): void { }, ); - ipcMain.handle("providers:refresh", async (event) => { + ipcMain.handle("providers:refresh", async (event, providerValue?: unknown) => { // A catalog refresh can renew OAuth credentials inside Pi, so treat it as // a credential-affecting operation rather than accepting stale documents. providerAuthOwner(event); - const errors = await providerRegistry.refreshBuiltinCatalogs(); - if (errors.size > 0) { - const [providerId, error] = errors.entries().next().value as [string, Error]; - throw new Error(`${providerId} model refresh failed: ${error.message}`); + const providerId = providerValue === undefined ? undefined : asProviderId(providerValue); + if (providerId !== undefined && !providerRegistry.isBuiltinProvider(providerId)) { + throw new Error("Only Pi built-in provider catalogs can be refreshed."); } - return listProviders(); + return refreshProviderCatalogs( + providerId === undefined ? undefined : [providerId], + ); + }); + + ipcMain.handle("providers:refreshIfStale", async (event) => { + providerAuthOwner(event); + return refreshProviderCatalogs(undefined, false); }); ipcMain.handle("settings:get", async () => configStore.getSettings()); @@ -355,6 +392,40 @@ export function registerProviderHandlers(): void { return configStore.setAnthropicThinkingLevel(selection.modelId, selection.level); }, ); + ipcMain.handle( + "settings:setProviderThinking", + async ( + _event, + providerIdValue: unknown, + modelIdValue: unknown, + levelValue: unknown, + ) => { + const providerId = asProviderId(providerIdValue); + const modelId = asString(modelIdValue, "modelId"); + if (modelId.length > MAX_CONFIG_ID_LENGTH || !isGenerationThinkingLevel(levelValue)) { + throw new Error("Invalid provider thinking selection."); + } + const metadata = providerRegistry.builtinProvider(providerId)?.modelMetadata?.[modelId]; + if (!metadata?.thinkingLevels?.includes(levelValue)) { + throw new Error("This thinking level is not supported by the selected model."); + } + return configStore.setProviderThinkingLevel(providerId, modelId, levelValue); + }, + ); + ipcMain.handle( + "settings:setModelVisibility", + async (_event, providerIdValue: unknown, modelIdValue: unknown, hiddenValue: unknown) => { + const providerId = asProviderId(providerIdValue); + const modelId = asString(modelIdValue, "modelId"); + if (modelId.length > MAX_CONFIG_ID_LENGTH || typeof hiddenValue !== "boolean") { + throw new Error("Invalid model visibility request."); + } + return configStore.setModelVisibility(providerId, modelId, hiddenValue); + }, + ); + ipcMain.handle("settings:showAllProviderModels", async (_event, providerIdValue: unknown) => { + return configStore.showAllProviderModels(asProviderId(providerIdValue)); + }); ipcMain.handle("settings:set", async (_event, patch: unknown) => { if (typeof patch !== "object" || patch === null) throw new Error("Invalid settings patch."); const p = patch as Record; diff --git a/main/handlers/scheduled-tasks.ts b/main/handlers/scheduled-tasks.ts index 9b689f09..77269e1d 100644 --- a/main/handlers/scheduled-tasks.ts +++ b/main/handlers/scheduled-tasks.ts @@ -1,17 +1,7 @@ import { ipcMain } from "../platform.js"; -import { configStore } from "../services/config-store.js"; -import { scheduleService } from "../services/schedule-service.js"; -import { listScheduledScripts } from "../services/schedule-script.js"; -import { - nextScheduledRuns, - scheduleStore, - systemTimezone, - validateTimezone, -} from "../services/schedule-store.js"; import { parseScheduledTaskInput } from "./scheduled-tasks-parse.js"; -import type { ScheduledTaskSettings } from "../services/types.js"; -import { scheduledSettingsPatch } from "../services/scheduled-settings-core.js"; -import { selectedMcpServers } from "../services/mcp-selection.js"; +import { scheduledTaskApplicationService } from "../services/scheduled-task-application-service-main.js"; +import { systemTimezone } from "../services/schedule-store.js"; function requiredString(value: unknown, name: string): string { if (typeof value !== "string" || !value.trim()) { @@ -24,69 +14,41 @@ function optionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } -function settingsDefaults( - input: Awaited>, -): ScheduledTaskSettings { - return { - enabled: input.scheduledTasksEnabled !== false, - defaultMode: input.scheduledDefaultMode === "script" ? "script" : "llm", - defaultPermission: input.scheduledDefaultPermission === "full" ? "full" : "read-only", - defaultMcpEnabled: input.scheduledDefaultMcpEnabled === true, - defaultNotify: input.scheduledDefaultNotify !== false, - defaultTimezone: validateTimezone(input.scheduledDefaultTimezone ?? systemTimezone()), - }; -} - export function registerScheduledTaskHandlers(): void { - ipcMain.handle("schedule:list", () => scheduleStore.list()); + ipcMain.handle("schedule:list", () => scheduledTaskApplicationService.list()); ipcMain.handle("schedule:save", async (_event, input: unknown) => { - const parsed = parseScheduledTaskInput(input); - if ((parsed.mcpServerIds?.length ?? 0) > 0) { - selectedMcpServers(await configStore.listMcpServers(), parsed.mcpServerIds); - } - return scheduleService.save(parsed); + return scheduledTaskApplicationService.save(parseScheduledTaskInput(input)); }); ipcMain.handle("schedule:remove", (_event, id: unknown) => - scheduleService.remove(requiredString(id, "id")), + scheduledTaskApplicationService.remove(requiredString(id, "id")), ); ipcMain.handle("schedule:pause", (_event, id: unknown) => - scheduleService.pause(requiredString(id, "id")), + scheduledTaskApplicationService.pause(requiredString(id, "id")), ); ipcMain.handle("schedule:resume", (_event, id: unknown) => - scheduleService.resume(requiredString(id, "id")), + scheduledTaskApplicationService.resume(requiredString(id, "id")), ); ipcMain.handle("schedule:runNow", (_event, id: unknown) => - scheduleService.runNow(requiredString(id, "id")), + scheduledTaskApplicationService.runNow(requiredString(id, "id")), ); ipcMain.handle("schedule:runs", (_event, id: unknown) => - scheduleStore.runs(requiredString(id, "id")), + scheduledTaskApplicationService.runs(requiredString(id, "id")), ); ipcMain.handle("schedule:preview", (_event, cron: unknown, timezone: unknown, count: unknown) => - nextScheduledRuns( + scheduledTaskApplicationService.preview( requiredString(cron, "cron"), optionalString(timezone) ?? systemTimezone(), typeof count === "number" ? count : 3, ), ); - ipcMain.handle("schedule:scripts", async (_event, workspaceId?: unknown) => { - const id = optionalString(workspaceId); - const workspace = id ? await configStore.getWorkspace(id) : undefined; - if (id && !workspace) throw new Error(`Workspace ${id} not found.`); - return listScheduledScripts({ workspaceRoot: workspace?.folderPath }); - }); + ipcMain.handle("schedule:scripts", (_event, workspaceId?: unknown) => + scheduledTaskApplicationService.scripts(optionalString(workspaceId))); ipcMain.handle("schedule:settings", async (_event, patch?: unknown) => { - const current = await configStore.getSettings(); - if (patch === undefined) return settingsDefaults(current); + const current = await scheduledTaskApplicationService.settings(); + if (patch === undefined) return current.value; const input = ( patch && typeof patch === "object" && !Array.isArray(patch) ? patch : {} ) as Record; - const saved = await configStore.setSettings(scheduledSettingsPatch(input, validateTimezone)); - if ( - typeof input.enabled === "boolean" && - input.enabled !== (current.scheduledTasksEnabled !== false) - ) { - await scheduleService.setGlobalEnabled(input.enabled); - } - return settingsDefaults(saved); + return (await scheduledTaskApplicationService.updateSettings(current.revision, input)).value; }); } diff --git a/main/handlers/workspaces.ts b/main/handlers/workspaces.ts index 4a60e433..6c3a5fce 100644 --- a/main/handlers/workspaces.ts +++ b/main/handlers/workspaces.ts @@ -1,11 +1,7 @@ // Workspace CRUD + folder helpers (git status, reveal in Finder). -import * as fs from "fs/promises"; -import * as path from "path"; import type { IpcMainInvokeEvent, OpenDialogOptions } from "electron"; -import { BrowserWindow, dialog, ipcMain, logger, shell } from "../platform.js"; -import { configStore } from "../services/config-store.js"; -import { ensureUserDataDir } from "../services/data-store.js"; +import { BrowserWindow, dialog, ipcMain, shell } from "../platform.js"; import { listExternalEditors, openFolderInExternalEditor } from "../services/external-editors.js"; import { gitBranches, @@ -14,53 +10,29 @@ import { gitCompare, gitComparisonDiff, gitCreateBranch, - gitCreateWorktree, - gitDeleteManagedWorktree, gitDiff, - gitFinalizeManagedWorktreeDeletion, gitInfo, - gitManagedWorktreeDeletionPending, - gitManagedWorktreeRegistered, - gitManagedWorktreeUsable, gitPush, gitPushCapability, gitReview, - gitRollbackWorktree, gitWorktrees, - GitManagedWorktreeDeleteError, type GitCommitInput, type GitComparisonDiffInput, type GitDiffInput, type GitPushInput, } from "../services/git.js"; -import { llmClient } from "../services/llm-client.js"; -import { scheduleService } from "../services/schedule-service.js"; -import { createScratchWorkspaceDirectory } from "../services/scratch-workspace.js"; -import { terminalService } from "../services/terminal.js"; -import type { Workspace, WorkspacePermission } from "../services/types.js"; +import { workspaceApplicationService } from "../services/workspace-application-service-main.js"; import { listWorkspaceFiles, readWorkspaceFile, WorkspaceFileError, writeWorkspaceFile, } from "../services/workspace-files.js"; -import { workspaceMutationGate } from "../services/workspace-mutation-gate.js"; -import { removeManagedWorkspace } from "../services/managed-worktree-removal-core.js"; -import { assertManagedWorktreeAdmission } from "../services/managed-worktree-admission.js"; -import { withWorkspaceScheduleRestoration } from "../services/workspace-schedule-restoration.js"; -import { - commitManagedWorktreeCreation, - ManagedWorktreeCreationError, -} from "../services/managed-worktree-creation-core.js"; -import { - admitRendererOwnedWorkspaceOperation, - workspaceOperationRegistry, -} from "../services/workspace-operation-registry.js"; import { rendererDocumentOwner } from "../services/renderer-document-owner.js"; -import { assertWorkspaceRecordRemovalAllowed } from "../services/workspace-record-removal.js"; import { parseWorktreeCreateParams } from "./worktree-create-params.js"; - -const PERMISSIONS: WorkspacePermission[] = ["full", "ask", "none"]; +import { workspaceEnvironmentApplicationService } from "../services/workspace-environment-application-service-main.js"; +import type { WorkspaceEnvironmentDirectory } from "../services/workspace-environment-application-service.js"; +import { workspaceWorktreeApplicationService } from "../services/workspace-worktree-application-service-main.js"; function asString(value: unknown, name: string): string { if (typeof value !== "string" || value.length === 0) { @@ -131,171 +103,51 @@ function asGitComparisonDiffInput(value: unknown): GitComparisonDiffInput { }; } -function newId(): string { - return `w-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; -} - -interface WorkspaceDirectory { - folderPath: string; - workspace: Workspace; -} - async function withWorkspaceOperation( event: IpcMainInvokeEvent, workspaceIdValue: unknown, - operation: (resolved: WorkspaceDirectory, signal: AbortSignal) => Promise, + operation: (resolved: WorkspaceEnvironmentDirectory, signal: AbortSignal) => Promise, allowNoAccess = false, ): Promise { - const workspaceId = asString(workspaceIdValue, "workspaceId"); - if (workspaceMutationGate.isChanging(workspaceId)) - throw new Error("The workspace is changing. Try again in a moment."); const owner = rendererDocumentOwner( event, () => new Error("Workspace access requires the active renderer document."), ); - const admission = admitRendererOwnedWorkspaceOperation( - workspaceOperationRegistry, + return workspaceEnvironmentApplicationService.run( owner, - workspaceId, + asString(workspaceIdValue, "workspaceId"), + operation, + { allowNoAccess }, ); - try { - const resolved = await workspaceDirectory(workspaceId, true, allowNoAccess); - if ( - !resolved || - owner.isDestroyed() || - admission.signal.aborted || - workspaceMutationGate.isChanging(workspaceId) - ) { - throw new Error("The workspace changed before the operation could start."); - } - return await operation(resolved, admission.signal); - } finally { - admission.release(); - } } -async function withWorkspaceRecordOperation( +async function withOptionalWorkspaceOperation( event: IpcMainInvokeEvent, workspaceIdValue: unknown, - operation: (workspace: Workspace, signal: AbortSignal) => Promise, + operation: (resolved: WorkspaceEnvironmentDirectory | undefined, signal: AbortSignal) => Promise, ): Promise { - const workspaceId = asString(workspaceIdValue, "workspaceId"); - if (workspaceMutationGate.isChanging(workspaceId)) { - throw new Error("The workspace is changing. Try again in a moment."); - } const owner = rendererDocumentOwner( event, () => new Error("Workspace access requires the active renderer document."), ); - const admission = admitRendererOwnedWorkspaceOperation( - workspaceOperationRegistry, + return workspaceEnvironmentApplicationService.runOptional( owner, - workspaceId, + asString(workspaceIdValue, "workspaceId"), + operation, ); - try { - const workspace = await configStore.getWorkspace(workspaceId); - if ( - !workspace || - owner.isDestroyed() || - admission.signal.aborted || - workspaceMutationGate.isChanging(workspaceId) - ) { - throw new Error("The workspace changed before the operation could start."); - } - return await operation(workspace, admission.signal); - } finally { - admission.release(); - } -} - -async function withOptionalWorkspaceOperation( - event: IpcMainInvokeEvent, - workspaceIdValue: unknown, - operation: (resolved: WorkspaceDirectory | undefined, signal: AbortSignal) => Promise, -): Promise { - const workspaceId = asString(workspaceIdValue, "workspaceId"); - return withWorkspaceRecordOperation(event, workspaceId, async (_workspace, signal) => { - const resolved = await workspaceDirectory(workspaceId, false); - if (signal.aborted || workspaceMutationGate.isChanging(workspaceId)) { - throw new Error("The workspace changed before the operation could start."); - } - return operation(resolved, signal); - }); -} - -async function saveWorkspaceForFolder( - folderPath: string, - permission: WorkspacePermission, -): Promise { - const canonicalPath = await fs.realpath(folderPath); - if (!(await fs.stat(canonicalPath)).isDirectory()) - throw new Error("Choose a folder for this workspace."); - const now = Date.now(); - return configStore.saveWorkspace({ - id: newId(), - name: path.basename(canonicalPath) || "Workspace", - folderPath: canonicalPath, - permission, - createdAt: now, - updatedAt: now, - }); -} - -/** Resolve Git paths from persisted workspace state, never from renderer input. */ -async function workspaceDirectory( - workspaceId: unknown, - required: boolean, - allowNoAccess = false, -): Promise { - const id = asString(workspaceId, "workspaceId"); - const workspace = await configStore.getWorkspace(id); - if (!workspace) throw new Error(`Workspace ${id} was not found.`); - if (workspace.permission === "none" && !allowNoAccess) { - if (!required) return undefined; - throw new Error(`${workspace.name} does not allow local file access.`); - } - if (!workspace.folderPath) { - if (!required) return undefined; - throw new Error(`${workspace.name} does not have a folder.`); - } - await assertManagedWorktreeAdmission(workspace); - try { - const folderPath = await fs.realpath(workspace.folderPath); - const stats = await fs.stat(folderPath); - if (!stats.isDirectory()) throw new Error("not a directory"); - return { folderPath, workspace }; - } catch { - if (!required) return undefined; - throw new Error(`${workspace.name}'s folder is no longer available.`); - } } export function registerWorkspaceHandlers(): void { - ipcMain.handle("workspaces:list", async () => configStore.listWorkspaces()); + ipcMain.handle("workspaces:list", async () => workspaceApplicationService.list()); ipcMain.handle( "workspaces:get", - async (_event, id: unknown) => (await configStore.getWorkspace(asString(id, "id"))) ?? null, + async (_event, id: unknown) => workspaceApplicationService.get(asString(id, "id")), ); - ipcMain.handle("workspaces:create", async (_event, input: unknown) => { - const i = (typeof input === "object" && input !== null ? input : {}) as Record; - if ("folderPath" in i) - throw new Error("Choose workspace folders through Aiden's folder picker."); - const name = (typeof i.name === "string" && i.name.trim()) || "Workspace"; - const permission = PERMISSIONS.includes(i.permission as WorkspacePermission) - ? (i.permission as WorkspacePermission) - : "ask"; - const now = Date.now(); - const workspace: Workspace = { - id: newId(), - name, - permission, - createdAt: now, - updatedAt: now, - }; - return configStore.saveWorkspace(workspace); - }); + ipcMain.handle("workspaces:create", async (_event, input: unknown) => + workspaceApplicationService.create(input), + ); ipcMain.handle("workspaces:createFromFolder", async (event) => { const parent = BrowserWindow.fromWebContents(event.sender); @@ -304,113 +156,20 @@ export function registerWorkspaceHandlers(): void { ? await dialog.showOpenDialog(parent, options) : await dialog.showOpenDialog(options); if (result.canceled || !result.filePaths[0]) return null; - return saveWorkspaceForFolder(result.filePaths[0], "ask"); + return workspaceApplicationService.createFromFolder(result.filePaths[0]); }); - ipcMain.handle("workspaces:createScratch", async () => { - const scratch = await createScratchWorkspaceDirectory(); - const now = Date.now(); - const workspace: Workspace = { - id: newId(), - name: scratch.name, - folderPath: scratch.folderPath, - permission: "ask", - createdAt: now, - updatedAt: now, - }; - try { - return await configStore.saveWorkspace(workspace); - } catch (error) { - // The directory is still empty here; avoid leaving an orphan if persistence fails. - await fs.rmdir(scratch.folderPath).catch(() => undefined); - throw error; - } - }); + ipcMain.handle("workspaces:createScratch", async () => + workspaceApplicationService.createScratch(), + ); - ipcMain.handle("workspaces:update", async (_event, id: unknown, patch: unknown) => { - const workspaceId = asString(id, "id"); - const finishMutation = workspaceMutationGate.begin(workspaceId); - try { - await workspaceOperationRegistry.cancelAndSettle(workspaceId); - const existing = await configStore.getWorkspace(workspaceId); - if (!existing) throw new Error(`Workspace ${String(id)} not found.`); - const p = (typeof patch === "object" && patch !== null ? patch : {}) as Record< - string, - unknown - >; - if ("folderPath" in p) - throw new Error("Workspace folders cannot be changed from renderer input."); - const next: Workspace = { - ...existing, - name: typeof p.name === "string" && p.name.trim() ? p.name.trim() : existing.name, - permission: PERMISSIONS.includes(p.permission as WorkspacePermission) - ? (p.permission as WorkspacePermission) - : existing.permission, - }; - if (next.permission !== existing.permission) { - return await withWorkspaceScheduleRestoration( - { - restoreOnExit: existing.permission !== "none", - resume: () => scheduleService.resumeWorkspace(existing.id), - onResumeError: (error) => { - logger.error( - "schedule", - "Could not restore scheduled tasks after workspace update failed.", - error, - ); - }, - }, - async ({ ensureResumedOnExit, keepPaused }) => { - terminalService.closeForWorkspace(existing.id); - await llmClient.cancelWorkspaceAndSettle(existing.id); - await scheduleService.cancelWorkspace(existing.id); - const saved = await configStore.saveWorkspace(next); - if (saved.permission !== "none") { - ensureResumedOnExit(); - await scheduleService.resumeWorkspace(saved.id); - } - keepPaused(); - return saved; - }, - ); - } - return await configStore.saveWorkspace(next); - } finally { - finishMutation(); - } - }); + ipcMain.handle("workspaces:update", async (_event, id: unknown, patch: unknown) => + workspaceApplicationService.update(asString(id, "id"), patch), + ); - ipcMain.handle("workspaces:remove", async (_event, id: unknown) => { - const workspaceId = asString(id, "id"); - const finishMutation = workspaceMutationGate.begin(workspaceId); - terminalService.closeForWorkspace(workspaceId); - try { - await workspaceOperationRegistry.cancelAndSettle(workspaceId); - const existing = await configStore.getWorkspace(workspaceId); - assertWorkspaceRecordRemovalAllowed(existing); - await withWorkspaceScheduleRestoration( - { - restoreOnExit: existing?.permission !== "none", - resume: () => scheduleService.resumeWorkspace(workspaceId), - onResumeError: (error) => { - logger.error( - "schedule", - "Could not restore scheduled tasks after workspace removal failed.", - error, - ); - }, - }, - async ({ keepPaused }) => { - await llmClient.cancelWorkspaceAndSettle(workspaceId); - await scheduleService.cancelWorkspace(workspaceId); - await configStore.removeWorkspace(workspaceId); - keepPaused(); - }, - ); - } finally { - finishMutation(); - } - }); + ipcMain.handle("workspaces:remove", async (_event, id: unknown) => + workspaceApplicationService.remove(asString(id, "id")), + ); ipcMain.handle("workspaces:gitInfo", async (event, workspaceId: unknown) => withOptionalWorkspaceOperation(event, workspaceId, async (resolved, signal) => @@ -532,155 +291,20 @@ export function registerWorkspaceHandlers(): void { workspaceId, name, ); - return withWorkspaceOperation(event, sourceWorkspaceId, async (resolved, signal) => { - const worktreeRoot = await ensureUserDataDir("worktrees"); - const worktree = await gitCreateWorktree(resolved.folderPath, worktreeRoot, branch, signal); - const now = Date.now(); - const workspace: Workspace = { - id: newId(), - name: `${path.basename(resolved.workspace.folderPath ?? resolved.workspace.name)} · ${branch}`, - folderPath: worktree.workspacePath, - permission: resolved.workspace.permission, - managedWorktree: { - repositoryPath: worktree.repositoryPath, - worktreePath: worktree.path, - branch, - worktreeGitDir: worktree.worktreeGitDir, - ownershipToken: worktree.ownershipToken, - worktreeDevice: worktree.worktreeDevice, - worktreeInode: worktree.worktreeInode, - createdFromHead: worktree.createdFromHead, - }, - createdAt: now, - updatedAt: now, - }; - try { - return await commitManagedWorktreeCreation({ - validateBeforeSave: async () => { - const latest = await workspaceDirectory(sourceWorkspaceId, true); - if ( - !latest || - signal.aborted || - latest.folderPath !== resolved.folderPath || - latest.workspace.permission !== resolved.workspace.permission - ) { - throw new Error( - "The source workspace changed while Aiden was creating the worktree.", - ); - } - }, - saveWorkspace: () => configStore.saveWorkspace(workspace), - validateAfterSave: () => { - if (signal.aborted || workspaceMutationGate.isChanging(sourceWorkspaceId)) { - throw new Error("The source workspace changed while Aiden was saving the worktree."); - } - }, - removeWorkspaceRecord: (saved) => configStore.removeWorkspace(saved.id), - rollbackWorktree: () => gitRollbackWorktree(resolved.folderPath, worktree), - }); - } catch (error) { - if (error instanceof ManagedWorktreeCreationError) { - logger.error("git", error.logMessage, error.errors); - } - throw error; - } - }); + const owner = rendererDocumentOwner( + event, + () => new Error("Workspace access requires the active renderer document."), + ); + return workspaceWorktreeApplicationService.create(owner, sourceWorkspaceId, branch); }); ipcMain.handle("git:deleteManagedWorktree", async (event, workspaceId: unknown) => { const id = asString(workspaceId, "workspaceId"); - return withWorkspaceRecordOperation(event, id, async (workspace, signal) => { - const managed = workspace.managedWorktree; - if (!managed) throw new Error("This workspace is not an Aiden-managed worktree."); - const finishMutation = workspaceMutationGate.begin(id); - try { - await workspaceOperationRegistry.cancelAndSettle(id, { - exceptSignal: signal, - }); - return await withWorkspaceScheduleRestoration( - { - restoreOnExit: workspace.permission !== "none", - resume: () => scheduleService.resumeWorkspace(id), - onResumeError: (error) => { - logger.error( - "schedule", - "Could not restore scheduled tasks after managed worktree deletion failed.", - error, - ); - }, - }, - async ({ keepPaused }) => { - terminalService.closeForWorkspace(id); - await llmClient.cancelWorkspaceAndSettle(id); - await scheduleService.cancelWorkspace(id); - const result = await removeManagedWorkspace({ - deleteWorktree: () => - gitDeleteManagedWorktree( - managed.repositoryPath, - managed.worktreePath, - managed.branch, - managed.createdFromHead, - signal, - managed.worktreeGitDir, - managed.ownershipToken, - managed.worktreeDevice, - managed.worktreeInode, - ), - destructiveMutationAttempted: (error) => - error instanceof GitManagedWorktreeDeleteError - ? error.destructiveMutationAttempted - : undefined, - deletionPending: () => - gitManagedWorktreeDeletionPending( - managed.worktreePath, - managed.worktreeGitDir!, - managed.ownershipToken!, - ), - workspacePathExists: async () => { - try { - await fs.stat(managed.worktreePath); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; - throw error; - } - }, - worktreeRegistered: () => - gitManagedWorktreeRegistered( - managed.repositoryPath, - managed.worktreePath, - managed.branch, - managed.worktreeGitDir, - managed.ownershipToken, - ), - worktreeUsable: () => - gitManagedWorktreeUsable( - managed.repositoryPath, - managed.worktreePath, - managed.branch, - managed.worktreeGitDir, - managed.ownershipToken, - managed.worktreeDevice, - managed.worktreeInode, - ), - onDestructiveBoundary: keepPaused, - removeWorkspaceRecord: () => configStore.removeWorkspace(id), - reconciledResult: () => ({ branchDeleted: false }), - }); - if (managed.worktreeGitDir && managed.ownershipToken) { - await gitFinalizeManagedWorktreeDeletion( - managed.worktreePath, - managed.worktreeGitDir, - managed.ownershipToken, - ); - } - return result; - }, - ); - } finally { - finishMutation(); - } - }); + const owner = rendererDocumentOwner( + event, + () => new Error("Workspace access requires the active renderer document."), + ); + return workspaceWorktreeApplicationService.remove(owner, id); }); // Reveal the workspace folder in Finder. shell.openPath opens a directory itself. diff --git a/main/index.ts b/main/index.ts index 4b74c8fc..7103c905 100644 --- a/main/index.ts +++ b/main/index.ts @@ -101,6 +101,16 @@ import { reconcilePendingMcpCredentialCleanup, } from "./services/mcp-credential-cleanup.js"; import { resetOnboardingData } from "./services/onboarding-reset.js"; +import { + getOnboardingSnapshot, + setOnboardingOutcome, + setOnboardingProgress, +} from "./services/onboarding-state.js"; +import { rendererDocumentOwner } from "./services/renderer-document-owner.js"; +import { + initializeAidenRemoteService, + stopAidenRemoteServiceAndSettle, +} from "./services/aiden-remote-service-main.js"; const ownsSingleInstanceLock = app.requestSingleInstanceLock(); @@ -340,6 +350,11 @@ async function shutdownAndQuit(settingsPrepared = false): Promise { app.exit(1); return; } + try { + await stopAidenRemoteServiceAndSettle(); + } catch (error) { + logger.error("aiden-remote", "Remote Access did not stop cleanly.", error); + } cleanupApplication(); try { await Promise.all([ @@ -725,6 +740,79 @@ ipcMain.handle("app:resetOnboarding", async (event) => { return requestOnboardingReset(window); }); +ipcMain.handle("app:getOnboardingState", async (event, legacyComplete: unknown) => { + if ( + !mainWindow || + mainWindow.isDestroyed() || + event.sender.id !== mainWindow.webContents.id + ) { + throw new Error("Onboarding state is unavailable outside the active application window."); + } + const owner = rendererDocumentOwner( + event, + () => new Error("Onboarding state is unavailable outside the active application document."), + ); + return getOnboardingSnapshot(legacyComplete === true, () => !owner.isDestroyed()); +}); + +ipcMain.handle( + "app:setOnboardingOutcome", + async (event, outcome: unknown, selectedProviderId: unknown) => { + if ( + !mainWindow || + mainWindow.isDestroyed() || + event.sender.id !== mainWindow.webContents.id + ) { + throw new Error("Onboarding can only be changed from the active application window."); + } + if (outcome !== "incomplete" && outcome !== "completed") { + throw new Error("Invalid onboarding outcome."); + } + if ( + selectedProviderId !== undefined && + (typeof selectedProviderId !== "string" || + selectedProviderId.length === 0 || + selectedProviderId.length > 128) + ) { + throw new Error("Invalid onboarding provider selection."); + } + const owner = rendererDocumentOwner( + event, + () => new Error("Onboarding can only be changed from the active application document."), + ); + return setOnboardingOutcome(outcome, selectedProviderId, () => !owner.isDestroyed()); + }, +); + +ipcMain.handle( + "app:setOnboardingProgress", + async (event, step: unknown, selectedProviderId: unknown) => { + if ( + !mainWindow || + mainWindow.isDestroyed() || + event.sender.id !== mainWindow.webContents.id + ) { + throw new Error("Onboarding can only be changed from the active application window."); + } + if (step !== "profile" && step !== "provider") { + throw new Error("Invalid onboarding step."); + } + if ( + selectedProviderId !== undefined && + (typeof selectedProviderId !== "string" || + selectedProviderId.length === 0 || + selectedProviderId.length > 128) + ) { + throw new Error("Invalid onboarding provider selection."); + } + const owner = rendererDocumentOwner( + event, + () => new Error("Onboarding can only be changed from the active application document."), + ); + return setOnboardingProgress(step, selectedProviderId, () => !owner.isDestroyed()); + }, +); + ipcMain.handle("app:getUpdateState", (event) => { if ( !mainWindow || @@ -1605,6 +1693,16 @@ if (!ownsSingleInstanceLock) { ); powerMonitor.on("resume", () => void portableConfigWatcher.refresh()); + try { + await initializeAidenRemoteService(); + } catch (error) { + logger.error( + "aiden-remote", + "Remote Access could not restore its saved listener state; the desktop app will remain available for repair.", + error, + ); + } + await createMainWindow(); if (packagedSubagentSoak) { await runPackagedSubagentSoak(packagedSubagentSoak); diff --git a/main/services/aiden-remote-approved-roots.test.ts b/main/services/aiden-remote-approved-roots.test.ts new file mode 100644 index 00000000..de54f436 --- /dev/null +++ b/main/services/aiden-remote-approved-roots.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { AidenRemoteApprovedRootService } from "./aiden-remote-approved-roots.js"; +import { AidenRemoteStateRegistry, createDefaultAidenRemoteState } from "./aiden-remote-state.js"; + +async function fixture() { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-approved-root-")); + let document = createDefaultAidenRemoteState(); + const state = new AidenRemoteStateRegistry({ + load: async () => structuredClone(document), + save: async (next) => { document = structuredClone(next); }, + }); + const service = new AidenRemoteApprovedRootService(state, { + now: () => 12_345, + randomBytes: (size) => Buffer.alloc(size, 3), + homeDirectory: () => directory, + }); + return { directory, state, service }; +} + +test("approved roots canonicalize symlinks and persist filesystem identity without exposing it remotely", async () => { + const app = await fixture(); + try { + const folder = path.join(app.directory, "Workspace"); + const alias = path.join(app.directory, "Alias"); + await fs.mkdir(folder); + await fs.symlink(folder, alias); + const root = await app.service.addLocalFolder(alias); + assert.equal(root.folderPath, await fs.realpath(folder)); + assert.equal(root.label, "Workspace"); + assert.match(root.device, /^\d+$/u); + assert.match(root.inode, /^\d+$/u); + assert.equal(root.policyRevision, "remote-browser-v1:no-hidden-system"); + } finally { + await fs.rm(app.directory, { force: true, recursive: true }); + } +}); + +test("approved roots reject duplicates, nested overlap, files, and filesystem root", async () => { + const app = await fixture(); + try { + const parent = path.join(app.directory, "parent"); + const child = path.join(parent, "child"); + const file = path.join(app.directory, "file.txt"); + await fs.mkdir(child, { recursive: true }); + await fs.writeFile(file, "x"); + await app.service.addLocalFolder(parent); + await assert.rejects(app.service.addLocalFolder(parent), /already covered/u); + await assert.rejects(app.service.addLocalFolder(child), /already covered/u); + await assert.rejects(app.service.addLocalFolder(file), /must be a directory/u); + await assert.rejects(app.service.addLocalFolder(path.parse(app.directory).root), /filesystem root/u); + } finally { + await fs.rm(app.directory, { force: true, recursive: true }); + } +}); + +test("approving an entire home folder requires a separate local confirmation", async () => { + const app = await fixture(); + try { + await assert.rejects( + app.service.addLocalFolder(app.directory), + /requires local confirmation/u, + ); + const root = await app.service.addLocalFolder(app.directory, { confirmHomeDirectory: true }); + assert.equal(root.folderPath, await fs.realpath(app.directory)); + assert.equal(await app.service.removeLocalRoot(root.id), true); + } finally { + await fs.rm(app.directory, { force: true, recursive: true }); + } +}); diff --git a/main/services/aiden-remote-approved-roots.ts b/main/services/aiden-remote-approved-roots.ts new file mode 100644 index 00000000..193b63f9 --- /dev/null +++ b/main/services/aiden-remote-approved-roots.ts @@ -0,0 +1,79 @@ +import { randomBytes } from "node:crypto"; +import * as fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { + AidenRemoteApprovedRoot, + AidenRemoteStateRegistry, +} from "./aiden-remote-state.js"; + +export const AIDEN_REMOTE_ROOT_POLICY_REVISION = "remote-browser-v1:no-hidden-system"; + +export interface AidenRemoteApprovedRootDependencies { + now(): number; + randomBytes(size: number): Buffer; + homeDirectory(): string; +} + +function isWithin(parent: string, candidate: string): boolean { + const relative = path.relative(parent, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function displayLabel(folderPath: string): string { + return path.basename(folderPath) || folderPath; +} + +export class AidenRemoteApprovedRootService { + constructor( + private readonly state: Pick, + private readonly dependencies: AidenRemoteApprovedRootDependencies = { + now: Date.now, + randomBytes, + homeDirectory: os.homedir, + }, + ) {} + + async addLocalFolder( + selectedPath: string, + options: { confirmHomeDirectory?: boolean } = {}, + ): Promise { + if (!path.isAbsolute(selectedPath) || Buffer.byteLength(selectedPath, "utf8") > 4_096) { + throw new Error("Select an absolute local folder."); + } + const canonicalPath = await fs.realpath(selectedPath); + const metadata = await fs.stat(canonicalPath, { bigint: true }); + if (!metadata.isDirectory()) throw new Error("The approved root must be a directory."); + if (canonicalPath === path.parse(canonicalPath).root) { + throw new Error("The filesystem root cannot be approved for remote browsing."); + } + const canonicalHome = await fs.realpath(this.dependencies.homeDirectory()); + if (canonicalPath === canonicalHome && options.confirmHomeDirectory !== true) { + throw new Error("Approving the entire home directory requires local confirmation."); + } + + const existing = (await this.state.snapshot()).approvedRoots; + if (existing.some((root) => isWithin(root.folderPath, canonicalPath))) { + throw new Error("This folder is already covered by an approved root."); + } + if (existing.some((root) => isWithin(canonicalPath, root.folderPath))) { + throw new Error("This folder would overlap an existing approved root."); + } + + const root: AidenRemoteApprovedRoot = { + id: `root_${this.dependencies.randomBytes(24).toString("base64url")}`, + label: displayLabel(canonicalPath), + folderPath: canonicalPath, + device: metadata.dev.toString(), + inode: metadata.ino.toString(), + policyRevision: AIDEN_REMOTE_ROOT_POLICY_REVISION, + createdAt: this.dependencies.now(), + }; + await this.state.addApprovedRoot(root); + return root; + } + + async removeLocalRoot(rootId: string): Promise { + return this.state.removeApprovedRoot(rootId); + } +} diff --git a/main/services/aiden-remote-attachments.ts b/main/services/aiden-remote-attachments.ts new file mode 100644 index 00000000..3c2671dc --- /dev/null +++ b/main/services/aiden-remote-attachments.ts @@ -0,0 +1,373 @@ +import { randomBytes } from "node:crypto"; +import { + attachmentInlineBytes, + attachmentRepresentationBytes, + parseAttachments, +} from "./attachment-contract.js"; +import { + MAX_IMAGE_BYTES, + MAX_TEXT_CHARS, +} from "./attachments.js"; +import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; +import type { Attachment } from "./types.js"; +import { MAX_ATTACHMENT_INLINE_BYTES } from "../../renderer/shared/attachment-contract.js"; + +export const MAX_AIDEN_REMOTE_ATTACHMENTS_PER_TURN = 10; +export const MAX_AIDEN_REMOTE_ATTACHMENT_REQUEST_BYTES = 12 * 1_048_576; +export const AIDEN_REMOTE_ATTACHMENT_TTL_MS = 10 * 60 * 1_000; +const MAX_PENDING_ATTACHMENTS = 256; +const MAX_PENDING_ATTACHMENTS_PER_DEVICE = 40; +const MAX_PENDING_ATTACHMENTS_PER_CHAT = 20; +const MAX_PENDING_REPRESENTATION_BYTES = 64 * 1_048_576; +const MAX_REMOTE_IMAGE_DIMENSION = 16_384; +const MAX_REMOTE_IMAGE_PIXELS = 40_000_000; +const ATTACHMENT_ID = /^att_[A-Za-z0-9_-]{43}$/u; +const TEXT_MIME_TYPES = new Set([ + "text/plain", + "text/markdown", + "text/csv", + "application/json", + "application/xml", + "application/yaml", + "application/x-yaml", + "application/javascript", + "application/typescript", +]); + +export interface AidenRemoteAttachmentProjection { + id: string; + name: string; + mimeType: string; + kind: "image" | "text"; + size: number; + expiresAt: string; +} + +interface PendingAttachmentRecord { + id: string; + deviceId: string; + chatId: string; + attachment: Attachment; + expiresAt: number; + representationBytes: number; +} + +function ownRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function exactKeys(record: Record, expected: ReadonlySet): boolean { + let count = 0; + for (const key in record) { + if (!Object.prototype.hasOwnProperty.call(record, key)) continue; + count += 1; + if (count > expected.size || !expected.has(key)) return false; + } + return count === expected.size; +} + +function validDisplayName(value: unknown): value is string { + if (typeof value !== "string") return false; + const cleaned = value.trim(); + if (!cleaned || Array.from(cleaned).length > 255 || cleaned.includes("/") || cleaned.includes("\\")) { + return false; + } + for (let index = 0; index < cleaned.length; index += 1) { + const code = cleaned.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return false; + } + return true; +} + +function readUInt16BE(bytes: Buffer, offset: number): number | undefined { + return offset >= 0 && offset + 2 <= bytes.length ? bytes.readUInt16BE(offset) : undefined; +} + +function readUInt32BE(bytes: Buffer, offset: number): number | undefined { + return offset >= 0 && offset + 4 <= bytes.length ? bytes.readUInt32BE(offset) : undefined; +} + +function jpegDimensions(bytes: Buffer): [number, number] | undefined { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return undefined; + let offset = 2; + while (offset + 4 <= bytes.length) { + while (offset < bytes.length && bytes[offset] === 0xff) offset += 1; + if (offset >= bytes.length) return undefined; + const marker = bytes[offset]!; + offset += 1; + if (marker === 0xd9 || marker === 0xda) return undefined; + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue; + const segmentLength = readUInt16BE(bytes, offset); + if (segmentLength === undefined || segmentLength < 2 || offset + segmentLength > bytes.length) { + return undefined; + } + const isStartOfFrame = + (marker >= 0xc0 && marker <= 0xc3) || + (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || + (marker >= 0xcd && marker <= 0xcf); + if (isStartOfFrame) { + if (segmentLength < 7) return undefined; + const height = readUInt16BE(bytes, offset + 3); + const width = readUInt16BE(bytes, offset + 5); + return width && height ? [width, height] : undefined; + } + offset += segmentLength; + } + return undefined; +} + +function imageDimensions(bytes: Buffer, mimeType: string): [number, number] | undefined { + if (mimeType === "image/png") { + if ( + bytes.length < 24 || + bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a" || + bytes.subarray(12, 16).toString("ascii") !== "IHDR" + ) { + return undefined; + } + const width = readUInt32BE(bytes, 16); + const height = readUInt32BE(bytes, 20); + return width && height ? [width, height] : undefined; + } + if (mimeType === "image/jpeg") return jpegDimensions(bytes); + return undefined; +} + +function validateDimensions(bytes: Buffer, mimeType: string): void { + const dimensions = imageDimensions(bytes, mimeType); + if ( + !dimensions || + dimensions[0] > MAX_REMOTE_IMAGE_DIMENSION || + dimensions[1] > MAX_REMOTE_IMAGE_DIMENSION || + dimensions[0] * dimensions[1] > MAX_REMOTE_IMAGE_PIXELS + ) { + throw new AidenRemoteServiceError( + "invalid_request", + "The image dimensions are invalid or too large.", + 400, + ); + } +} + +function parseUpload(input: unknown, id: string): Attachment { + const record = ownRecord(input); + if (!record || !validDisplayName(record.name) || typeof record.kind !== "string") { + throw new AidenRemoteServiceError("invalid_request", "The attachment upload is invalid.", 400); + } + const name = record.name.trim(); + try { + if (record.kind === "image") { + if ( + !exactKeys(record, new Set(["name", "mimeType", "kind", "data"])) || + (record.mimeType !== "image/png" && record.mimeType !== "image/jpeg") || + typeof record.data !== "string" || + record.data.length === 0 || + record.data.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4 + ) { + throw new Error("invalid image envelope"); + } + const bytes = Buffer.from(record.data, "base64"); + const attachment = parseAttachments([{ + id, + name, + mimeType: record.mimeType, + kind: "image", + size: bytes.length, + data: record.data, + }])?.[0]; + if (!attachment) throw new Error("invalid image attachment"); + validateDimensions(bytes, attachment.mimeType); + return attachment; + } + + if ( + record.kind !== "text" || + !exactKeys(record, new Set(["name", "mimeType", "kind", "text"])) || + typeof record.mimeType !== "string" || + !TEXT_MIME_TYPES.has(record.mimeType.toLowerCase()) || + typeof record.text !== "string" || + Array.from(record.text).length > MAX_TEXT_CHARS || + Buffer.byteLength(record.text, "utf8") > MAX_TEXT_CHARS * 4 + ) { + throw new Error("invalid text envelope"); + } + const attachment = parseAttachments([{ + id, + name, + mimeType: record.mimeType.toLowerCase(), + kind: "text", + size: Buffer.byteLength(record.text, "utf8"), + text: record.text, + }])?.[0]; + if (!attachment) throw new Error("invalid text attachment"); + return attachment; + } catch (error) { + if (error instanceof AidenRemoteServiceError) throw error; + throw new AidenRemoteServiceError( + "invalid_request", + "The attachment data does not match its declared type or limits.", + 400, + ); + } +} + +function projection(record: PendingAttachmentRecord): AidenRemoteAttachmentProjection { + return { + id: record.id, + name: record.attachment.name, + mimeType: record.attachment.mimeType, + kind: record.attachment.kind, + size: record.attachment.size, + expiresAt: new Date(record.expiresAt).toISOString(), + }; +} + +export class AidenRemoteAttachmentStore { + private readonly records = new Map(); + private retainedRepresentationBytes = 0; + + constructor(private readonly options: { + now?: () => number; + randomId?: () => string; + maxEntries?: number; + maxRepresentationBytes?: number; + } = {}) {} + + upload(deviceId: string, chatId: string, input: unknown): AidenRemoteAttachmentProjection { + const now = this.now(); + this.prune(now); + const id = this.nextId(); + const attachment = parseUpload(input, id); + const representationBytes = attachmentRepresentationBytes([attachment]); + const maxEntries = this.options.maxEntries ?? MAX_PENDING_ATTACHMENTS; + const maxRepresentationBytes = + this.options.maxRepresentationBytes ?? MAX_PENDING_REPRESENTATION_BYTES; + let deviceCount = 0; + let chatCount = 0; + for (const record of this.records.values()) { + if (record.deviceId === deviceId) deviceCount += 1; + if (record.deviceId === deviceId && record.chatId === chatId) chatCount += 1; + } + if ( + this.records.size >= maxEntries || + deviceCount >= MAX_PENDING_ATTACHMENTS_PER_DEVICE || + chatCount >= MAX_PENDING_ATTACHMENTS_PER_CHAT || + representationBytes > maxRepresentationBytes - this.retainedRepresentationBytes + ) { + throw new AidenRemoteServiceError( + "handle_capacity", + "Aiden's temporary attachment capacity is full. Try again shortly.", + 429, + true, + ); + } + const record: PendingAttachmentRecord = { + id, + deviceId, + chatId, + attachment, + expiresAt: now + AIDEN_REMOTE_ATTACHMENT_TTL_MS, + representationBytes, + }; + this.records.set(id, record); + this.retainedRepresentationBytes += representationBytes; + return projection(record); + } + + consume(deviceId: string, chatId: string, input: unknown): Attachment[] | undefined { + if (input === undefined) return undefined; + if ( + !Array.isArray(input) || + input.length === 0 || + input.length > MAX_AIDEN_REMOTE_ATTACHMENTS_PER_TURN + ) { + throw new AidenRemoteServiceError("invalid_request", "The attachment references are invalid.", 400); + } + const ids: string[] = []; + const unique = new Set(); + for (const candidate of input) { + if (typeof candidate !== "string" || !ATTACHMENT_ID.test(candidate) || unique.has(candidate)) { + throw new AidenRemoteServiceError("invalid_request", "The attachment references are invalid.", 400); + } + unique.add(candidate); + ids.push(candidate); + } + + const now = this.now(); + const selected: PendingAttachmentRecord[] = []; + for (const id of ids) { + const record = this.records.get(id); + if (!record) { + throw new AidenRemoteServiceError("handle_invalid", "That attachment is no longer available.", 409); + } + if (record.expiresAt <= now) { + this.removeRecord(record); + throw new AidenRemoteServiceError("handle_expired", "That attachment expired. Attach it again.", 409); + } + if (record.deviceId !== deviceId) { + throw new AidenRemoteServiceError("handle_wrong_device", "That attachment belongs to another device.", 403); + } + if (record.chatId !== chatId) { + throw new AidenRemoteServiceError("handle_invalid", "That attachment belongs to another chat.", 409); + } + selected.push(record); + } + const attachments = selected.map((record) => structuredClone(record.attachment)); + if (attachmentInlineBytes(attachments) > MAX_ATTACHMENT_INLINE_BYTES) { + throw new AidenRemoteServiceError("payload_too_large", "The attachments exceed the turn limit.", 413); + } + for (const record of selected) this.removeRecord(record); + this.prune(now); + return attachments; + } + + remove(deviceId: string, chatId: string, id: string): void { + if (!ATTACHMENT_ID.test(id)) { + throw new AidenRemoteServiceError("invalid_request", "The attachment reference is invalid.", 400); + } + const record = this.records.get(id); + if (!record) return; + if (record.deviceId !== deviceId) { + throw new AidenRemoteServiceError("handle_wrong_device", "That attachment belongs to another device.", 403); + } + if (record.chatId !== chatId) { + throw new AidenRemoteServiceError("handle_invalid", "That attachment belongs to another chat.", 409); + } + this.removeRecord(record); + } + + revokeDevice(deviceId: string): void { + for (const record of [...this.records.values()]) { + if (record.deviceId === deviceId) this.removeRecord(record); + } + } + + private now(): number { + return this.options.now?.() ?? Date.now(); + } + + private nextId(): string { + const id = this.options.randomId?.() ?? `att_${randomBytes(32).toString("base64url")}`; + if (!ATTACHMENT_ID.test(id) || this.records.has(id)) { + throw new AidenRemoteServiceError("internal_error", "Aiden could not allocate an attachment reference.", 500); + } + return id; + } + + private prune(now: number): void { + for (const record of [...this.records.values()]) { + if (record.expiresAt <= now) this.removeRecord(record); + } + } + + private removeRecord(record: PendingAttachmentRecord): void { + if (!this.records.delete(record.id)) return; + this.retainedRepresentationBytes = Math.max( + 0, + this.retainedRepresentationBytes - record.representationBytes, + ); + } +} diff --git a/main/services/aiden-remote-chat-http.test.ts b/main/services/aiden-remote-chat-http.test.ts new file mode 100644 index 00000000..caf296bf --- /dev/null +++ b/main/services/aiden-remote-chat-http.test.ts @@ -0,0 +1,260 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import test from "node:test"; +import type { Chat, ChatMessage } from "./types.js"; +import { AidenRemoteChatService } from "./aiden-remote-chats.js"; +import { createAidenRemoteRequestHandler } from "./aiden-remote-router.js"; +import { AidenRemoteStreamService } from "./aiden-remote-streams.js"; + +const ONE_PIXEL_PNG = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL2aQAAAABJRU5ErkJggg=="; + +test("HTTP client resumes, approves, denies, and cancels device-owned mocked turns without duplicate messages", async () => { + let chat: Chat = { + id: "chat-1", + title: "Remote chat", + workspaceId: "workspace-1", + providerId: "provider-1", + model: "model-1", + createdAt: 1_000, + updatedAt: 2_000, + messages: [], + }; + let turnNumber = 0; + let appendCount = 0; + const ownerByStream = new Map(); + const approvalToStream = new Map(); + + const streams = new AidenRemoteStreamService({ + now: Date.now, + cancel: (streamId) => { + ownerByStream.get(streamId)?.send("chat:done", { streamId, content: "" }); + return true; + }, + approve: (approvalId, decision) => { + const streamId = approvalToStream.get(approvalId); + const owner = streamId ? ownerByStream.get(streamId) : undefined; + if (!streamId || !owner) return false; + const assistant: ChatMessage = { + id: `assistant-${turnNumber}`, + role: "assistant", + content: decision === "allow" ? "Allowed" : "Denied", + createdAt: Date.now(), + }; + chat.messages.push(assistant); + chat.updatedAt += 1; + owner.send("chat:delta", { streamId, delta: assistant.content }); + owner.send("chat:done", { streamId, chat: structuredClone(chat) }); + return true; + }, + }); + const chats = new AidenRemoteChatService({ + application: { + list: async () => [structuredClone(chat)], + get: async () => ({ chat: structuredClone(chat), reconciliation: null }), + create: async () => structuredClone(chat), + rename: async (_id, title, options) => { + await options?.assertCurrent?.(chat); + chat.title = title; + chat.updatedAt += 1; + return structuredClone(chat); + }, + moveEmptyToWorkspace: async () => structuredClone(chat), + remove: async () => undefined, + }, + chatStore: { + get: async () => structuredClone(chat), + appendMessage: async (_id, message, meta) => { + if (!meta?.isCurrent?.()) throw new Error("turn expired"); + appendCount += 1; + chat.messages.push({ + id: message.id!, + role: message.role, + content: message.content, + createdAt: Date.now(), + ...(message.attachments ? { attachments: structuredClone(message.attachments) } : {}), + }); + chat.updatedAt += 1; + return structuredClone(chat); + }, + }, + generation: { + beginChatTurn: () => ({ + isActive: () => true, + reserveAppendPayload: () => undefined, + settleAsyncWork: () => undefined, + onReleased: () => undefined, + release: () => undefined, + }), + start: async (streamId, params, owner, options) => { + turnNumber += 1; + ownerByStream.set(streamId, owner); + options.onTurnAccepted(); + owner.send("chat:delta", { streamId, delta: "Working" }); + if ( + !params.messages.length && + !chat.messages[chat.messages.length - 1]?.content.includes("Cancel") + ) { + const approvalId = `approval-${turnNumber}`; + approvalToStream.set(approvalId, streamId); + owner.send("chat:approval", { + streamId, + approvalId, + summary: "Change the workspace", + }); + } + return true; + }, + }, + streams, + models: { + resolve: async () => ({ providerId: "provider-1", modelId: "model-1", thinkingLevels: [] }), + }, + }); + + const handler = createAidenRemoteRequestHandler({ + instanceId: "instance-1", + displayName: () => "Studio Mac", + appVersion: "0.30.0", + devices: { + acquireDeviceAuthorization: () => () => undefined, + authenticate: async (credential) => credential === "a".repeat(43) + ? { + id: "device-1", + revoked: false, + capabilities: new Set(["chat:read", "chat:write", "approval:respond"] as const), + } + : null, + }, + pairing: { exchange: async () => { throw new Error("unused"); } }, + chats, + streams, + models: { list: async () => ({ providers: [], defaults: {} }) }, + connectionMode: () => "lan", + now: Date.now, + log: () => undefined, + }); + const server = createServer(handler); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("server did not bind"); + const base = `http://127.0.0.1:${address.port}/api/aiden/v1`; + const headers = { + authorization: `Bearer ${"a".repeat(43)}`, + "aiden-protocol-version": "1", + }; + const start = async (text: string, key: string, attachmentIds?: string[]) => { + const response = await fetch(`${base}/chats/chat-1/turns`, { + method: "POST", + headers: { ...headers, "content-type": "application/json", "idempotency-key": key }, + body: JSON.stringify({ text, ...(attachmentIds ? { attachmentIds } : {}) }), + }); + assert.equal(response.status, 202); + return response.json() as Promise<{ + streamId: string; + message: { id: string; attachments?: { id: string; name: string }[] }; + }>; + }; + + try { + const uploadedResponse = await fetch(`${base}/chats/chat-1/attachments`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ + name: "proof.png", + mimeType: "image/png", + kind: "image", + data: ONE_PIXEL_PNG, + }), + }); + assert.equal(uploadedResponse.status, 201); + const uploaded = await uploadedResponse.json() as { id: string; name: string }; + assert.match(uploaded.id, /^att_[A-Za-z0-9_-]{43}$/u); + assert.equal(uploaded.name, "proof.png"); + assert.equal(JSON.stringify(uploaded).includes("contents"), false); + + const discardedResponse = await fetch(`${base}/chats/chat-1/attachments`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ + name: "discard.txt", + mimeType: "text/plain", + kind: "text", + text: "discard me", + }), + }); + assert.equal(discardedResponse.status, 201); + const discarded = await discardedResponse.json() as { id: string }; + const removed = await fetch(`${base}/chats/chat-1/attachments/${discarded.id}`, { + method: "DELETE", + headers, + }); + assert.equal(removed.status, 204); + + const first = await start("Approve this", "turn-http-key-000001", [uploaded.id]); + const replay = await start("Approve this", "turn-http-key-000001", [uploaded.id]); + assert.deepEqual(replay, first); + assert.deepEqual(first.message.attachments?.map(({ id, name }) => ({ id, name })), [ + { id: uploaded.id, name: "proof.png" }, + ]); + const attachmentContent = await fetch( + `${base}/chats/chat-1/attachments/${uploaded.id}/content`, + { headers }, + ); + assert.equal(attachmentContent.status, 200); + assert.equal(attachmentContent.headers.get("content-type"), "image/png"); + assert.equal(attachmentContent.headers.get("cache-control"), "no-store"); + assert.equal( + Buffer.from(await attachmentContent.arrayBuffer()).toString("base64"), + ONE_PIXEL_PNG, + ); + const unauthenticatedContent = await fetch( + `${base}/chats/chat-1/attachments/${uploaded.id}/content`, + { headers: { "aiden-protocol-version": "1" } }, + ); + assert.equal(unauthenticatedContent.status, 401); + assert.equal(appendCount, 1); + const waiting = await fetch(`${base}/streams/${first.streamId}`, { headers }); + assert.equal((await waiting.json()).state, "waiting_for_approval"); + + const allowed = await fetch(`${base}/approvals/approval-1/respond`, { + method: "POST", + headers: { ...headers, "content-type": "application/json", "idempotency-key": "allow-http-key-000001" }, + body: JSON.stringify({ decision: "allow" }), + }); + assert.equal(allowed.status, 200); + const firstEvents = await fetch(`${base}/streams/${first.streamId}/events?after=1`, { headers }); + const firstReplay = await firstEvents.text(); + assert.match(firstReplay, /event: approval_required/u); + assert.match(firstReplay, /event: done/u); + const eventIds = [...firstReplay.matchAll(/^id: (\d+)$/gmu)].map((match) => Number(match[1])); + const lastEventId = eventIds[eventIds.length - 1]!; + const terminalReplay = await fetch(`${base}/streams/${first.streamId}/events`, { + headers: { ...headers, "last-event-id": String(lastEventId - 1) }, + }); + assert.match(await terminalReplay.text(), /event: done/u); + + const second = await start("Deny this", "turn-http-key-000002"); + const denied = await fetch(`${base}/approvals/approval-2/respond`, { + method: "POST", + headers: { ...headers, "content-type": "application/json", "idempotency-key": "deny-http-key-000001" }, + body: JSON.stringify({ decision: "deny" }), + }); + assert.equal(denied.status, 200); + assert.equal(streams.status("device-1", second.streamId).state, "done"); + + const third = await start("Cancel this", "turn-http-key-000003"); + const cancelled = await fetch(`${base}/streams/${third.streamId}/cancel`, { + method: "POST", + headers: { ...headers, "idempotency-key": "cancel-http-key-0001" }, + }); + assert.equal(cancelled.status, 202); + assert.equal(streams.status("device-1", third.streamId).state, "cancelled"); + assert.equal(appendCount, 3); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); diff --git a/main/services/aiden-remote-chats.test.ts b/main/services/aiden-remote-chats.test.ts new file mode 100644 index 00000000..8cd92239 --- /dev/null +++ b/main/services/aiden-remote-chats.test.ts @@ -0,0 +1,654 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import test from "node:test"; +import type { Chat, ChatMessage } from "./types.js"; +import { AidenRemoteChatService, projectAidenRemoteChat } from "./aiden-remote-chats.js"; +import { AidenRemoteStreamService } from "./aiden-remote-streams.js"; +import { + AIDEN_REMOTE_ATTACHMENT_TTL_MS, + AidenRemoteAttachmentStore, +} from "./aiden-remote-attachments.js"; + +const ONE_PIXEL_PNG = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL2aQAAAABJRU5ErkJggg=="; +const ONE_PIXEL_GIF = "R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="; + +function chat(overrides: Partial = {}): Chat { + return { + id: "chat-1", + title: "New chat", + workspaceId: "workspace-1", + providerId: "provider-1", + model: "model-1", + createdAt: 1_000, + updatedAt: 2_000, + messages: [], + ...overrides, + }; +} + +function fixture( + initial = chat(), + fixtureOptions: { + startThrows?: boolean; + attachments?: AidenRemoteAttachmentStore; + isTitlePending?: (chatId: string) => boolean; + } = {}, +) { + let current: Chat | null = structuredClone(initial); + let creates = 0; + let appends = 0; + let notifications = 0; + const streams = new AidenRemoteStreamService({ + now: () => 10_000, + cancel: () => true, + approve: () => true, + }); + const service = new AidenRemoteChatService({ + application: { + list: async () => current ? [structuredClone(current)] : [], + get: async () => ({ chat: current ? structuredClone(current) : null, reconciliation: null }), + create: async (input) => { + creates += 1; + current = chat({ + id: `chat-${creates + 1}`, + workspaceId: input.workspaceId, + providerId: input.providerId, + model: input.model, + }); + return structuredClone(current); + }, + rename: async (_id, title, options) => { + if (!current) throw new Error("missing"); + await options?.assertCurrent?.(current); + current.title = title; + current.updatedAt += 1; + return structuredClone(current); + }, + moveEmptyToWorkspace: async (_id, workspaceId, options) => { + if (!current) throw new Error("missing"); + await options?.assertCurrent?.(current); + if (current.messages.length) throw new Error("not empty"); + current.workspaceId = workspaceId; + current.updatedAt += 1; + return structuredClone(current); + }, + remove: async (_id, options) => { + if (!current) throw new Error("missing"); + await options?.assertCurrent?.(current); + current = null; + }, + }, + chatStore: { + get: async () => current ? structuredClone(current) : null, + appendMessage: async (_id, message, meta) => { + if (!current || !meta?.isCurrent?.()) throw new Error("stale"); + appends += 1; + const stored: ChatMessage = { + id: message.id!, + role: message.role, + content: message.content, + createdAt: 3_000, + ...(message.attachments ? { attachments: structuredClone(message.attachments) } : {}), + }; + current.messages.push(stored); + current.providerId = meta.providerId; + current.model = meta.model; + current.updatedAt += 1; + return structuredClone(current); + }, + }, + generation: { + beginChatTurn: (_chatId, _turnId, _ownerId) => { + let active = true; + return { + isActive: () => active, + reserveAppendPayload: () => undefined, + settleAsyncWork: () => undefined, + onReleased: () => undefined, + release: () => { active = false; }, + }; + }, + start: async (streamId, _params, owner, generationOptions) => { + if (fixtureOptions.startThrows) throw new Error("provider setup failed"); + generationOptions.onTurnAccepted(); + owner.send("chat:delta", { streamId, delta: "Answer" }); + const assistant: ChatMessage = { + id: "assistant-1", + role: "assistant", + content: "Answer", + createdAt: 4_000, + reasoning: "private-safe-reasoning", + pi: { role: "assistant", content: [], api: "openai-responses", provider: "openai", model: "model-1", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: "stop", timestamp: 4_000 }, + }; + current?.messages.push(assistant); + owner.send("chat:done", { streamId, chat: current }); + return true; + }, + }, + streams, + models: { + resolve: async () => ({ providerId: "provider-1", modelId: "model-1", thinkingLevels: ["low", "high"] }), + }, + ...(fixtureOptions.attachments ? { attachments: fixtureOptions.attachments } : {}), + ...(fixtureOptions.isTitlePending ? { isTitlePending: fixtureOptions.isTitlePending } : {}), + notifyChanged: () => { notifications += 1; }, + }); + return { + service, + streams, + creates: () => creates, + appends: () => appends, + notifications: () => notifications, + current: () => current ? structuredClone(current) : null, + }; +} + +test("chat projection is path-free and excludes private Pi protocol and reasoning", () => { + const projection = projectAidenRemoteChat(chat({ + messages: [{ + id: "user-1", + role: "user", + content: "Hello", + reasoning: "hidden", + createdAt: 2_000, + attachments: [{ + id: "/Users/private/attachment-id", + name: "/Users/private/notes.txt", + mimeType: "text/plain", + kind: "text", + size: 5, + text: "hello", + }], + }], + })); + assert.deepEqual(projection.messages[0], { + id: "user-1", + role: "user", + text: "Hello", + createdAt: new Date(2_000).toISOString(), + attachments: [{ + id: `legacy_${createHash("sha256").update("/Users/private/attachment-id").digest("base64url")}`, + name: "notes.txt", + mimeType: "text/plain", + kind: "text", + size: 5, + }], + }); + assert.equal(JSON.stringify(projection).includes("reasoning"), false); + assert.equal(JSON.stringify(projection).includes("/Users/private"), false); + assert.match(projection.revision, /^rev_[A-Za-z0-9_-]{43}$/u); +}); + +test("chat projection preserves only renderer-safe exceptional outcomes", () => { + const projection = projectAidenRemoteChat(chat({ + messages: [ + { + id: "assistant-failed", + role: "assistant", + content: "Partial answer", + createdAt: 2_000, + providerFailure: { + version: 1, + category: "service_unavailable", + attempts: 2, + retryExhausted: true, + }, + }, + { + id: "assistant-cancelled", + role: "assistant", + content: "Stopped answer", + createdAt: 3_000, + timeline: { + version: 3, + generationId: "stream-safe", + status: "cancelled", + startedAt: 1_000, + finishedAt: 2_000, + cancellationOrigin: "user_stop", + steps: [], + }, + }, + { + id: "assistant-complete", + role: "assistant", + content: "Complete answer", + createdAt: 4_000, + timeline: { + version: 3, + generationId: "stream-complete", + status: "completed", + startedAt: 1_000, + finishedAt: 2_000, + steps: [], + }, + }, + ], + })); + + assert.deepEqual(projection.messages.map((message) => message.outcome), [ + { + status: "failed", + category: "service_unavailable", + attempts: 2, + retryExhausted: true, + }, + { status: "cancelled" }, + undefined, + ]); + assert.notEqual(projection.revision, projectAidenRemoteChat(chat()).revision); +}); + +test("chat projection retains only the sanitized activity timeline", () => { + const safeTimeline = { + version: 3 as const, + generationId: "stream-safe", + status: "completed" as const, + startedAt: 1_000, + finishedAt: 2_000, + steps: [{ + id: "tool-1", + order: 0, + kind: "tool" as const, + toolCallId: "call-1", + toolName: "read_file", + label: "Read file", + status: "completed" as const, + startedAt: 1_000, + updatedAt: 2_000, + finishedAt: 2_000, + contentOffset: 0, + target: "README.md", + }], + }; + const projection = projectAidenRemoteChat(chat({ + messages: [ + { id: "assistant-safe", role: "assistant", content: "Done", createdAt: 2_000, timeline: safeTimeline }, + { + id: "assistant-unsafe", + role: "assistant", + content: "No leak", + createdAt: 3_000, + timeline: { ...safeTimeline, steps: [{ ...safeTimeline.steps[0], target: "/Users/private/secret" }] }, + }, + ], + })); + assert.deepEqual(projection.messages[0]?.timeline, safeTimeline); + assert.equal(projection.messages[1]?.timeline, undefined); + assert.doesNotMatch(JSON.stringify(projection), /Users\/private/u); +}); + +test("chat reads expose an in-flight background title without changing the revision", async () => { + let pending = true; + const app = fixture(chat(), { isTitlePending: () => pending }); + + const whilePending = await app.service.get("chat-1"); + assert.equal(whilePending.titlePending, true); + const revision = whilePending.revision; + + pending = false; + const settled = await app.service.get("chat-1"); + assert.equal("titlePending" in settled, false); + assert.equal(settled.revision, revision); +}); + +test("chat create is device-scoped idempotent and CRUD checks exact revisions", async () => { + const app = fixture(); + const key = "chat-create-key-00001"; + const created = await app.service.create("device-1", key, { workspaceId: "workspace-1" }); + assert.deepEqual(await app.service.create("device-1", key, { workspaceId: "workspace-1" }), created); + assert.equal(app.creates(), 1); + await assert.rejects( + app.service.rename(created.id, "rev_stale", { title: "Changed" }), + (error: unknown) => (error as { code?: string }).code === "revision_conflict", + ); + const renamed = await app.service.rename(created.id, created.revision, { title: "Changed" }); + assert.equal(renamed.title, "Changed"); + await app.service.remove(created.id, renamed.revision); + assert.equal(app.notifications(), 3); +}); + +test("remote turn atomically appends once, owns its stream, and replays the accepted response", async () => { + const app = fixture(); + const key = "turn-start-key-000001"; + const first = await app.service.startTurn("device-1", "chat-1", key, { text: "Hello" }); + const replay = await app.service.startTurn("device-1", "chat-1", key, { text: "Hello" }); + assert.deepEqual(replay, first); + assert.equal(app.appends(), 1); + assert.equal(first.message.text, "Hello"); + const status = app.streams.status("device-1", first.streamId); + assert.equal(status.state, "done"); + assert.throws( + () => app.streams.status("device-2", first.streamId), + (error: unknown) => (error as { code?: string }).code === "not_found", + ); +}); + +test("a provider setup failure after append returns the one accepted message with a terminal error stream", async () => { + const app = fixture(chat(), { startThrows: true }); + const accepted = await app.service.startTurn( + "device-1", + "chat-1", + "turn-failure-key-0001", + { text: "Keep this once" }, + ); + assert.equal(accepted.status, "accepted"); + assert.equal(accepted.message.text, "Keep this once"); + assert.equal(app.appends(), 1); + assert.equal(app.streams.status("device-1", accepted.streamId).state, "error"); + assert.deepEqual( + await app.service.startTurn("device-1", "chat-1", "turn-failure-key-0001", { text: "Keep this once" }), + accepted, + ); + assert.equal(app.appends(), 1); +}); + +test("remote attachments are one-use, bounded, and projected without inline contents", async () => { + let sequence = 0; + const attachments = new AidenRemoteAttachmentStore({ + now: () => 10_000, + randomId: () => `att_${String(sequence++).padStart(43, "A")}`, + }); + const app = fixture(chat(), { attachments }); + const image = await app.service.uploadAttachment("device-1", "chat-1", { + name: "diagram.png", + mimeType: "image/png", + kind: "image", + data: ONE_PIXEL_PNG, + }); + const text = await app.service.uploadAttachment("device-1", "chat-1", { + name: "notes.md", + mimeType: "text/markdown", + kind: "text", + text: "# Notes", + }); + + const accepted = await app.service.startTurn( + "device-1", + "chat-1", + "turn-attachments-0001", + { text: "", attachmentIds: [image.id, text.id] }, + ); + assert.deepEqual(accepted.message.attachments, [ + { id: image.id, name: "diagram.png", mimeType: "image/png", kind: "image", size: 70 }, + { id: text.id, name: "notes.md", mimeType: "text/markdown", kind: "text", size: 7 }, + ]); + const serialized = JSON.stringify(accepted.message); + assert.equal(serialized.includes(ONE_PIXEL_PNG), false); + assert.equal(serialized.includes("# Notes"), false); + assert.deepEqual(app.current()?.messages[0]?.attachments?.map(({ name, kind }) => ({ name, kind })), [ + { name: "diagram.png", kind: "image" }, + { name: "notes.md", kind: "text" }, + ]); + const imageContent = await app.service.attachmentContent("chat-1", image.id); + assert.equal(imageContent.mimeType, "image/png"); + assert.equal(imageContent.bytes.toString("base64"), ONE_PIXEL_PNG); + await assert.rejects( + app.service.attachmentContent("chat-1", text.id), + (error: unknown) => (error as { code?: string }).code === "not_found", + ); + await assert.rejects( + app.service.attachmentContent("chat-2", image.id), + (error: unknown) => (error as { code?: string }).code === "not_found", + ); + await assert.rejects( + app.service.attachmentContent("chat-1", "missing-attachment"), + (error: unknown) => (error as { code?: string }).code === "not_found", + ); + await assert.rejects( + app.service.startTurn("device-1", "chat-1", "turn-attachments-0002", { + text: "Do not reuse", + attachmentIds: [image.id], + }), + (error: unknown) => (error as { code?: string }).code === "handle_invalid", + ); +}); + +test("attachment content fails closed when projected identifiers are ambiguous", async () => { + const duplicate = "attachment-duplicate"; + const attachment = { + id: duplicate, + name: "duplicate.png", + mimeType: "image/png", + kind: "image" as const, + size: 70, + data: ONE_PIXEL_PNG, + }; + const app = fixture(chat({ + messages: [{ + id: "message-1", + role: "user", + content: "Two copies", + createdAt: 1_500, + attachments: [attachment, { ...attachment }], + }], + })); + await assert.rejects( + app.service.attachmentContent("chat-1", duplicate), + (error: unknown) => (error as { code?: string }).code === "not_found", + ); +}); + +test("attachment content never exposes images from hidden message roles", async () => { + const app = fixture(chat({ + messages: [{ + id: "system-image-message", + role: "system", + content: "private", + createdAt: 1_500, + attachments: [{ + id: "hidden-system-image", + name: "hidden.png", + mimeType: "image/png", + kind: "image", + size: Buffer.from(ONE_PIXEL_PNG, "base64").byteLength, + data: ONE_PIXEL_PNG, + }], + }], + })); + await assert.rejects( + app.service.attachmentContent("chat-1", "hidden-system-image"), + (error: unknown) => (error as { code?: string }).code === "not_found", + ); +}); + +test("assistant image attachments project to paired clients and retain authenticated content", async () => { + const imageBytes = Buffer.from(ONE_PIXEL_PNG, "base64"); + const app = fixture(chat({ + messages: [{ + id: "assistant-image-message", + role: "assistant", + content: "Here it is.", + createdAt: 1_500, + attachments: [{ + id: "assistant-shared-image", + name: "Result.png", + mimeType: "image/png", + kind: "image", + size: imageBytes.length, + data: ONE_PIXEL_PNG, + }], + }], + })); + const projected = await app.service.get("chat-1"); + assert.deepEqual(projected.messages[0]?.attachments, [{ + id: "assistant-shared-image", + name: "Result.png", + mimeType: "image/png", + kind: "image", + size: imageBytes.length, + }]); + const content = await app.service.attachmentContent("chat-1", "assistant-shared-image"); + assert.equal(content.mimeType, "image/png"); + assert.deepEqual(content.bytes, imageBytes); +}); + +test("attachment content rejects stored raster bytes that do not match their MIME type", async () => { + const app = fixture(chat({ + messages: [{ + id: "message-1", + role: "assistant", + content: "Generated image", + createdAt: 1_500, + attachments: [{ + id: "mismatched-image", + name: "mismatched.jpg", + mimeType: "image/jpeg", + kind: "image", + size: 70, + data: ONE_PIXEL_PNG, + }], + }], + })); + await assert.rejects( + app.service.attachmentContent("chat-1", "mismatched-image"), + (error: unknown) => (error as { code?: string }).code === "not_found", + ); +}); + +test("attachment content rejects canonical raster formats outside the public PNG and JPEG contract", async () => { + const app = fixture(chat({ + messages: [{ + id: "message-1", + role: "assistant", + content: "Generated animation", + createdAt: 1_500, + attachments: [{ + id: "unsupported-gif", + name: "animation.gif", + mimeType: "image/gif", + kind: "image", + size: Buffer.from(ONE_PIXEL_GIF, "base64").byteLength, + data: ONE_PIXEL_GIF, + }], + }], + })); + await assert.rejects( + app.service.attachmentContent("chat-1", "unsupported-gif"), + (error: unknown) => (error as { code?: string }).code === "not_found", + ); +}); + +test("attachment content rejects a truncated image even when its header and metadata remain readable", async () => { + const complete = Buffer.from(ONE_PIXEL_PNG, "base64"); + const truncated = complete.subarray(0, complete.length - 12); + const app = fixture(chat({ + messages: [{ + id: "message-1", + role: "assistant", + content: "Incomplete image", + createdAt: 1_500, + attachments: [{ + id: "truncated-image", + name: "truncated.png", + mimeType: "image/png", + kind: "image", + size: truncated.byteLength, + data: truncated.toString("base64"), + }], + }], + })); + await assert.rejects( + app.service.attachmentContent("chat-1", "truncated-image"), + (error: unknown) => (error as { code?: string }).code === "not_found", + ); +}); + +test("attachment references enforce device, chat, expiry, revocation, dimensions, and capacity", () => { + let now = 20_000; + let sequence = 0; + const store = new AidenRemoteAttachmentStore({ + now: () => now, + randomId: () => `att_${String(sequence++).padStart(43, "B")}`, + maxEntries: 2, + }); + const first = store.upload("device-1", "chat-1", { + name: "one.txt", + mimeType: "text/plain", + kind: "text", + text: "one", + }); + assert.throws( + () => store.consume("device-2", "chat-1", [first.id]), + (error: unknown) => (error as { code?: string }).code === "handle_wrong_device", + ); + assert.throws( + () => store.consume("device-1", "chat-2", [first.id]), + (error: unknown) => (error as { code?: string }).code === "handle_invalid", + ); + assert.throws( + () => store.consume("device-1", "chat-1", [first.id, first.id]), + (error: unknown) => (error as { code?: string }).code === "invalid_request", + ); + assert.throws( + () => store.consume( + "device-1", + "chat-1", + Array.from({ length: 11 }, (_, index) => `att_${String(index).padStart(43, "D")}`), + ), + (error: unknown) => (error as { code?: string }).code === "invalid_request", + ); + now += AIDEN_REMOTE_ATTACHMENT_TTL_MS; + assert.throws( + () => store.consume("device-1", "chat-1", [first.id]), + (error: unknown) => (error as { code?: string }).code === "handle_expired", + ); + + const revoked = store.upload("device-1", "chat-1", { + name: "revoked.txt", + mimeType: "text/plain", + kind: "text", + text: "private", + }); + store.revokeDevice("device-1"); + assert.throws( + () => store.consume("device-1", "chat-1", [revoked.id]), + (error: unknown) => (error as { code?: string }).code === "handle_invalid", + ); + + const oversizedDimensions = Buffer.alloc(24); + Buffer.from("89504e470d0a1a0a", "hex").copy(oversizedDimensions, 0); + Buffer.from("IHDR", "ascii").copy(oversizedDimensions, 12); + oversizedDimensions.writeUInt32BE(20_000, 16); + oversizedDimensions.writeUInt32BE(1, 20); + assert.throws( + () => store.upload("device-1", "chat-1", { + name: "huge.png", + mimeType: "image/png", + kind: "image", + data: oversizedDimensions.toString("base64"), + }), + (error: unknown) => (error as { code?: string }).code === "invalid_request", + ); + assert.throws( + () => store.upload("device-1", "chat-1", { + name: "../secret.txt", + mimeType: "text/plain", + kind: "text", + text: "secret", + }), + (error: unknown) => (error as { code?: string }).code === "invalid_request", + ); + + const capacity = new AidenRemoteAttachmentStore({ + now: () => now, + randomId: () => `att_${String(sequence++).padStart(43, "C")}`, + maxEntries: 1, + }); + capacity.upload("device-1", "chat-1", { + name: "first.txt", + mimeType: "text/plain", + kind: "text", + text: "first", + }); + assert.throws( + () => capacity.upload("device-1", "chat-1", { + name: "second.txt", + mimeType: "text/plain", + kind: "text", + text: "second", + }), + (error: unknown) => (error as { code?: string }).code === "handle_capacity", + ); +}); diff --git a/main/services/aiden-remote-chats.ts b/main/services/aiden-remote-chats.ts new file mode 100644 index 00000000..3139e691 --- /dev/null +++ b/main/services/aiden-remote-chats.ts @@ -0,0 +1,748 @@ +import { createHash, randomUUID } from "node:crypto"; +import { persistedChatWorkspaceId } from "../../renderer/shared/chat-workspace.js"; +import { isGenerationThinkingLevel } from "../../renderer/shared/generation-thinking.js"; +import { + parseGenerationTimeline, + type GenerationTimeline, +} from "../../renderer/shared/generation-timeline.js"; +import { parseProviderFailureV1 } from "../../renderer/shared/provider-failure.js"; +import { + appendChatMessageWithReconciliation, + isAppendReconciliationRequiredError, +} from "./chat-append-commit.js"; +import type { createChatApplicationService } from "./chat-application-service.js"; +import type { ChatGenerationOwner } from "./chat-generation-owner.js"; +import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; +import { + AidenIdempotencyLedger, + type AidenIdempotencySnapshot, + AidenOperationContractError, + AidenOperationUnknownOutcomeError, + assertRevision, +} from "./aiden-remote-operation-contract.js"; +import type { AidenRemoteModelService } from "./aiden-remote-models.js"; +import type { AidenRemoteStreamService } from "./aiden-remote-streams.js"; +import { + AidenRemoteAttachmentStore, + MAX_AIDEN_REMOTE_ATTACHMENTS_PER_TURN, + type AidenRemoteAttachmentProjection, +} from "./aiden-remote-attachments.js"; +import { + attachmentRepresentationBytes, + safeStoredAttachments, +} from "./attachment-contract.js"; +import { + imageBytesMatchMime, + MAX_IMAGE_BYTES, +} from "./attachments.js"; +import type { Chat, ChatMessage, ChatStartParams } from "./types.js"; + +const SAFE_ID = /^[A-Za-z0-9._:-]{1,128}$/u; +const IDEMPOTENCY_KEY = /^[\x21-\x7e]{16,128}$/u; +type ChatApplicationService = ReturnType; + +function remoteImageHasCompleteTrailer(bytes: Uint8Array, mimeType: "image/png" | "image/jpeg"): boolean { + if (mimeType === "image/jpeg") { + return bytes.length >= 2 && bytes[bytes.length - 2] === 0xff && bytes[bytes.length - 1] === 0xd9; + } + const iend = [0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130]; + return bytes.length >= iend.length && iend.every((value, index) => bytes[bytes.length - iend.length + index] === value); +} + +export interface AidenRemoteMessageProjection { + id: string; + role: "user" | "assistant"; + text: string; + createdAt: string; + attachments?: AidenRemoteMessageAttachmentProjection[]; + outcome?: AidenRemoteMessageOutcomeProjection; + timeline?: GenerationTimeline; +} + +export interface AidenRemoteMessageAttachmentProjection { + id: string; + name: string; + mimeType: string; + kind: "image" | "text"; + size: number; +} + +export interface AidenRemoteMessageOutcomeProjection { + status: "failed" | "cancelled"; + category?: string; + attempts?: number; + retryExhausted?: boolean; +} + +export interface AidenRemoteAttachmentContent { + bytes: Buffer; + mimeType: string; +} + +export interface AidenRemoteChatProjection { + id: string; + workspaceId: string; + title: string; + providerId?: string; + modelId?: string; + messages: AidenRemoteMessageProjection[]; + createdAt: string; + updatedAt: string; + revision: string; + titlePending?: true; +} + +function ownRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function exactKeys(record: Record, required: string[], optional: string[] = []): boolean { + const allowed = new Set([...required, ...optional]); + return required.every((key) => Object.prototype.hasOwnProperty.call(record, key)) + && Object.keys(record).every((key) => allowed.has(key)); +} + +function boundedString(value: unknown, maximum: number): value is string { + return typeof value === "string" && value.length > 0 && Array.from(value).length <= maximum; +} + +function safeId(value: string, label: string): string { + if (!SAFE_ID.test(value)) { + throw new AidenRemoteServiceError("invalid_request", `The ${label} identifier is invalid.`, 400); + } + return value; +} + +function safeAttachmentDisplayName(value: string): string { + const segments = value.replace(/\\/gu, "/").split("/"); + const leaf = segments[segments.length - 1] ?? ""; + const cleaned = Array.from(leaf) + .filter((character) => { + const code = character.charCodeAt(0); + return code > 0x1f && code !== 0x7f; + }) + .slice(0, 255) + .join("") + .trim(); + return cleaned || "Attachment"; +} + +function projectedAttachmentId(value: string): string { + return /^[A-Za-z0-9._:-]{1,256}$/u.test(value) + ? value + : `legacy_${createHash("sha256").update(value).digest("base64url")}`; +} + +function projectMessageAttachments(value: unknown): AidenRemoteMessageAttachmentProjection[] { + return (safeStoredAttachments(value) ?? []).map((attachment) => ({ + id: projectedAttachmentId(attachment.id), + name: safeAttachmentDisplayName(attachment.name), + mimeType: /^[\x21-\x7e]{1,120}$/u.test(attachment.mimeType) + ? attachment.mimeType + : attachment.kind === "image" ? "image/unknown" : "text/plain", + kind: attachment.kind, + size: attachment.size, + })); +} + +function projectMessageOutcome(message: ChatMessage): AidenRemoteMessageOutcomeProjection | undefined { + const failure = parseProviderFailureV1(message.providerFailure); + if (failure) { + return { + status: "failed", + category: failure.category, + attempts: failure.attempts, + retryExhausted: failure.retryExhausted, + }; + } + const timeline = parseGenerationTimeline(message.timeline, message.content.length); + if (timeline?.status === "failed" || timeline?.status === "cancelled") { + return { status: timeline.status }; + } + return undefined; +} + +function projectMessageTimeline(message: ChatMessage): GenerationTimeline | undefined { + if (message.role !== "assistant") return undefined; + return parseGenerationTimeline(message.timeline, message.content.length); +} + +function chatRevision(chat: Chat): string { + const visible = { + id: chat.id, + workspaceId: persistedChatWorkspaceId(chat.workspaceId), + title: chat.title, + providerId: chat.providerId ?? null, + model: chat.model ?? null, + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + messages: chat.messages + .filter((message) => message.role === "user" || message.role === "assistant") + .map((message) => ({ + id: message.id, + role: message.role, + content: message.content, + createdAt: message.createdAt, + attachments: projectMessageAttachments(message.attachments), + outcome: projectMessageOutcome(message) ?? null, + timeline: projectMessageTimeline(message) ?? null, + })), + }; + return `rev_${createHash("sha256").update(JSON.stringify(visible)).digest("base64url")}`; +} + +export function projectAidenRemoteChat( + chat: Chat, + options: { titlePending?: boolean } = {}, +): AidenRemoteChatProjection { + return { + id: chat.id, + workspaceId: persistedChatWorkspaceId(chat.workspaceId), + title: chat.title.slice(0, 1_024), + ...(chat.providerId ? { providerId: chat.providerId } : {}), + ...(chat.model ? { modelId: chat.model } : {}), + messages: chat.messages.flatMap((message) => { + if (message.role !== "user" && message.role !== "assistant") return []; + const attachments = projectMessageAttachments(message.attachments); + const outcome = projectMessageOutcome(message); + const timeline = projectMessageTimeline(message); + return [{ + id: message.id, + role: message.role, + text: message.content.slice(0, 200_000), + createdAt: new Date(message.createdAt).toISOString(), + ...(attachments.length > 0 ? { attachments } : {}), + ...(outcome ? { outcome } : {}), + ...(timeline ? { timeline } : {}), + }]; + }), + createdAt: new Date(chat.createdAt).toISOString(), + updatedAt: new Date(chat.updatedAt).toISOString(), + revision: chatRevision(chat), + ...(options.titlePending === true ? { titlePending: true as const } : {}), + }; +} + +function parseCreate(input: unknown): { workspaceId: string; providerId?: string; model?: string } { + const record = ownRecord(input); + if ( + !record || + !exactKeys(record, ["workspaceId"], ["providerId", "modelId"]) || + !boundedString(record.workspaceId, 128) || + (record.providerId !== undefined && !boundedString(record.providerId, 256)) || + (record.modelId !== undefined && !boundedString(record.modelId, 256)) + ) { + throw new AidenRemoteServiceError("invalid_request", "The chat creation request is invalid.", 400); + } + return { + workspaceId: safeId(record.workspaceId, "workspace"), + ...(typeof record.providerId === "string" ? { providerId: record.providerId } : {}), + ...(typeof record.modelId === "string" ? { model: record.modelId } : {}), + }; +} + +function parseTitle(input: unknown): string { + const record = ownRecord(input); + if (!record || !exactKeys(record, ["title"]) || !boundedString(record.title, 200)) { + throw new AidenRemoteServiceError("invalid_request", "The chat title is invalid.", 400); + } + const title = record.title.trim(); + if (!title) { + throw new AidenRemoteServiceError("invalid_request", "The chat title is invalid.", 400); + } + return title; +} + +function parseMove(input: unknown): { workspaceId: string; confirmedForeground: true } { + const record = ownRecord(input); + if ( + !record || + !exactKeys(record, ["workspaceId", "confirmedForeground"]) || + !boundedString(record.workspaceId, 128) || + record.confirmedForeground !== true + ) { + throw new AidenRemoteServiceError("invalid_request", "The chat move request is invalid.", 400); + } + return { workspaceId: safeId(record.workspaceId, "workspace"), confirmedForeground: true }; +} + +function parseTurn(input: unknown): { + text: string; + providerId?: string; + modelId?: string; + thinkingLevel?: ChatStartParams["thinkingLevel"]; + attachmentIds?: string[]; +} { + const record = ownRecord(input); + const attachmentIds = record?.attachmentIds; + const structurallyValidAttachmentIds = + attachmentIds === undefined || + (Array.isArray(attachmentIds) && + attachmentIds.length > 0 && + attachmentIds.length <= MAX_AIDEN_REMOTE_ATTACHMENTS_PER_TURN && + attachmentIds.every((value) => typeof value === "string")); + if ( + !record || + !exactKeys(record, ["text"], ["providerId", "modelId", "thinkingLevel", "attachmentIds"]) || + typeof record.text !== "string" || + Array.from(record.text).length > 200_000 || + (!record.text.trim() && (!Array.isArray(attachmentIds) || attachmentIds.length === 0)) || + (record.providerId !== undefined && !boundedString(record.providerId, 256)) || + (record.modelId !== undefined && !boundedString(record.modelId, 256)) || + (record.thinkingLevel !== undefined && !isGenerationThinkingLevel(record.thinkingLevel)) || + !structurallyValidAttachmentIds + ) { + throw new AidenRemoteServiceError( + "invalid_request", + "The turn must contain bounded text or valid attachment references.", + 400, + ); + } + return { + text: record.text, + ...(typeof record.providerId === "string" ? { providerId: record.providerId } : {}), + ...(typeof record.modelId === "string" ? { modelId: record.modelId } : {}), + ...(isGenerationThinkingLevel(record.thinkingLevel) + ? { thinkingLevel: record.thinkingLevel } + : {}), + ...(Array.isArray(attachmentIds) ? { attachmentIds: [...attachmentIds] as string[] } : {}), + }; +} + +function ephemeralOwner(deviceId: string, operationId: string): ChatGenerationOwner { + let destroyed = false; + const listeners = new Set<() => void>(); + return { + id: 0, + documentId: `remote-chat:${createHash("sha256").update(`${deviceId}:${operationId}`).digest("base64url")}`, + isDestroyed: () => destroyed, + send: () => undefined, + onInvalidated: (listener) => { + if (destroyed) listener(); + else listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +function requireRevision(expected: string, chat: Chat): void { + try { + assertRevision(expected, chatRevision(chat)); + } catch { + throw new AidenRemoteServiceError( + "revision_conflict", + "The chat changed. Refresh it before trying again.", + 409, + false, + { currentRevision: chatRevision(chat) }, + ); + } +} + +export class AidenRemoteChatService { + private readonly idempotency: AidenIdempotencyLedger; + private readonly attachments: AidenRemoteAttachmentStore; + + constructor( + private readonly options: { + application: Pick; + chatStore: { + get(id: string): Promise; + appendMessage( + id: string, + message: Omit & { id?: string; createdAt?: number }, + meta?: { + providerId?: string; + model?: string; + expectedWorkspaceId?: string; + isCurrent?: () => boolean; + }, + ): Promise; + }; + generation: { + beginChatTurn(chatId: string, turnId: string, ownerId: string): { + isActive(): boolean; + reserveAppendPayload(bytes: number): void; + settleAsyncWork(): void; + onReleased(cleanup: () => void): void; + release(): void; + } | null; + start( + streamId: string, + params: ChatStartParams, + owner: ChatGenerationOwner, + options: { + allowSubagents: boolean; + allowComputerUse: false; + usageSource: "chat"; + turnId: string; + onTurnAccepted(): void; + }, + ): Promise; + }; + streams: AidenRemoteStreamService; + models: Pick; + attachments?: AidenRemoteAttachmentStore; + idempotency?: AidenIdempotencyLedger; + persistIdempotency?: (snapshot: AidenIdempotencySnapshot) => Promise; + notifyChanged?: (chatId?: string) => void; + isTitlePending?: (chatId: string) => boolean; + }, + ) { + this.idempotency = options.idempotency ?? new AidenIdempotencyLedger(); + this.attachments = options.attachments ?? new AidenRemoteAttachmentStore(); + } + + private async executeIdempotent( + scope: { deviceId: string; route: string; resourceId: string; key: string }, + input: unknown, + action: () => Promise, + ): Promise { + if (!IDEMPOTENCY_KEY.test(scope.key)) { + throw new AidenRemoteServiceError("invalid_request", "Idempotency-Key is invalid.", 400); + } + if (!this.options.persistIdempotency) return this.idempotency.execute(scope, input, action); + let admit!: () => void; + let reject!: (error: unknown) => void; + const durable = new Promise((resolve, rejectPromise) => { + admit = resolve; + reject = rejectPromise; + }); + const pending = this.idempotency.execute(scope, input, async () => { + await durable; + return action(); + }); + try { + await this.options.persistIdempotency(this.idempotency.snapshot()); + admit(); + } catch (error) { + reject(error); + await pending.catch(() => undefined); + throw new AidenRemoteServiceError("internal_error", "Aiden could not prepare this chat request.", 500); + } + let result: T | undefined; + let failure: unknown; + try { + result = await pending; + } catch (error) { + failure = error; + } + try { + await this.options.persistIdempotency(this.idempotency.snapshot()); + } catch { + throw new AidenRemoteServiceError( + "idempotency_in_flight", + "The chat request may have completed, but its outcome could not be recorded.", + 409, + ); + } + if (failure) throw failure; + return result!; + } + + private async chat(chatId: string): Promise { + const result = await this.options.application.get(safeId(chatId, "chat")); + if (!result.chat || result.chat.id !== chatId) { + throw new AidenRemoteServiceError("not_found", "This Aiden chat no longer exists.", 404); + } + if (result.reconciliation) { + throw new AidenRemoteServiceError("operation_in_progress", "This chat is still reconciling.", 409, true); + } + return result.chat; + } + + private project(chat: Chat): AidenRemoteChatProjection { + return projectAidenRemoteChat(chat, { + titlePending: this.options.isTitlePending?.(chat.id) === true, + }); + } + + async list(workspaceId?: string): Promise<{ chats: AidenRemoteChatProjection[] }> { + if (workspaceId) safeId(workspaceId, "workspace"); + const metadata = await this.options.application.list(workspaceId); + const chats = await Promise.all(metadata.map((entry) => this.chat(entry.id))); + return { chats: chats.map((chat) => this.project(chat)) }; + } + + async get(chatId: string): Promise { + return this.project(await this.chat(chatId)); + } + + async uploadAttachment( + deviceId: string, + chatId: string, + input: unknown, + ): Promise { + await this.chat(chatId); + return this.attachments.upload(deviceId, safeId(chatId, "chat"), input); + } + + async removeAttachment(deviceId: string, chatId: string, attachmentId: string): Promise { + await this.chat(chatId); + this.attachments.remove(deviceId, safeId(chatId, "chat"), attachmentId); + } + + async attachmentContent( + chatId: string, + attachmentId: string, + ): Promise { + safeId(chatId, "chat"); + if (!/^[A-Za-z0-9._:-]{1,256}$/u.test(attachmentId)) { + throw new AidenRemoteServiceError("invalid_request", "The attachment identifier is invalid.", 400); + } + const authoritative = await this.chat(chatId); + const matches = authoritative.messages.flatMap((message) => + message.role === "user" || message.role === "assistant" + ? (safeStoredAttachments(message.attachments) ?? []).filter( + (attachment) => projectedAttachmentId(attachment.id) === attachmentId, + ) + : [], + ); + if (matches.length !== 1) { + throw new AidenRemoteServiceError("not_found", "This attachment is unavailable.", 404); + } + const attachment = matches[0]!; + if (attachment.kind === "image") { + if ( + (attachment.mimeType !== "image/png" && attachment.mimeType !== "image/jpeg") || + typeof attachment.data !== "string" + ) { + throw new AidenRemoteServiceError("not_found", "This image is unavailable.", 404); + } + const bytes = Buffer.from(attachment.data, "base64"); + if ( + bytes.length === 0 || + bytes.length > MAX_IMAGE_BYTES || + bytes.length !== attachment.size || + !imageBytesMatchMime(bytes, attachment.mimeType) || + !remoteImageHasCompleteTrailer(bytes, attachment.mimeType) + ) { + throw new AidenRemoteServiceError("not_found", "This image is unavailable.", 404); + } + return { bytes, mimeType: attachment.mimeType }; + } + throw new AidenRemoteServiceError("not_found", "This image is unavailable.", 404); + } + + revokeDevice(deviceId: string): void { + this.attachments.revokeDevice(deviceId); + } + + async create(deviceId: string, key: string, input: unknown): Promise { + const parsed = parseCreate(input); + try { + return await this.executeIdempotent( + { deviceId, route: "POST /chats", resourceId: "chat-registry", key }, + parsed, + async () => { + const owner = ephemeralOwner(deviceId, key); + const selection = parsed.providerId || parsed.model + ? await this.options.models.resolve(parsed.providerId, parsed.model) + : undefined; + const created = await this.options.application.create( + { + workspaceId: parsed.workspaceId, + ...(selection + ? { providerId: selection.providerId, model: selection.modelId } + : {}), + }, + owner, + ); + if (!created) throw new AidenRemoteServiceError("internal_error", "Aiden could not create the chat.", 500); + this.options.notifyChanged?.(created.id); + return projectAidenRemoteChat(created); + }, + ); + } catch (error) { + return this.mapOperationError(error); + } + } + + async rename(chatId: string, revision: string, input: unknown): Promise { + const title = parseTitle(input); + const updated = await this.options.application.rename(safeId(chatId, "chat"), title, { + assertCurrent: (chat) => requireRevision(revision, chat), + }); + this.options.notifyChanged?.(chatId); + return projectAidenRemoteChat(updated); + } + + async move( + deviceId: string, + chatId: string, + revision: string, + key: string, + input: unknown, + ): Promise { + const parsed = parseMove(input); + try { + return await this.executeIdempotent( + { deviceId, route: "POST /chats/{id}/move", resourceId: safeId(chatId, "chat"), key }, + { revision, ...parsed }, + async () => { + const moved = await this.options.application.moveEmptyToWorkspace(chatId, parsed.workspaceId, { + assertCurrent: (chat) => requireRevision(revision, chat), + }); + this.options.notifyChanged?.(chatId); + return projectAidenRemoteChat(moved!); + }, + ); + } catch (error) { + return this.mapOperationError(error); + } + } + + async remove(chatId: string, revision: string): Promise { + await this.options.application.remove(safeId(chatId, "chat"), { + assertCurrent: (chat) => requireRevision(revision, chat), + }); + this.options.notifyChanged?.(chatId); + } + + async startTurn( + deviceId: string, + chatId: string, + key: string, + input: unknown, + ): Promise<{ + turnId: string; + streamId: string; + status: "accepted"; + message: AidenRemoteMessageProjection; + }> { + const parsed = parseTurn(input); + try { + return await this.executeIdempotent( + { deviceId, route: "POST /chats/{id}/turns", resourceId: safeId(chatId, "chat"), key }, + parsed, + async () => { + const authoritative = await this.chat(chatId); + const selection = await this.options.models.resolve( + parsed.providerId ?? authoritative.providerId, + parsed.modelId ?? authoritative.model, + ); + if ( + parsed.thinkingLevel && + selection.thinkingLevels.length > 0 && + !selection.thinkingLevels.includes(parsed.thinkingLevel) + ) { + throw new AidenRemoteServiceError("invalid_request", "That thinking level is unavailable.", 400); + } + const turnId = `turn_${randomUUID()}`; + const streamId = `stream_${randomUUID()}`; + const owner = this.options.streams.create(deviceId, streamId, chatId, turnId); + const turn = this.options.generation.beginChatTurn(chatId, turnId, owner.owner.documentId); + if (!turn) { + owner.invalidate(); + this.options.streams.markStartError(deviceId, streamId, new Error("This chat already has a response in progress.")); + throw new AidenRemoteServiceError("turn_already_active", "This chat already has a response in progress.", 409); + } + const attachments = this.attachments.consume(deviceId, chatId, parsed.attachmentIds); + turn.onReleased(owner.owner.onInvalidated(turn.release)); + turn.reserveAppendPayload( + Buffer.byteLength(parsed.text, "utf8") + + attachmentRepresentationBytes(attachments) + + 1_024, + ); + const messageId = `message_${randomUUID()}`; + let appended = false; + let accepted = false; + let appendedChat: Chat | undefined; + try { + const workspaceId = persistedChatWorkspaceId(authoritative.workspaceId); + const chat = await appendChatMessageWithReconciliation({ + messageId, + append: () => this.options.chatStore.appendMessage( + chatId, + { + id: messageId, + role: "user", + content: parsed.text, + ...(attachments?.length ? { attachments } : {}), + }, + { + providerId: selection.providerId, + model: selection.modelId, + expectedWorkspaceId: workspaceId, + isCurrent: turn.isActive, + }, + ), + recover: () => this.options.chatStore.get(chatId), + }); + appended = true; + appendedChat = chat; + turn.settleAsyncWork(); + const started = await this.options.generation.start( + streamId, + { + chatId, + workspaceId, + providerId: selection.providerId, + model: selection.modelId, + ...(parsed.thinkingLevel ? { thinkingLevel: parsed.thinkingLevel } : {}), + messages: [], + }, + owner.owner, + { + allowSubagents: true, + allowComputerUse: false, + usageSource: "chat", + turnId, + onTurnAccepted: () => { + accepted = true; + this.options.streams.markRunning(deviceId, streamId); + }, + }, + ); + if (!started && !accepted) { + this.options.streams.markStartError( + deviceId, + streamId, + new Error("Generation stopped before it could begin."), + ); + } + this.options.notifyChanged?.(chatId); + const message = chat.messages.find((candidate) => candidate.id === messageId)!; + return { + turnId, + streamId, + status: "accepted" as const, + message: projectAidenRemoteChat({ ...chat, messages: [message] }).messages[0]!, + }; + } catch (error) { + if (!appended) turn.release(); + turn.settleAsyncWork(); + this.options.streams.markStartError(deviceId, streamId, error); + if (isAppendReconciliationRequiredError(error)) { + throw new AidenOperationUnknownOutcomeError(); + } + if (appended && appendedChat) { + this.options.notifyChanged?.(chatId); + const message = appendedChat.messages.find((candidate) => candidate.id === messageId)!; + return { + turnId, + streamId, + status: "accepted" as const, + message: projectAidenRemoteChat({ ...appendedChat, messages: [message] }).messages[0]!, + }; + } + throw error; + } + }, + ); + } catch (error) { + return this.mapOperationError(error); + } + } + + private mapOperationError(error: unknown): never { + if (error instanceof AidenRemoteServiceError) throw error; + if (error instanceof AidenOperationContractError) { + const status = error.code === "idempotency_capacity" ? 429 : 409; + throw new AidenRemoteServiceError(error.code, "This chat request cannot be safely repeated.", status); + } + throw error; + } +} diff --git a/main/services/aiden-remote-errors.ts b/main/services/aiden-remote-errors.ts new file mode 100644 index 00000000..73d40818 --- /dev/null +++ b/main/services/aiden-remote-errors.ts @@ -0,0 +1,30 @@ +import type { AidenRemoteErrorCode } from "./aiden-remote-protocol.js"; + +export class AidenRemoteServiceError extends Error { + constructor( + readonly code: AidenRemoteErrorCode, + message: string, + readonly status: number, + readonly retryable = false, + readonly details?: { + currentRevision?: string; + retryAfterSeconds?: number; + chatId?: string; + minimumClientVersion?: string; + limit?: number; + field?: string; + }, + ) { + super(message); + this.name = "AidenRemoteServiceError"; + } +} + +export function asAidenRemoteServiceError(error: unknown): AidenRemoteServiceError { + if (error instanceof AidenRemoteServiceError) return error; + return new AidenRemoteServiceError( + "internal_error", + "Aiden could not complete this remote request.", + 500, + ); +} diff --git a/main/services/aiden-remote-files.test.ts b/main/services/aiden-remote-files.test.ts new file mode 100644 index 00000000..933ac3b5 --- /dev/null +++ b/main/services/aiden-remote-files.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; +import { AidenRemoteFileService } from "./aiden-remote-files.js"; +import { AidenOpaqueHandleStore } from "./aiden-remote-opaque-handles.js"; +import { AidenRemoteWorkspaceOwnerRegistry } from "./aiden-remote-workspace-owners.js"; +import type { Workspace } from "./types.js"; +import { createWorkspaceEnvironmentApplicationService } from "./workspace-environment-application-service.js"; +import { WorkspaceMutationGate } from "./workspace-mutation-gate.js"; +import { WorkspaceOperationRegistry } from "./workspace-operation-registry.js"; + +test("remote Files uses device/workspace-bound opaque handles and version-safe writes", async () => { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-remote-files-")); + const root = path.join(temporary, "workspace"); + const outside = path.join(temporary, "outside.txt"); + await fs.mkdir(path.join(root, "Sources"), { recursive: true }); + await fs.writeFile(path.join(root, "Sources", "App.swift"), "let value = 1\n", "utf8"); + await fs.writeFile(outside, "private\n", "utf8"); + const workspace: Workspace = { + id: "workspace-1", + name: "Project", + folderPath: root, + permission: "ask", + createdAt: 1, + updatedAt: 2, + }; + const workspaces = new Map([[workspace.id, workspace]]); + const application = createWorkspaceEnvironmentApplicationService({ + configStore: { getWorkspace: async (id) => workspaces.get(id) }, + workspaceMutationGate: new WorkspaceMutationGate(), + workspaceOperationRegistry: new WorkspaceOperationRegistry(), + assertManagedWorktreeAdmission: async () => undefined, + realpath: fs.realpath, + stat: fs.stat, + }); + const handles = new AidenOpaqueHandleStore(); + const owners = new AidenRemoteWorkspaceOwnerRegistry(); + const service = new AidenRemoteFileService({ + instanceId: "instance-1", + application, + owners, + handles, + }); + + try { + const index = await service.list("device-1", workspace.id); + assert.equal(index.maxEntries, 4_000); + assert.equal(index.maxDepth, 20); + assert.equal(index.truncated, false); + const file = index.entries.find((entry) => entry.displayPath === "Sources/App.swift"); + assert.ok(file); + assert.match(file.id, /^file_[A-Za-z0-9_-]{43}$/u); + assert.equal(file.language, "Swift"); + assert.equal(JSON.stringify(index).includes(root), false); + assert.equal(handles.storedTokenMaterialForTesting().some((value) => value.includes("App.swift")), false); + + const first = await service.read("device-1", workspace.id, file.id); + assert.equal(first.content, "let value = 1\n"); + assert.equal(first.displayPath, "Sources/App.swift"); + + await assert.rejects( + () => service.read("device-2", workspace.id, file.id), + (error: unknown) => error instanceof AidenRemoteServiceError && error.code === "handle_wrong_device", + ); + workspaces.set("workspace-2", { ...workspace, id: "workspace-2" }); + await assert.rejects( + () => service.read("device-1", "workspace-2", file.id), + (error: unknown) => error instanceof AidenRemoteServiceError && error.code === "handle_wrong_device", + ); + + await fs.writeFile(path.join(root, "Sources", "App.swift"), "let value = 2\n", "utf8"); + await assert.rejects( + () => service.write("device-1", workspace.id, file.id, { + content: "let value = 3\n", + expectedVersion: first.version, + }), + (error: unknown) => error instanceof AidenRemoteServiceError && error.code === "revision_conflict", + ); + + const refreshed = await service.read("device-1", workspace.id, file.id); + const saved = await service.write("device-1", workspace.id, file.id, { + content: "let value = 3\n", + expectedVersion: refreshed.version, + }); + assert.equal(saved.content, "let value = 3\n"); + assert.equal(await fs.readFile(path.join(root, "Sources", "App.swift"), "utf8"), "let value = 3\n"); + + await fs.rm(path.join(root, "Sources", "App.swift")); + await fs.symlink(outside, path.join(root, "Sources", "App.swift")); + await assert.rejects( + () => service.read("device-1", workspace.id, file.id), + (error: unknown) => error instanceof AidenRemoteServiceError && error.code === "path_outside_root", + ); + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } +}); + +test("remote workspace owners survive disconnect-shaped reuse and revoke active ownership", () => { + const registry = new AidenRemoteWorkspaceOwnerRegistry(); + const first = registry.owner("device-1"); + assert.equal(first, registry.owner("device-1")); + let invalidations = 0; + first.onInvalidated(() => { invalidations += 1; }); + registry.revokeDevice("device-1"); + assert.equal(first.isDestroyed(), true); + assert.equal(invalidations, 1); + assert.notEqual(first, registry.owner("device-1")); +}); diff --git a/main/services/aiden-remote-files.ts b/main/services/aiden-remote-files.ts new file mode 100644 index 00000000..8e9de63e --- /dev/null +++ b/main/services/aiden-remote-files.ts @@ -0,0 +1,336 @@ +import { randomBytes } from "node:crypto"; +import path from "node:path"; +import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; +import { + AidenOpaqueHandleError, + AidenOpaqueHandleStore, + inspectAidenFilesystemIdentity, + type AidenOpaqueHandleClaims, +} from "./aiden-remote-opaque-handles.js"; +import { projectAidenRemoteWorkspace } from "./aiden-remote-workspaces.js"; +import type { AidenRemoteWorkspaceOwnerRegistry } from "./aiden-remote-workspace-owners.js"; +import type { WorkspaceEnvironmentApplicationService } from "./workspace-environment-application-service.js"; +import { + listWorkspaceFiles, + readWorkspaceFile, + WorkspaceFileError, + writeWorkspaceFile, + type WorkspaceFileDocument, + type WorkspaceFileEntry, +} from "./workspace-files.js"; + +const FILE_HANDLE_TTL_MS = 10 * 60_000; +const MAX_DISPLAY_PATH_LENGTH = 4_096; +const MAX_WIRE_FILE_SIZE = 5 * 1_048_576; + +export interface AidenRemoteFileEntry { + id: string; + displayPath: string; + name: string; + kind: "file" | "directory" | "symlink"; + size?: number; + language?: string; +} + +export interface AidenRemoteFileIndex { + snapshotId: string; + entries: AidenRemoteFileEntry[]; + truncated: boolean; + maxEntries: 4_000; + maxDepth: 20; +} + +export interface AidenRemoteFileDocument { + id: string; + displayPath: string; + content: string; + version: string; + truncated: false; + warning?: string; +} + +function languageFor(entry: WorkspaceFileEntry): string | undefined { + if (entry.kind !== "file") return undefined; + const extension = path.extname(entry.name).slice(1).toLowerCase(); + const names: Record = { + c: "C", cc: "C++", cpp: "C++", css: "CSS", go: "Go", h: "C Header", + html: "HTML", java: "Java", js: "JavaScript", json: "JSON", jsx: "JSX", + kt: "Kotlin", md: "Markdown", mjs: "JavaScript", py: "Python", rb: "Ruby", + rs: "Rust", sh: "Shell", swift: "Swift", ts: "TypeScript", tsx: "TSX", + yaml: "YAML", yml: "YAML", + }; + return names[extension]; +} + +function safeDisplayPath(value: string): string { + if ( + !value || + value.length > MAX_DISPLAY_PATH_LENGTH || + path.isAbsolute(value) || + value.split("/").some((part) => part === "..") || + [...value].some((character) => { + const code = character.codePointAt(0) ?? 0; + return code <= 0x1f || code === 0x7f; + }) + ) { + throw new AidenRemoteServiceError( + "workspace_unavailable", + "A workspace file could not be projected safely.", + 409, + ); + } + return value; +} + +function mapHandleError(error: unknown): never { + if (!(error instanceof AidenOpaqueHandleError)) throw error; + const status = error.code === "handle_wrong_device" + ? 403 + : error.code === "handle_expired" + ? 410 + : error.code === "handle_capacity" + ? 429 + : error.code === "root_policy_changed" || + error.code === "filesystem_identity_changed" || + error.code === "path_outside_root" + ? 409 + : 400; + throw new AidenRemoteServiceError( + error.code, + error.code === "handle_expired" + ? "This file link expired. Refresh Files and try again." + : error.code === "handle_capacity" + ? "Aiden's file-handle capacity is temporarily full." + : "This file link is no longer valid. Refresh Files and try again.", + status, + error.code === "handle_capacity", + ); +} + +function parseWrite(value: unknown): { content: string; expectedVersion: string } { + const record = value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; + if ( + !record || + Object.keys(record).length !== 2 || + typeof record.content !== "string" || + Buffer.byteLength(record.content, "utf8") > 1_500_000 || + typeof record.expectedVersion !== "string" || + !/^[0-9a-f]{64}$/u.test(record.expectedVersion) + ) { + throw new AidenRemoteServiceError("invalid_request", "The file save request is invalid.", 400); + } + return { content: record.content, expectedVersion: record.expectedVersion }; +} + +function projectedDocument( + fileId: string, + displayPath: string, + document: WorkspaceFileDocument, +): AidenRemoteFileDocument { + return { + id: fileId, + displayPath, + content: document.content, + version: document.version, + truncated: false, + ...(document.warning ? { warning: [...document.warning].slice(0, 500).join("") } : {}), + }; +} + +export class AidenRemoteFileService { + private handleStore: AidenOpaqueHandleStore | undefined; + private readonly now = (): number => this.options.now?.() ?? Date.now(); + + constructor( + private readonly options: { + instanceId: string; + application: Pick; + owners: Pick; + handles?: AidenOpaqueHandleStore; + now?: () => number; + }, + ) {} + + private get handles(): AidenOpaqueHandleStore { + this.handleStore ??= this.options.handles ?? new AidenOpaqueHandleStore({ now: this.now }); + return this.handleStore; + } + + private async claims( + deviceId: string, + workspaceId: string, + folderPath: string, + workspaceRevision: string, + displayPath: string, + snapshotId: string, + ): Promise { + const identity = await inspectAidenFilesystemIdentity( + folderPath, + path.join(folderPath, displayPath), + ); + return { + instanceId: this.options.instanceId, + deviceId, + workspaceId, + rootId: workspaceId, + policyRevision: workspaceRevision, + ...identity, + displayPath, + snapshotId, + expiresAt: this.now() + FILE_HANDLE_TTL_MS, + }; + } + + async list(deviceId: string, workspaceId: string): Promise { + return this.options.application.run( + this.options.owners.owner(deviceId), + workspaceId, + async ({ folderPath, workspace }, signal) => { + const index = await listWorkspaceFiles(folderPath, signal); + const snapshotId = `files_${randomBytes(24).toString("base64url")}`; + const revision = projectAidenRemoteWorkspace(workspace).revision; + const entries: AidenRemoteFileEntry[] = []; + let omitted = false; + for (const entry of index.entries) { + if (signal.aborted) throw new Error("The workspace operation was cancelled."); + try { + const displayPath = safeDisplayPath(entry.path); + const claims = await this.claims( + deviceId, + workspaceId, + folderPath, + revision, + displayPath, + snapshotId, + ); + entries.push({ + id: this.handles.issue("file", claims), + displayPath, + name: [...entry.name].slice(0, 255).join(""), + kind: entry.kind, + ...(entry.size !== undefined && entry.size <= MAX_WIRE_FILE_SIZE + ? { size: entry.size } + : {}), + ...(languageFor(entry) ? { language: languageFor(entry)! } : {}), + }); + } catch (error) { + if (error instanceof AidenOpaqueHandleError && error.code === "handle_capacity") { + mapHandleError(error); + } + omitted = true; + } + } + const projection: AidenRemoteFileIndex = { + snapshotId, + entries, + truncated: index.truncated || omitted, + maxEntries: 4_000, + maxDepth: 20, + }; + return projection; + }, + ).catch((error: unknown) => { + if (error instanceof AidenRemoteServiceError) throw error; + throw new AidenRemoteServiceError( + "workspace_unavailable", + "This workspace's files are not currently available on the Mac.", + 409, + ); + }); + } + + private async withResolvedFile( + deviceId: string, + workspaceId: string, + fileId: string, + operation: ( + input: { folderPath: string; displayPath: string; signal: AbortSignal }, + ) => Promise, + ): Promise { + let stored: AidenOpaqueHandleClaims; + try { + stored = this.handles.claimsFor(fileId, "file"); + } catch (error) { + mapHandleError(error); + } + try { + return await this.options.application.run( + this.options.owners.owner(deviceId), + workspaceId, + async ({ folderPath, workspace }, signal) => { + if (!stored.displayPath || stored.workspaceId !== workspaceId) { + throw new AidenOpaqueHandleError("handle_wrong_device"); + } + const current = await this.claims( + deviceId, + workspaceId, + folderPath, + projectAidenRemoteWorkspace(workspace).revision, + stored.displayPath, + stored.snapshotId ?? "", + ); + current.expiresAt = stored.expiresAt; + this.handles.resolve(fileId, "file", current); + return operation({ folderPath, displayPath: stored.displayPath, signal }); + }, + ); + } catch (error) { + if (error instanceof AidenOpaqueHandleError) mapHandleError(error); + throw error; + } + } + + read(deviceId: string, workspaceId: string, fileId: string): Promise { + return this.withResolvedFile(deviceId, workspaceId, fileId, async (input) => { + try { + return projectedDocument( + fileId, + input.displayPath, + await readWorkspaceFile(input.folderPath, input.displayPath, input.signal), + ); + } catch { + throw new AidenRemoteServiceError( + "workspace_unavailable", + "This file cannot currently be read as bounded UTF-8 text.", + 409, + ); + } + }); + } + + write( + deviceId: string, + workspaceId: string, + fileId: string, + value: unknown, + ): Promise { + const input = parseWrite(value); + return this.withResolvedFile(deviceId, workspaceId, fileId, async (resolved) => { + try { + const document = await writeWorkspaceFile( + resolved.folderPath, + resolved.displayPath, + input.content, + input.expectedVersion, + resolved.signal, + ); + return projectedDocument(fileId, resolved.displayPath, document); + } catch (error) { + if (error instanceof WorkspaceFileError && error.code === "changed_on_disk") { + throw new AidenRemoteServiceError( + "revision_conflict", + "This file changed on the Mac. Reload it before saving.", + 409, + ); + } + throw new AidenRemoteServiceError( + "workspace_unavailable", + "Aiden could not safely save this file on the Mac.", + 409, + ); + } + }); + } +} diff --git a/main/services/aiden-remote-git.test.ts b/main/services/aiden-remote-git.test.ts new file mode 100644 index 00000000..bbcb8eda --- /dev/null +++ b/main/services/aiden-remote-git.test.ts @@ -0,0 +1,285 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import * as fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import test from "node:test"; +import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; +import { AidenRemoteGitService } from "./aiden-remote-git.js"; +import { AidenRemoteWorkspaceOwnerRegistry } from "./aiden-remote-workspace-owners.js"; +import { + gitBranches, + gitCheckout, + gitCommit, + gitCompare, + gitComparisonDiff, + gitCreateBranch, + gitDiff, + gitPush, + gitPushCapability, + gitReview, + gitWorktrees, +} from "./git.js"; +import type { Workspace } from "./types.js"; +import { createWorkspaceEnvironmentApplicationService } from "./workspace-environment-application-service.js"; +import { workspaceRevision } from "./aiden-remote-workspaces.js"; +import { WorkspaceMutationGate } from "./workspace-mutation-gate.js"; +import { WorkspaceOperationRegistry } from "./workspace-operation-registry.js"; + +const runFile = promisify(execFile); + +async function git(cwd: string, ...args: string[]): Promise { + const result = await runFile("/usr/bin/git", args, { cwd, encoding: "utf8" }); + return result.stdout.trim(); +} + +test("remote Git keeps paths and snapshot internals on the Mac and safely completes reviewed mutations", async () => { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-remote-git-")); + const repository = path.join(temporary, "repository"); + const bare = path.join(temporary, "remote.git"); + await fs.mkdir(repository); + await git(repository, "init", "-b", "main"); + await git(repository, "config", "user.email", "aiden@example.test"); + await git(repository, "config", "user.name", "Aiden Test"); + await fs.writeFile(path.join(repository, "App.swift"), "let value = 1\n", "utf8"); + await git(repository, "add", "App.swift"); + await git(repository, "commit", "-m", "Initial"); + await runFile("/usr/bin/git", ["init", "--bare", bare], { encoding: "utf8" }); + await git(repository, "remote", "add", "origin", bare); + await git(repository, "push", "-u", "origin", "main"); + + const workspace: Workspace = { + id: "workspace-1", + name: "Project", + folderPath: repository, + permission: "ask", + createdAt: 1, + updatedAt: 2, + }; + const application = createWorkspaceEnvironmentApplicationService({ + configStore: { getWorkspace: async (id) => id === workspace.id ? workspace : undefined }, + workspaceMutationGate: new WorkspaceMutationGate(), + workspaceOperationRegistry: new WorkspaceOperationRegistry(), + assertManagedWorktreeAdmission: async () => undefined, + realpath: fs.realpath, + stat: fs.stat, + }); + const managedWorkspace: Workspace = { + id: "workspace-managed", + name: "Mobile Worktree", + folderPath: path.join(temporary, "managed"), + permission: "ask", + managedWorktree: { + repositoryPath: repository, + worktreePath: path.join(temporary, "managed"), + branch: "feature/worktree", + worktreeGitDir: path.join(repository, ".git", "worktrees", "managed"), + ownershipToken: "a".repeat(64), + worktreeDevice: 1, + worktreeInode: 2, + createdFromHead: "b".repeat(40), + }, + createdAt: 3, + updatedAt: 4, + }; + let createWorktreeCount = 0; + let deleteWorktreeCount = 0; + const service = new AidenRemoteGitService({ + application, + owners: new AidenRemoteWorkspaceOwnerRegistry(), + git: { + review: gitReview, + diff: gitDiff, + branches: gitBranches, + checkout: gitCheckout, + createBranch: gitCreateBranch, + commit: gitCommit, + pushCapability: gitPushCapability, + push: gitPush, + compare: gitCompare, + comparisonDiff: gitComparisonDiff, + worktrees: gitWorktrees, + }, + worktrees: { + create: async (_owner, sourceId, branch, name) => { + assert.equal(sourceId, workspace.id); + assert.equal(branch, "feature/worktree"); + assert.equal(name, "Mobile Worktree"); + createWorktreeCount += 1; + return managedWorkspace; + }, + remove: async (_owner, id, validate) => { + assert.equal(id, managedWorkspace.id); + validate?.(managedWorkspace); + deleteWorktreeCount += 1; + return { branchDeleted: true }; + }, + }, + listWorkspaces: async () => [workspace], + persistIdempotency: async () => undefined, + }); + + try { + await fs.writeFile(path.join(repository, "App.swift"), "let value = 2\n", "utf8"); + const review = await service.review("device-1", workspace.id); + assert.equal(review.status, "snapshot"); + assert.equal(review.result?.kind, "review"); + if (review.result?.kind !== "review" || !review.snapshotId) throw new Error("missing review"); + const changed = review.result.files.find((file) => file.displayPath === "App.swift"); + assert.ok(changed); + assert.match(changed.id, /^file_[A-Za-z0-9_-]{43}$/u); + assert.equal(JSON.stringify(review).includes(repository), false); + + const diff = await service.diff("device-1", workspace.id, { + snapshotId: review.snapshotId, + fileId: changed.id, + }); + assert.equal(diff.result?.kind, "diff"); + if (diff.result?.kind !== "diff") throw new Error("missing diff"); + assert.match(diff.result.diff, /value = 2/u); + assert.equal(diff.result.diff.includes(repository), false); + + await assert.rejects( + () => service.diff("device-2", workspace.id, { + snapshotId: review.snapshotId, + fileId: changed.id, + }), + (error: unknown) => error instanceof AidenRemoteServiceError && error.code === "handle_wrong_device", + ); + await assert.rejects( + () => service.commit("device-1", workspace.id, "commit-key-remote-0001", { + snapshotId: review.snapshotId, + message: "Update value", + scope: "all-reviewed", + confirmedForeground: false, + }), + (error: unknown) => error instanceof AidenRemoteServiceError && error.code === "permission_confirmation_required", + ); + + const commitInput = { + snapshotId: review.snapshotId, + message: "Update value", + scope: "all-reviewed", + confirmedForeground: true, + }; + const committed = await service.commit( + "device-1", + workspace.id, + "commit-key-remote-0002", + commitInput, + ); + const replayed = await service.commit( + "device-1", + workspace.id, + "commit-key-remote-0002", + commitInput, + ); + assert.deepEqual(replayed, committed); + assert.equal(await git(repository, "rev-list", "--count", "HEAD"), "2"); + + const branchSnapshot = await service.branches("device-1", workspace.id); + assert.ok(branchSnapshot.snapshotId); + const created = await service.createBranch( + "device-1", + workspace.id, + "branch-key-remote-0001", + { name: "feature/mobile", startPoint: "main", confirmedForeground: true }, + ); + assert.equal(created.status, "succeeded"); + assert.equal(await git(repository, "branch", "--show-current"), "feature/mobile"); + await assert.rejects( + () => service.checkout( + "device-1", + workspace.id, + "checkout-key-remote-1", + { branch: "main", snapshotId: branchSnapshot.snapshotId, confirmedForeground: true }, + ), + (error: unknown) => error instanceof AidenRemoteServiceError && error.code === "operation_stale", + ); + const freshBranches = await service.branches("device-1", workspace.id); + await service.checkout( + "device-1", + workspace.id, + "checkout-key-remote-2", + { branch: "main", snapshotId: freshBranches.snapshotId, confirmedForeground: true }, + ); + assert.equal(await git(repository, "branch", "--show-current"), "main"); + + const pushCapability = await service.pushCapability("device-1", workspace.id); + assert.equal(pushCapability.result?.kind, "push-capability"); + if (pushCapability.result?.kind !== "push-capability" || !pushCapability.snapshotId) { + throw new Error("missing push capability"); + } + assert.equal(pushCapability.result.allowed, true); + const pushed = await service.push( + "device-1", + workspace.id, + "push-key-remote-000001", + { + snapshotId: pushCapability.snapshotId, + remote: pushCapability.result.remote, + branch: pushCapability.result.branch, + confirmedForeground: true, + }, + ); + assert.equal(pushed.status, "succeeded"); + assert.equal(await git(repository, "rev-parse", "HEAD"), await git(repository, "rev-parse", "origin/main")); + + await assert.rejects( + () => service.createWorktree("device-1", workspace.id, "worktree-key-remote-0001", { + branch: "feature/worktree", + name: "Mobile Worktree", + confirmedForeground: false, + }), + (error: unknown) => error instanceof AidenRemoteServiceError && error.code === "permission_confirmation_required", + ); + const createInput = { + branch: "feature/worktree", + name: "Mobile Worktree", + confirmedForeground: true, + }; + const worktreeCreated = await service.createWorktree( + "device-1", + workspace.id, + "worktree-key-remote-0002", + createInput, + ); + const worktreeReplay = await service.createWorktree( + "device-1", + workspace.id, + "worktree-key-remote-0002", + createInput, + ); + assert.deepEqual(worktreeReplay, worktreeCreated); + assert.equal(createWorktreeCount, 1); + + await assert.rejects( + () => service.deleteManagedWorktree( + "device-1", + managedWorkspace.id, + "rev_wrong", + "worktree-delete-key-0001", + { confirmedForeground: true }, + ), + (error: unknown) => error instanceof AidenRemoteServiceError && error.code === "revision_conflict", + ); + const deleted = await service.deleteManagedWorktree( + "device-1", + managedWorkspace.id, + workspaceRevision(managedWorkspace), + "worktree-delete-key-0002", + { confirmedForeground: true }, + ); + assert.equal(deleted.result?.kind, "mutation"); + assert.equal(deleteWorktreeCount, 1); + + const worktrees = await service.worktrees("device-1", workspace.id); + assert.equal(worktrees.result?.kind, "worktrees"); + if (worktrees.result?.kind !== "worktrees") throw new Error("missing worktrees"); + assert.deepEqual(worktrees.result.worktrees.map((entry) => entry.id), [workspace.id]); + assert.equal(JSON.stringify(worktrees).includes(repository), false); + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } +}); diff --git a/main/services/aiden-remote-git.ts b/main/services/aiden-remote-git.ts new file mode 100644 index 00000000..6d7f06fb --- /dev/null +++ b/main/services/aiden-remote-git.ts @@ -0,0 +1,775 @@ +import { createHash, randomBytes } from "node:crypto"; +import path from "node:path"; +import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; +import { + AidenIdempotencyLedger, + AidenOperationContractError, + assertRevision, + type AidenIdempotencySnapshot, +} from "./aiden-remote-operation-contract.js"; +import type { AidenRemoteWorkspaceOwnerRegistry } from "./aiden-remote-workspace-owners.js"; +import { + GitServiceError, + type GitCommitInput, + type GitComparison, + type GitComparisonDiffInput, + type GitDiffInput, + type GitPushCapability, + type GitPushInput, + type GitReview, + type GitReviewFile, +} from "./git.js"; +import type { GitBranches, GitWorktree, Workspace } from "./types.js"; +import type { WorkspaceEnvironmentApplicationService } from "./workspace-environment-application-service.js"; +import type { WorkspaceWorktreeApplicationService } from "./workspace-worktree-application-service.js"; +import { workspaceRevision } from "./aiden-remote-workspaces.js"; + +const SNAPSHOT_TTL_MS = 10 * 60_000; +const MAX_SNAPSHOTS = 4_096; +const MAX_FILES = 4_000; +const MAX_DIFF_CHARACTERS = 2_000_000; +const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7e]{16,128}$/u; + +type GitFileStatus = "added" | "modified" | "deleted" | "renamed" | "untracked" | "conflicted"; + +export interface AidenRemoteGitFile { + id: string; + displayPath: string; + status: GitFileStatus; + staged?: boolean; + additions?: number; + deletions?: number; +} + +export type AidenRemoteGitProjection = + | { kind: "review"; branch: string; uncommitted: number; files: AidenRemoteGitFile[] } + | { kind: "diff"; displayPath: string; diff: string; truncated: boolean } + | { kind: "branches"; current: string; branches: string[] } + | { kind: "comparison"; comparisonId: string; base: string; head: string; files: AidenRemoteGitFile[] } + | { kind: "push-capability"; allowed: boolean; reason?: string; remote?: string; branch?: string } + | { kind: "worktrees"; worktrees: Array<{ id: string; name: string; branch: string; managed: boolean }> } + | { kind: "mutation"; message: string; branch?: string; commitId?: string; workspaceId?: string; warning?: string }; + +export interface AidenRemoteGitResult { + operationId: string; + status: "snapshot" | "accepted" | "running" | "succeeded" | "failed" | "conflict"; + snapshotId?: string; + capability?: { allowed: boolean; reason?: string }; + result?: AidenRemoteGitProjection; +} + +interface SnapshotBase { + deviceId: string; + workspaceId: string; + expiresAt: number; + files: Map; +} + +type Snapshot = SnapshotBase & ( + | { kind: "review"; expectedSnapshot?: string } + | { kind: "branches"; digest: string } + | { kind: "comparison"; comparison: GitComparison } + | { kind: "push"; capability: GitPushCapability } +); + +type SnapshotInput = + | { kind: "review"; deviceId: string; workspaceId: string; expectedSnapshot?: string } + | { kind: "branches"; deviceId: string; workspaceId: string; digest: string } + | { kind: "comparison"; deviceId: string; workspaceId: string; comparison: GitComparison } + | { kind: "push"; deviceId: string; workspaceId: string; capability: GitPushCapability }; + +interface GitDependencies { + review(folderPath: string, signal?: AbortSignal): Promise; + diff(folderPath: string, input: GitDiffInput, signal?: AbortSignal): Promise<{ path: string; patch: string; truncated: boolean }>; + branches(folderPath: string, signal?: AbortSignal): Promise; + checkout(folderPath: string, name: string, signal?: AbortSignal): Promise; + createBranch(folderPath: string, name: string, signal?: AbortSignal): Promise; + commit(folderPath: string, input: GitCommitInput, signal?: AbortSignal): Promise<{ commit: string; branch: string; subject: string; warning?: string }>; + pushCapability(folderPath: string, signal?: AbortSignal): Promise; + push(folderPath: string, input: GitPushInput, signal?: AbortSignal): Promise<{ branch: string; commit: string; remote: string; warning?: string }>; + compare(folderPath: string, targetRef: string, signal?: AbortSignal): Promise; + comparisonDiff(folderPath: string, input: GitComparisonDiffInput, signal?: AbortSignal): Promise<{ path: string; patch: string; truncated: boolean }>; + worktrees(folderPath: string, signal?: AbortSignal): Promise; +} + +function digest(value: string): string { + return createHash("sha256").update(value).digest("base64url"); +} + +function operationId(): string { + return `op_${randomBytes(24).toString("base64url")}`; +} + +function safeDisplayPath(value: string): string { + if ( + !value || + value.length > 4_096 || + path.isAbsolute(value) || + value.split("/").some((part) => part === "..") || + [...value].some((character) => { + const code = character.codePointAt(0) ?? 0; + return code <= 0x1f || code === 0x7f; + }) + ) { + throw new AidenRemoteServiceError("workspace_unavailable", "A Git path could not be projected safely.", 409); + } + return value; +} + +function safeString(value: string, maximum: number): string { + return [...value].slice(0, maximum).join(""); +} + +function status(value: GitReviewFile["status"]): GitFileStatus { + return value === "copied" ? "renamed" : value; +} + +function branchesDigest(value: GitBranches): string { + return digest(JSON.stringify({ + isRepo: value.isRepo, + current: value.current ?? null, + branches: value.branches, + remoteBranches: value.remoteBranches, + uncommitted: value.uncommitted, + detached: value.detached ?? false, + unborn: value.unborn ?? false, + })); +} + +function ownRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function exactKeys(record: Record, required: string[]): boolean { + return Object.keys(record).length === required.length && required.every( + (key) => Object.prototype.hasOwnProperty.call(record, key), + ); +} + +function boundedString(value: unknown, maximum: number): value is string { + return typeof value === "string" && value.length > 0 && [...value].length <= maximum; +} + +function requireConfirmation(record: Record): void { + if (record.confirmedForeground !== true) { + throw new AidenRemoteServiceError( + "permission_confirmation_required", + "This Git change requires an explicit foreground confirmation.", + 409, + ); + } +} + +function mapGitError(error: unknown): never { + if (error instanceof AidenRemoteServiceError) throw error; + if (!(error instanceof GitServiceError)) { + throw new AidenRemoteServiceError("internal_error", "Aiden could not complete this Git operation.", 500); + } + if (error.code === "stale_snapshot" || error.code === "dirty_worktree" || error.code === "conflicted") { + throw new AidenRemoteServiceError("operation_stale", "The repository changed. Refresh Git and try again.", 409); + } + if (error.code === "invalid_input" || error.code === "invalid_ref") { + throw new AidenRemoteServiceError("invalid_request", "The Git request is invalid.", 400); + } + if (error.code === "aborted") { + throw new AidenRemoteServiceError("operation_stale", "The Git operation was cancelled.", 409, true); + } + throw new AidenRemoteServiceError( + "git_capability_denied", + error.code === "not_repo" + ? "This workspace is not a Git repository." + : "This Git operation is not available for the current workspace state.", + 409, + ); +} + +export class AidenRemoteGitService { + private readonly snapshots = new Map(); + private readonly idempotency: AidenIdempotencyLedger; + private readonly now = (): number => this.options.now?.() ?? Date.now(); + + constructor( + private readonly options: { + application: Pick; + owners: Pick; + git: GitDependencies; + worktrees?: Pick; + listWorkspaces(): Promise; + idempotency?: AidenIdempotencyLedger; + persistIdempotency?: (snapshot: AidenIdempotencySnapshot) => Promise; + now?: () => number; + }, + ) { + this.idempotency = options.idempotency ?? new AidenIdempotencyLedger(); + } + + private prune(): void { + const now = this.now(); + for (const [key, value] of this.snapshots) { + if (value.expiresAt <= now) this.snapshots.delete(key); + } + } + + private issueSnapshot(value: SnapshotInput, files: GitReviewFile[] = []): { + snapshotId: string; + projectedFiles: AidenRemoteGitFile[]; + } { + this.prune(); + if (this.snapshots.size >= MAX_SNAPSHOTS) { + throw new AidenRemoteServiceError("handle_capacity", "Aiden's Git snapshot capacity is temporarily full.", 429, true); + } + const snapshotId = `snap_${randomBytes(32).toString("base64url")}`; + const mapped = new Map(); + const projectedFiles = files.slice(0, MAX_FILES).map((file) => { + const token = `file_${randomBytes(32).toString("base64url")}`; + mapped.set(digest(token), file.path); + return { + id: token, + displayPath: safeDisplayPath(file.path), + status: status(file.status), + staged: file.staged, + ...(file.additions === undefined ? {} : { additions: Math.max(0, file.additions) }), + ...(file.deletions === undefined ? {} : { deletions: Math.max(0, file.deletions) }), + }; + }); + this.snapshots.set(digest(snapshotId), { + ...value, + expiresAt: this.now() + SNAPSHOT_TTL_MS, + files: mapped, + } as Snapshot); + return { snapshotId, projectedFiles }; + } + + private snapshot( + deviceId: string, + workspaceId: string, + snapshotId: string, + expectedKind: Snapshot["kind"], + ): Snapshot { + this.prune(); + if (!/^snap_[A-Za-z0-9_-]{43}$/u.test(snapshotId)) { + throw new AidenRemoteServiceError("handle_invalid", "This Git snapshot is invalid.", 400); + } + const snapshot = this.snapshots.get(digest(snapshotId)); + if (!snapshot) throw new AidenRemoteServiceError("handle_expired", "This Git snapshot expired. Refresh Git and try again.", 410); + if (snapshot.deviceId !== deviceId) throw new AidenRemoteServiceError("handle_wrong_device", "This Git snapshot belongs to another paired device.", 403); + if (snapshot.workspaceId !== workspaceId || snapshot.kind !== expectedKind) { + throw new AidenRemoteServiceError("operation_stale", "This Git snapshot does not match the workspace operation.", 409); + } + return snapshot; + } + + private filePath(snapshot: Snapshot, fileId: string): string { + if (!/^file_[A-Za-z0-9_-]{43}$/u.test(fileId)) { + throw new AidenRemoteServiceError("handle_invalid", "This Git file link is invalid.", 400); + } + const value = snapshot.files.get(digest(fileId)); + if (!value) throw new AidenRemoteServiceError("operation_stale", "This file is not part of the Git snapshot.", 409); + return value; + } + + private async executeIdempotent( + scope: { deviceId: string; route: string; resourceId: string; key: string }, + input: unknown, + action: () => Promise, + ): Promise { + if (!IDEMPOTENCY_KEY_PATTERN.test(scope.key)) { + throw new AidenRemoteServiceError("invalid_request", "Idempotency-Key is invalid.", 400); + } + if (!this.options.persistIdempotency) return this.idempotency.execute(scope, input, action); + let release!: () => void; + let reject!: (error: unknown) => void; + const admission = new Promise((resolve, rejectPromise) => { + release = resolve; + reject = rejectPromise; + }); + const pending = this.idempotency.execute(scope, input, async () => { + await admission; + return action(); + }); + try { + await this.options.persistIdempotency(this.idempotency.snapshot()); + release(); + } catch (error) { + reject(error); + await pending.catch(() => undefined); + throw new AidenRemoteServiceError("internal_error", "Aiden could not durably prepare this Git operation.", 500); + } + let result: T | undefined; + let failure: unknown; + try { result = await pending; } catch (error) { failure = error; } + try { + await this.options.persistIdempotency(this.idempotency.snapshot()); + } catch { + throw new AidenRemoteServiceError( + "idempotency_in_flight", + "The Git operation may have completed, but Aiden could not durably record its outcome.", + 409, + ); + } + if (failure) throw failure; + return result!; + } + + private run( + deviceId: string, + workspaceId: string, + operation: (folderPath: string, signal: AbortSignal) => Promise, + ): Promise { + return this.options.application.run( + this.options.owners.owner(deviceId), + workspaceId, + ({ folderPath }, signal) => operation(folderPath, signal), + ); + } + + async review(deviceId: string, workspaceId: string): Promise { + try { + const review = await this.run(deviceId, workspaceId, this.options.git.review); + const issued = this.issueSnapshot( + { kind: "review", deviceId, workspaceId, expectedSnapshot: review.commit.snapshot }, + review.files, + ); + return { + operationId: operationId(), + status: "snapshot", + snapshotId: issued.snapshotId, + capability: { + allowed: review.commit.allowed, + ...(review.commit.reason ? { reason: safeString(review.commit.reason, 500) } : {}), + }, + result: { + kind: "review", + branch: safeString(review.branch ?? "HEAD", 500), + uncommitted: review.summary.fileCount, + files: issued.projectedFiles, + }, + }; + } catch (error) { mapGitError(error); } + } + + async diff(deviceId: string, workspaceId: string, value: unknown): Promise { + const record = ownRecord(value); + if (!record || !exactKeys(record, ["snapshotId", "fileId"]) || !boundedString(record.snapshotId, 128) || !boundedString(record.fileId, 128)) { + throw new AidenRemoteServiceError("invalid_request", "The Git diff request is invalid.", 400); + } + const snapshot = this.snapshot(deviceId, workspaceId, record.snapshotId, "review"); + if (snapshot.kind !== "review" || !snapshot.expectedSnapshot) { + throw new AidenRemoteServiceError("git_capability_denied", "A diff is unavailable for this review.", 409); + } + const displayPath = this.filePath(snapshot, record.fileId); + try { + const diff = await this.run(deviceId, workspaceId, (folderPath, signal) => + this.options.git.diff(folderPath, { expectedSnapshot: snapshot.expectedSnapshot!, path: displayPath }, signal)); + return { + operationId: operationId(), + status: "snapshot", + snapshotId: record.snapshotId, + result: { + kind: "diff", + displayPath: safeDisplayPath(diff.path), + diff: [...diff.patch].slice(0, MAX_DIFF_CHARACTERS).join(""), + truncated: diff.truncated || [...diff.patch].length > MAX_DIFF_CHARACTERS, + }, + }; + } catch (error) { mapGitError(error); } + } + + async branches(deviceId: string, workspaceId: string): Promise { + try { + const branches = await this.run(deviceId, workspaceId, this.options.git.branches); + const issued = this.issueSnapshot({ + kind: "branches", + deviceId, + workspaceId, + digest: branchesDigest(branches), + }); + return { + operationId: operationId(), + status: "snapshot", + snapshotId: issued.snapshotId, + capability: { + allowed: branches.isRepo && !branches.detached, + ...(!branches.isRepo + ? { reason: "This workspace is not a Git repository." } + : branches.detached + ? { reason: "Checkout is unavailable while HEAD is detached." } + : {}), + }, + result: { + kind: "branches", + current: safeString(branches.current ?? "HEAD", 500), + branches: branches.branches.slice(0, 4_000).map((branch) => safeString(branch, 500)), + }, + }; + } catch (error) { mapGitError(error); } + } + + async checkout(deviceId: string, workspaceId: string, key: string, value: unknown): Promise { + const record = ownRecord(value); + if (!record || !exactKeys(record, ["branch", "snapshotId", "confirmedForeground"]) || !boundedString(record.branch, 500) || !boundedString(record.snapshotId, 128)) { + throw new AidenRemoteServiceError("invalid_request", "The branch checkout request is invalid.", 400); + } + requireConfirmation(record); + const snapshot = this.snapshot(deviceId, workspaceId, record.snapshotId, "branches"); + return this.executeIdempotent( + { deviceId, route: "POST /git/checkout", resourceId: workspaceId, key }, + record, + async () => { + try { + return await this.run(deviceId, workspaceId, async (folderPath, signal) => { + const current = await this.options.git.branches(folderPath, signal); + if (snapshot.kind !== "branches" || snapshot.digest !== branchesDigest(current)) { + throw new AidenRemoteServiceError("operation_stale", "The branch list changed. Refresh Git and try again.", 409); + } + await this.options.git.checkout(folderPath, record.branch as string, signal); + return { + operationId: operationId(), + status: "succeeded" as const, + result: { kind: "mutation" as const, message: "Checked out branch.", branch: record.branch as string }, + }; + }); + } catch (error) { mapGitError(error); } + }, + ); + } + + async createBranch(deviceId: string, workspaceId: string, key: string, value: unknown): Promise { + const record = ownRecord(value); + if (!record || !exactKeys(record, ["name", "startPoint", "confirmedForeground"]) || !boundedString(record.name, 200) || !boundedString(record.startPoint, 500)) { + throw new AidenRemoteServiceError("invalid_request", "The branch creation request is invalid.", 400); + } + requireConfirmation(record); + return this.executeIdempotent( + { deviceId, route: "POST /git/branches", resourceId: workspaceId, key }, + record, + async () => { + try { + return await this.run(deviceId, workspaceId, async (folderPath, signal) => { + const branches = await this.options.git.branches(folderPath, signal); + if (record.startPoint !== (branches.current ?? "HEAD")) { + throw new AidenRemoteServiceError("operation_stale", "The branch start point changed. Refresh Git and try again.", 409); + } + await this.options.git.createBranch(folderPath, record.name as string, signal); + return { + operationId: operationId(), + status: "succeeded" as const, + result: { kind: "mutation" as const, message: "Created and checked out branch.", branch: record.name as string }, + }; + }); + } catch (error) { mapGitError(error); } + }, + ); + } + + async commit(deviceId: string, workspaceId: string, key: string, value: unknown): Promise { + const record = ownRecord(value); + if ( + !record || + !exactKeys(record, ["snapshotId", "message", "scope", "confirmedForeground"]) || + !boundedString(record.snapshotId, 128) || + !boundedString(record.message, 20_000) || + (record.scope !== "all-reviewed" && record.scope !== "staged-reviewed") + ) { + throw new AidenRemoteServiceError("invalid_request", "The Git commit request is invalid.", 400); + } + requireConfirmation(record); + const snapshot = this.snapshot(deviceId, workspaceId, record.snapshotId, "review"); + if (snapshot.kind !== "review" || !snapshot.expectedSnapshot) { + throw new AidenRemoteServiceError("git_capability_denied", "Commit is unavailable for this review.", 409); + } + return this.executeIdempotent( + { deviceId, route: "POST /git/commit", resourceId: workspaceId, key }, + record, + async () => { + try { + const result = await this.run(deviceId, workspaceId, (folderPath, signal) => + this.options.git.commit(folderPath, { + expectedSnapshot: snapshot.expectedSnapshot!, + message: record.message as string, + mode: record.scope === "all-reviewed" ? "all" : "staged", + }, signal)); + return { + operationId: operationId(), + status: "succeeded" as const, + result: { + kind: "mutation" as const, + message: safeString(result.subject, 500), + branch: safeString(result.branch, 500), + commitId: safeString(result.commit, 128), + ...(result.warning ? { warning: safeString(result.warning, 500) } : {}), + }, + }; + } catch (error) { mapGitError(error); } + }, + ); + } + + async pushCapability(deviceId: string, workspaceId: string): Promise { + try { + const capability = await this.run(deviceId, workspaceId, this.options.git.pushCapability); + const issued = this.issueSnapshot({ kind: "push", deviceId, workspaceId, capability }); + const remote = capability.suggestedRemote; + return { + operationId: operationId(), + status: "snapshot", + snapshotId: issued.snapshotId, + capability: { + allowed: capability.allowed, + ...(capability.reason ? { reason: safeString(capability.reason, 500) } : {}), + }, + result: { + kind: "push-capability", + allowed: capability.allowed, + ...(capability.reason ? { reason: safeString(capability.reason, 500) } : {}), + ...(remote ? { remote: safeString(remote, 200) } : {}), + ...(capability.destinationBranch ? { branch: safeString(capability.destinationBranch, 500) } : {}), + }, + }; + } catch (error) { mapGitError(error); } + } + + async push(deviceId: string, workspaceId: string, key: string, value: unknown): Promise { + const record = ownRecord(value); + if (!record || !exactKeys(record, ["snapshotId", "remote", "branch", "confirmedForeground"]) || !boundedString(record.snapshotId, 128) || !boundedString(record.remote, 200) || !boundedString(record.branch, 500)) { + throw new AidenRemoteServiceError("invalid_request", "The Git push request is invalid.", 400); + } + requireConfirmation(record); + const snapshot = this.snapshot(deviceId, workspaceId, record.snapshotId, "push"); + if (snapshot.kind !== "push" || !snapshot.capability.allowed) { + throw new AidenRemoteServiceError("git_capability_denied", "Push is not available for this workspace state.", 409); + } + const capability = snapshot.capability; + if ( + record.remote !== capability.suggestedRemote || + record.branch !== capability.destinationBranch || + !capability.branch || + !capability.expectedHead || + !capability.suggestedRemote || + !capability.remoteIdentities[capability.suggestedRemote] + ) { + throw new AidenRemoteServiceError("operation_stale", "The reviewed push destination changed. Refresh Git and try again.", 409); + } + return this.executeIdempotent( + { deviceId, route: "POST /git/push", resourceId: workspaceId, key }, + record, + async () => { + try { + const input: GitPushInput = { + destinationBranch: capability.destinationBranch!, + expectedBranch: capability.branch!, + expectedHead: capability.expectedHead!, + expectedRemoteIdentity: capability.remoteIdentities[capability.suggestedRemote!]!, + remote: capability.suggestedRemote!, + setUpstream: !capability.upstream, + }; + const result = await this.run(deviceId, workspaceId, (folderPath, signal) => this.options.git.push(folderPath, input, signal)); + return { + operationId: operationId(), + status: "succeeded" as const, + result: { + kind: "mutation" as const, + message: "Pushed reviewed commits.", + branch: safeString(result.branch, 500), + commitId: safeString(result.commit, 128), + ...(result.warning ? { warning: safeString(result.warning, 500) } : {}), + }, + }; + } catch (error) { mapGitError(error); } + }, + ); + } + + async compare(deviceId: string, workspaceId: string, value: unknown): Promise { + const record = ownRecord(value); + if (!record || !exactKeys(record, ["baseRef"]) || !boundedString(record.baseRef, 500)) { + throw new AidenRemoteServiceError("invalid_request", "The Git comparison request is invalid.", 400); + } + try { + const comparison = await this.run(deviceId, workspaceId, (folderPath, signal) => this.options.git.compare(folderPath, record.baseRef as string, signal)); + const issued = this.issueSnapshot( + { kind: "comparison", deviceId, workspaceId, comparison }, + comparison.files, + ); + return { + operationId: operationId(), + status: "snapshot", + snapshotId: issued.snapshotId, + result: { + kind: "comparison", + comparisonId: issued.snapshotId, + base: safeString(comparison.targetLabel, 500), + head: safeString(comparison.currentBranch ?? "HEAD", 500), + files: issued.projectedFiles, + }, + }; + } catch (error) { mapGitError(error); } + } + + async comparisonDiff(deviceId: string, workspaceId: string, value: unknown): Promise { + const record = ownRecord(value); + if (!record || !exactKeys(record, ["comparisonId", "fileId"]) || !boundedString(record.comparisonId, 128) || !boundedString(record.fileId, 128)) { + throw new AidenRemoteServiceError("invalid_request", "The comparison diff request is invalid.", 400); + } + const snapshot = this.snapshot(deviceId, workspaceId, record.comparisonId, "comparison"); + if (snapshot.kind !== "comparison") throw new AidenRemoteServiceError("operation_stale", "This comparison is stale.", 409); + const displayPath = this.filePath(snapshot, record.fileId); + const comparison = snapshot.comparison; + try { + const diff = await this.run(deviceId, workspaceId, (folderPath, signal) => this.options.git.comparisonDiff(folderPath, { + expectedHead: comparison.expectedHead, + expectedTarget: comparison.expectedTarget, + mergeBase: comparison.mergeBase, + path: displayPath, + targetRef: comparison.targetRef, + }, signal)); + return { + operationId: operationId(), + status: "snapshot", + snapshotId: record.comparisonId, + result: { + kind: "diff", + displayPath: safeDisplayPath(diff.path), + diff: [...diff.patch].slice(0, MAX_DIFF_CHARACTERS).join(""), + truncated: diff.truncated || [...diff.patch].length > MAX_DIFF_CHARACTERS, + }, + }; + } catch (error) { mapGitError(error); } + } + + async worktrees(deviceId: string, workspaceId: string): Promise { + try { + const [worktrees, registered] = await Promise.all([ + this.run(deviceId, workspaceId, this.options.git.worktrees), + this.options.listWorkspaces(), + ]); + const resolvedWorkspaces = await Promise.all(registered.map(async (workspace) => { + try { + const resolved = await this.options.application.resolve(workspace.id, true, true); + return resolved ? { folderPath: resolved.folderPath, workspace } : undefined; + } catch { + return undefined; + } + })); + const projected = worktrees.flatMap((worktree) => { + const resolved = resolvedWorkspaces.find((candidate) => candidate?.folderPath === worktree.path); + if (!resolved) return []; + return [{ + id: resolved.workspace.id, + name: safeString(resolved.workspace.name, 120), + branch: safeString(worktree.branch ?? "detached", 500), + managed: Boolean(resolved.workspace.managedWorktree), + }]; + }); + return { + operationId: operationId(), + status: "snapshot", + result: { kind: "worktrees", worktrees: projected }, + }; + } catch (error) { mapGitError(error); } + } + + async createWorktree( + deviceId: string, + workspaceId: string, + key: string, + value: unknown, + ): Promise { + const record = ownRecord(value); + if ( + !record || + !exactKeys(record, ["branch", "name", "confirmedForeground"]) || + !boundedString(record.branch, 500) || + !boundedString(record.name, 120) + ) { + throw new AidenRemoteServiceError("invalid_request", "The managed-worktree request is invalid.", 400); + } + requireConfirmation(record); + if (!this.options.worktrees) { + throw new AidenRemoteServiceError("not_found", "Managed worktrees are unavailable.", 404); + } + return this.executeIdempotent( + { deviceId, route: "POST /git/worktrees", resourceId: workspaceId, key }, + record, + async () => { + try { + const workspace = await this.options.worktrees!.create( + this.options.owners.owner(deviceId), + workspaceId, + record.branch as string, + record.name as string, + ); + return { + operationId: operationId(), + status: "succeeded" as const, + result: { + kind: "mutation" as const, + message: "Created managed worktree.", + branch: safeString(workspace.managedWorktree?.branch ?? record.branch as string, 500), + workspaceId: workspace.id, + }, + }; + } catch (error) { mapGitError(error); } + }, + ); + } + + async deleteManagedWorktree( + deviceId: string, + workspaceId: string, + revision: string, + key: string, + value: unknown, + ): Promise { + const record = ownRecord(value); + if (!record || !exactKeys(record, ["confirmedForeground"])) { + throw new AidenRemoteServiceError("invalid_request", "The managed-worktree deletion request is invalid.", 400); + } + requireConfirmation(record); + if (!this.options.worktrees) { + throw new AidenRemoteServiceError("not_found", "Managed worktrees are unavailable.", 404); + } + return this.executeIdempotent( + { deviceId, route: "DELETE /git/managed-worktree", resourceId: workspaceId, key }, + { revision, ...record }, + async () => { + try { + const result = await this.options.worktrees!.remove( + this.options.owners.owner(deviceId), + workspaceId, + (workspace) => { + const currentRevision = workspaceRevision(workspace); + try { + assertRevision(revision, currentRevision); + } catch (error) { + if (error instanceof AidenOperationContractError) { + throw new AidenRemoteServiceError( + "revision_conflict", + "The workspace changed. Refresh it before trying again.", + 409, + false, + { currentRevision }, + ); + } + throw error; + } + }, + ); + return { + operationId: operationId(), + status: "succeeded" as const, + result: { + kind: "mutation" as const, + message: result.branchDeleted + ? "Removed managed worktree and branch." + : "Removed managed worktree.", + workspaceId, + }, + }; + } catch (error) { mapGitError(error); } + }, + ); + } +} diff --git a/main/services/aiden-remote-models.test.ts b/main/services/aiden-remote-models.test.ts new file mode 100644 index 00000000..7e7b856a --- /dev/null +++ b/main/services/aiden-remote-models.test.ts @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { AidenRemoteModelService } from "./aiden-remote-models.js"; +import type { Provider } from "./types.js"; + +function provider(overrides: Partial = {}): Provider { + return { + id: "provider-1", + kind: "openai", + label: "Provider", + baseUrl: "https://secret-endpoint.example/v1", + models: ["chat-model", "embedding-model"], + modelMetadata: { + "chat-model": { + source: "provider", + name: "Chat Model", + type: "llm", + thinkingLevels: ["low", "high"], + thinkingCanDisable: false, + }, + "embedding-model": { source: "provider", type: "embedding" }, + }, + needsKey: true, + hasKey: true, + authMethods: [{ type: "api_key", label: "Secret", canLogin: true }], + ...overrides, + }; +} + +test("model projection includes only configured chat models and no connection secrets", async () => { + const service = new AidenRemoteModelService({ + listProviders: async () => [provider(), provider({ id: "missing-key", hasKey: false })], + getSettings: async () => ({ lastProviderId: "provider-1", lastModel: "chat-model" }), + }); + const projection = await service.list(); + assert.deepEqual(projection.defaults, { providerId: "provider-1", modelId: "chat-model" }); + assert.equal(projection.providers.length, 1); + assert.deepEqual(projection.providers[0]?.models, [ + { + id: "chat-model", + label: "Chat Model", + thinkingLevels: ["low", "high"], + defaultThinkingLevel: "high", + thinkingCanDisable: false, + }, + ]); + const serialized = JSON.stringify(projection); + assert.equal(serialized.includes("secret-endpoint"), false); + assert.equal(serialized.includes("authMethods"), false); +}); + +test("custom provider artwork crosses the remote catalog only as bounded normalized PNG data", async () => { + const artwork = { + mimeType: "image/png" as const, + dataBase64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + }; + const service = new AidenRemoteModelService({ + listProviders: async () => [provider({ artwork })], + getSettings: async () => ({}), + }); + assert.deepEqual((await service.list()).providers[0]?.artwork, artwork); +}); + +test("model selection rejects missing providers and models", async () => { + const service = new AidenRemoteModelService({ + listProviders: async () => [provider()], + getSettings: async () => ({}), + }); + assert.deepEqual(await service.resolve(), { + providerId: "provider-1", + modelId: "chat-model", + thinkingLevels: ["low", "high"], + }); + await assert.rejects( + service.resolve("provider-1", "missing"), + (error: unknown) => (error as { code?: string }).code === "invalid_request", + ); +}); + +test("hidden models are projected for clients, skipped by defaults, and remain resolvable", async () => { + const service = new AidenRemoteModelService({ + listProviders: async () => [ + provider({ + models: ["hidden-model", "visible-model"], + defaultModel: "hidden-model", + modelMetadata: { + "hidden-model": { source: "provider", name: "Hidden Model", type: "llm" }, + "visible-model": { source: "provider", name: "Visible Model", type: "llm" }, + }, + }), + ], + getSettings: async () => ({ + lastProviderId: "provider-1", + lastModel: "hidden-model", + hiddenModelsByProvider: { "provider-1": ["hidden-model"] }, + }), + }); + + const projection = await service.list(); + assert.deepEqual(projection.defaults, { + providerId: "provider-1", + modelId: "visible-model", + }); + assert.deepEqual(projection.providers[0]?.models, [ + { id: "hidden-model", label: "Hidden Model", hidden: true }, + { id: "visible-model", label: "Visible Model" }, + ]); + assert.deepEqual(await service.resolve("provider-1", "hidden-model"), { + providerId: "provider-1", + modelId: "hidden-model", + thinkingLevels: [], + }); +}); + +test("a provider with every model hidden has no remote default", async () => { + const service = new AidenRemoteModelService({ + listProviders: async () => [provider({ models: ["chat-model"] })], + getSettings: async () => ({ + hiddenModelsByProvider: { "provider-1": ["chat-model"] }, + }), + }); + + assert.deepEqual((await service.list()).defaults, {}); +}); + +test("remote catalog omits oversized model identities instead of truncating or colliding", async () => { + const prefix = "x".repeat(256); + const service = new AidenRemoteModelService({ + listProviders: async () => [provider({ models: [`${prefix}a`, `${prefix}b`, "safe"] })], + getSettings: async () => ({}), + }); + + assert.deepEqual((await service.list()).providers[0]?.models.map((model) => model.id), ["safe"]); +}); + +test("remote model projection remains below the generic iOS response ceiling", async () => { + const models = Array.from({ length: 20_000 }, (_, index) => + `model-${index.toString().padStart(5, "0")}-${"x".repeat(120)}`, + ); + const service = new AidenRemoteModelService({ + listProviders: async () => [provider({ models })], + getSettings: async () => ({}), + }); + + const projection = await service.list(); + assert.ok(projection.providers[0]!.models.length < models.length); + assert.ok(Buffer.byteLength(JSON.stringify(projection), "utf8") <= 900 * 1024); +}); + +test("OpenCode Go projects remotely refreshed Ox Alpha metadata and thinking choices to iOS", async () => { + const service = new AidenRemoteModelService({ + listProviders: async () => [provider({ + id: "opencode-go", + label: "OpenCode Go", + models: ["ox-alpha-free"], + defaultModel: "ox-alpha-free", + modelMetadata: { + "ox-alpha-free": { + source: "provider", + name: "Ox Alpha Free (Unlimited)", + type: "llm", + reasoning: true, + thinkingLevels: ["low", "high", "max"], + thinkingCanDisable: false, + }, + }, + })], + getSettings: async () => ({ + providerThinkingByModel: { "opencode-go": { "ox-alpha-free": "max" } }, + }), + }); + assert.deepEqual((await service.list()).providers[0]?.models, [{ + id: "ox-alpha-free", + label: "Ox Alpha Free (Unlimited)", + thinkingLevels: ["low", "high", "max"], + defaultThinkingLevel: "max", + thinkingCanDisable: false, + }]); +}); diff --git a/main/services/aiden-remote-models.ts b/main/services/aiden-remote-models.ts new file mode 100644 index 00000000..18c3c5db --- /dev/null +++ b/main/services/aiden-remote-models.ts @@ -0,0 +1,197 @@ +import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; +import type { AppSettings, Provider } from "./types.js"; +import { isModelHidden } from "../../renderer/shared/model-visibility.js"; +import { normalizeProviderArtwork } from "../../renderer/shared/provider-artwork.js"; +import { + isGoogleThinkingLevel, + normalizeGoogleThinkingLevel, +} from "../../renderer/shared/google-thinking.js"; +import { + isCodexThinkingLevel, + normalizeCodexThinkingLevel, +} from "../../renderer/shared/codex-thinking.js"; +import { + isAnthropicThinkingLevel, + normalizeAnthropicThinkingLevel, +} from "../../renderer/shared/anthropic-thinking.js"; +import { normalizeProviderThinkingLevel } from "../../renderer/shared/provider-thinking.js"; +import { isGenerationThinkingLevel } from "../../renderer/shared/generation-thinking.js"; + +const MAX_REMOTE_MODEL_ID_LENGTH = 256; +const MAX_REMOTE_MODEL_CATALOG_BYTES = 900 * 1024; +const REMOTE_MODEL_CATALOG_RESERVE_BYTES = 4 * 1024; + +export interface AidenRemoteModelProjection { + id: string; + label: string; + thinkingLevels?: string[]; + defaultThinkingLevel?: string; + thinkingCanDisable?: boolean; + hidden?: boolean; +} + +export interface AidenRemoteProviderProjection { + id: string; + label: string; + artwork?: { mimeType: "image/png"; dataBase64: string }; + models: AidenRemoteModelProjection[]; +} + +export interface AidenRemoteModelsProjection { + providers: AidenRemoteProviderProjection[]; + defaults: Record; +} + +function bounded(value: string, maximum: number): string { + return Array.from(value).slice(0, maximum).join(""); +} + +export class AidenRemoteModelService { + constructor( + private readonly options: { + listProviders(): Promise; + getSettings(): Promise; + }, + ) {} + + async list(): Promise { + const [configured, settings] = await Promise.all([ + this.options.listProviders(), + this.options.getSettings(), + ]); + let serializedBytes = Buffer.byteLength('{"providers":[],"defaults":{}}', "utf8") + + REMOTE_MODEL_CATALOG_RESERVE_BYTES; + const providers: AidenRemoteProviderProjection[] = []; + for (const provider of configured) { + if ( + (!provider.hasKey && provider.needsKey) || + provider.id.length === 0 || + provider.id.length > MAX_REMOTE_MODEL_ID_LENGTH + ) { + continue; + } + const projected: AidenRemoteProviderProjection = { + id: provider.id, + label: bounded(provider.label, 256), + models: [], + }; + const baseBytes = Buffer.byteLength(JSON.stringify(projected), "utf8") + 1; + if (serializedBytes + baseBytes > MAX_REMOTE_MODEL_CATALOG_BYTES) break; + serializedBytes += baseBytes; + + const candidateArtwork = normalizeProviderArtwork(provider.artwork); + if (candidateArtwork) { + const artworkBytes = Buffer.byteLength(JSON.stringify(candidateArtwork), "utf8") + 12; + if (serializedBytes + artworkBytes <= MAX_REMOTE_MODEL_CATALOG_BYTES) { + projected.artwork = candidateArtwork; + serializedBytes += artworkBytes; + } + } + + for (const id of provider.models) { + if ( + id.length === 0 || + id.length > MAX_REMOTE_MODEL_ID_LENGTH || + provider.modelMetadata?.[id]?.type === "embedding" + ) { + continue; + } + const metadata = provider.modelMetadata?.[id]; + const thinkingLevels = metadata?.thinkingLevels + ?.slice(0, 8) + .map((level) => bounded(level, 32)); + const safeThinkingLevels = thinkingLevels?.filter(isGenerationThinkingLevel) ?? []; + const googleThinkingLevels = safeThinkingLevels.filter(isGoogleThinkingLevel); + const codexThinkingLevels = safeThinkingLevels.filter(isCodexThinkingLevel); + const anthropicThinkingLevels = safeThinkingLevels.filter(isAnthropicThinkingLevel); + const savedThinkingLevel = provider.id === "google" + ? settings.googleThinkingByModel?.[id] + : provider.id === "openai-codex" + ? settings.codexThinkingByModel?.[id] + : provider.id === "anthropic" + ? settings.anthropicThinkingByModel?.[id] + : settings.providerThinkingByModel?.[provider.id]?.[id]; + const defaultThinkingLevel = safeThinkingLevels.length === 0 + ? undefined + : provider.id === "google" + ? normalizeGoogleThinkingLevel(googleThinkingLevels, savedThinkingLevel) + : provider.id === "openai-codex" + ? normalizeCodexThinkingLevel(codexThinkingLevels, savedThinkingLevel) + : provider.id === "anthropic" + ? normalizeAnthropicThinkingLevel(anthropicThinkingLevels, savedThinkingLevel) + : normalizeProviderThinkingLevel(safeThinkingLevels, savedThinkingLevel); + const model: AidenRemoteModelProjection = { + id, + label: bounded(metadata?.name ?? id, 256), + ...(isModelHidden(settings.hiddenModelsByProvider, provider.id, id) + ? { hidden: true } + : {}), + ...(safeThinkingLevels.length ? { + thinkingLevels: safeThinkingLevels, + defaultThinkingLevel, + thinkingCanDisable: metadata?.thinkingCanDisable !== false, + } : {}), + }; + const modelBytes = Buffer.byteLength(JSON.stringify(model), "utf8") + 1; + if (serializedBytes + modelBytes > MAX_REMOTE_MODEL_CATALOG_BYTES) break; + projected.models.push(model); + serializedBytes += modelBytes; + } + + if (projected.models.length > 0) providers.push(projected); + else serializedBytes -= baseBytes + (projected.artwork + ? Buffer.byteLength(JSON.stringify(projected.artwork), "utf8") + 12 + : 0); + } + const selectedProvider = + providers.find( + (provider) => + provider.id === settings.lastProviderId && provider.models.some((model) => !model.hidden), + ) ?? providers.find((provider) => provider.models.some((model) => !model.hidden)); + const selectedModel = + selectedProvider?.models.find((model) => model.id === settings.lastModel && !model.hidden) ?? + selectedProvider?.models.find((model) => !model.hidden); + const projection = { + providers, + defaults: { + ...(selectedProvider ? { providerId: selectedProvider.id } : {}), + ...(selectedModel ? { modelId: selectedModel.id } : {}), + }, + }; + if (Buffer.byteLength(JSON.stringify(projection), "utf8") > MAX_REMOTE_MODEL_CATALOG_BYTES) { + throw new AidenRemoteServiceError( + "internal_error", + "The configured model catalog is too large for a paired device.", + 503, + ); + } + return projection; + } + + async resolve( + providerId?: string, + modelId?: string, + ): Promise<{ providerId: string; modelId: string; thinkingLevels: readonly string[] }> { + const projection = await this.list(); + const provider = providerId + ? projection.providers.find((candidate) => candidate.id === providerId) + : projection.providers.find((candidate) => candidate.id === projection.defaults.providerId); + const model = + provider && + (modelId + ? provider.models.find((candidate) => candidate.id === modelId) + : provider.models.find((candidate) => candidate.id === projection.defaults.modelId)); + if (!provider || !model) { + throw new AidenRemoteServiceError( + "invalid_request", + "Choose a configured Aiden provider and model before starting this turn.", + 400, + ); + } + return { + providerId: provider.id, + modelId: model.id, + thinkingLevels: model.thinkingLevels ?? [], + }; + } +} diff --git a/main/services/aiden-remote-opaque-handles.test.ts b/main/services/aiden-remote-opaque-handles.test.ts new file mode 100644 index 00000000..056f3d63 --- /dev/null +++ b/main/services/aiden-remote-opaque-handles.test.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + AidenOpaqueHandleError, + AidenOpaqueHandleStore, + inspectAidenFilesystemIdentity, + type AidenOpaqueHandleClaims, +} from "./aiden-remote-opaque-handles.js"; + +const claims = (overrides: Partial = {}): AidenOpaqueHandleClaims => ({ + instanceId: "instance-1", + deviceId: "device-1", + workspaceId: "workspace-1", + rootId: "root-1", + policyRevision: "policy-1", + canonicalRootPath: "/approved/root", + canonicalPath: "/approved/root/project/file.swift", + filesystemDevice: "device-volume-1", + filesystemInode: "inode-1", + expiresAt: 2_000, + depth: 2, + snapshotId: "snapshot-1", + kind: "file", + ...overrides, +}); + +function rejectsCode(action: () => unknown, code: string): void { + assert.throws(action, (error) => error instanceof AidenOpaqueHandleError && error.code === code); +} + +test("opaque handles store only a digest and bind identity, root policy, path identity, and snapshot", () => { + const store = new AidenOpaqueHandleStore({ now: () => 1_000 }); + const token = store.issue("file", claims()); + assert.match(token, /^file_[A-Za-z0-9_-]{43}$/); + assert.equal(store.storedTokenMaterialForTesting().includes(token), false); + assert.deepEqual(store.resolve(token, "file", claims(), { now: 1_000 }), claims()); + rejectsCode(() => store.resolve(token, "file", claims({ deviceId: "device-2" }), { now: 1_000 }), "handle_wrong_device"); + rejectsCode(() => store.resolve(token, "file", claims({ workspaceId: "workspace-2" }), { now: 1_000 }), "root_policy_changed"); + rejectsCode(() => store.resolve(token, "file", claims({ workspaceId: undefined }), { now: 1_000 }), "root_policy_changed"); + rejectsCode(() => store.resolve(token, "file", claims({ policyRevision: "policy-2" }), { now: 1_000 }), "root_policy_changed"); + rejectsCode(() => store.resolve(token, "file", claims({ filesystemInode: "inode-2" }), { now: 1_000 }), "filesystem_identity_changed"); + rejectsCode(() => store.resolve(token, "file", claims({ canonicalPath: "/outside/file.swift" }), { now: 1_000 }), "path_outside_root"); + rejectsCode(() => store.resolve(token, "file", claims(), { now: 2_000 }), "handle_expired"); + rejectsCode(() => store.issue("file", claims({ workspaceId: undefined, expiresAt: 3_000 })), "handle_invalid"); +}); + +test("filesystem inspection canonicalizes roots and rejects symlink escapes", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "aiden-handle-root-")); + const root = path.join(directory, "root"); + const outside = path.join(directory, "outside"); + try { + await mkdir(root); + await mkdir(outside); + await writeFile(path.join(root, "safe.txt"), "safe"); + await writeFile(path.join(outside, "secret.txt"), "secret"); + const safe = await inspectAidenFilesystemIdentity(root, path.join(root, "safe.txt")); + assert.equal(safe.canonicalPath.startsWith(`${safe.canonicalRootPath}${path.sep}`), true); + await symlink(path.join(outside, "secret.txt"), path.join(root, "escape.txt")); + await assert.rejects(() => inspectAidenFilesystemIdentity(root, path.join(root, "escape.txt")), (error) => error instanceof AidenOpaqueHandleError && error.code === "path_outside_root"); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("selection handles are atomic single-use even under concurrent consumers", async () => { + const store = new AidenOpaqueHandleStore({ now: () => 1_000 }); + const selectionClaims = claims({ workspaceId: undefined, snapshotId: undefined, kind: "directory" }); + const token = store.issue("sel", selectionClaims); + const attempts = await Promise.allSettled([ + Promise.resolve().then(() => store.consumeSelection(token, selectionClaims, () => "workspace-1", 1_000)), + Promise.resolve().then(() => store.consumeSelection(token, selectionClaims, () => "workspace-2", 1_000)), + ]); + assert.equal(attempts.filter((entry) => entry.status === "fulfilled").length, 1); + assert.equal(attempts.filter((entry) => entry.status === "rejected").length, 1); + const retryStore = new AidenOpaqueHandleStore({ now: () => 1_000 }); + const retryToken = retryStore.issue("sel", selectionClaims); + assert.throws(() => retryStore.consumeSelection(retryToken, selectionClaims, () => { throw new Error("workspace write failed"); }, 1_000), /workspace write failed/); + rejectsCode(() => retryStore.consumeSelection(retryToken, selectionClaims, () => "workspace-retried", 1_000), "handle_invalid"); + const fileSelection = claims({ snapshotId: undefined, kind: "file" }); + const fileToken = retryStore.issue("sel", fileSelection); + rejectsCode(() => retryStore.consumeSelection(fileToken, fileSelection, () => "bad", 1_000), "handle_invalid"); +}); + +test("selection transactions reject async callbacks before invocation and consume promise escape attempts", async () => { + const selectionClaims = claims({ workspaceId: undefined, snapshotId: undefined, kind: "directory" }); + const asyncStore = new AidenOpaqueHandleStore({ now: () => 1_000 }); + const asyncToken = asyncStore.issue("sel", selectionClaims); + let invoked = false; + const asyncMutation = async () => { + invoked = true; + return "workspace-async"; + }; + rejectsCode( + () => asyncStore.consumeSelection(asyncToken, selectionClaims, asyncMutation as unknown as () => never, 1_000), + "handle_invalid", + ); + assert.equal(invoked, false); + assert.equal(asyncStore.consumeSelection(asyncToken, selectionClaims, () => "workspace-sync", 1_000), "workspace-sync"); + + const escapedStore = new AidenOpaqueHandleStore({ now: () => 1_000 }); + const escapedToken = escapedStore.issue("sel", selectionClaims); + const promiseReturningMutation = (() => Promise.resolve("workspace-escaped")) as unknown as () => never; + rejectsCode( + () => escapedStore.consumeSelection(escapedToken, selectionClaims, promiseReturningMutation, 1_000), + "handle_invalid", + ); + rejectsCode( + () => escapedStore.consumeSelection(escapedToken, selectionClaims, () => "must-not-retry", 1_000), + "handle_invalid", + ); +}); + +test("opaque handle storage prunes consumed and expired entries and fails closed at capacity", () => { + let now = 1_000; + const store = new AidenOpaqueHandleStore({ maxEntries: 2, now: () => now }); + const first = store.issue("file", claims({ filesystemInode: "inode-1" })); + const secondClaims = claims({ filesystemInode: "inode-2" }); + const second = store.issue("file", secondClaims); + rejectsCode(() => store.issue("file", claims({ filesystemInode: "inode-3" })), "handle_capacity"); + + store.resolve(first, "file", claims({ filesystemInode: "inode-1" }), { now, consume: true }); + assert.doesNotThrow(() => store.issue("file", claims({ filesystemInode: "inode-3" }))); + now = 2_000; + rejectsCode(() => store.resolve(second, "file", secondClaims, { now }), "handle_expired"); + assert.doesNotThrow(() => store.issue("file", claims({ filesystemInode: "inode-4", expiresAt: 3_000 }))); + assert.equal(store.storedTokenMaterialForTesting().length <= 2, true); +}); diff --git a/main/services/aiden-remote-opaque-handles.ts b/main/services/aiden-remote-opaque-handles.ts new file mode 100644 index 00000000..7656913a --- /dev/null +++ b/main/services/aiden-remote-opaque-handles.ts @@ -0,0 +1,188 @@ +import { createHash, randomBytes } from "node:crypto"; +import { realpath, stat } from "node:fs/promises"; +import path from "node:path"; + +export type AidenOpaqueHandleKind = "loc" | "cur" | "sel" | "file"; + +export interface AidenOpaqueHandleClaims { + instanceId: string; + deviceId: string; + workspaceId?: string; + rootId: string; + policyRevision: string; + canonicalRootPath: string; + canonicalPath: string; + filesystemDevice: string; + filesystemInode: string; + expiresAt: number; + depth?: number; + snapshotId?: string; + cursorOffset?: number; + parentHandleDigest?: string; + displayPath?: string; + kind?: "directory" | "file"; +} + +export type AidenOpaqueHandleErrorCode = + | "handle_invalid" + | "handle_expired" + | "handle_wrong_device" + | "root_policy_changed" + | "filesystem_identity_changed" + | "path_outside_root" + | "handle_capacity"; + +export class AidenOpaqueHandleError extends Error { + constructor(readonly code: AidenOpaqueHandleErrorCode) { + super(code); + } +} + +interface StoredHandle { + kind: AidenOpaqueHandleKind; + claims: AidenOpaqueHandleClaims; + consumed: boolean; +} + +function digest(token: string): string { + return createHash("sha256").update(token).digest("base64url"); +} + +export class AidenOpaqueHandleStore { + private readonly handles = new Map(); + + constructor(private readonly options: { maxEntries?: number; now?: () => number } = {}) {} + + private get maxEntries(): number { + return Math.max(1, this.options.maxEntries ?? 10_000); + } + + private now(): number { + return this.options.now?.() ?? Date.now(); + } + + private prune(now: number, exceptDigest?: string): void { + for (const [key, stored] of this.handles) { + if (key !== exceptDigest && (stored.consumed || stored.claims.expiresAt <= now)) { + this.handles.delete(key); + } + } + } + + issue(kind: AidenOpaqueHandleKind, claims: AidenOpaqueHandleClaims): string { + const now = this.now(); + this.prune(now); + if (claims.expiresAt <= now) throw new AidenOpaqueHandleError("handle_expired"); + if (kind === "file" && !claims.workspaceId) throw new AidenOpaqueHandleError("handle_invalid"); + if (this.handles.size >= this.maxEntries) throw new AidenOpaqueHandleError("handle_capacity"); + const token = `${kind}_${randomBytes(32).toString("base64url")}`; + this.handles.set(digest(token), { kind, claims: { ...claims }, consumed: false }); + return token; + } + + claimsFor( + token: string, + expectedKind: AidenOpaqueHandleKind, + ): AidenOpaqueHandleClaims { + if (!new RegExp(`^${expectedKind}_[A-Za-z0-9_-]{43}$`, "u").test(token)) { + throw new AidenOpaqueHandleError("handle_invalid"); + } + const stored = this.handles.get(digest(token)); + if (!stored || stored.kind !== expectedKind || stored.consumed) { + throw new AidenOpaqueHandleError("handle_invalid"); + } + if (stored.claims.expiresAt <= this.now()) { + this.handles.delete(digest(token)); + throw new AidenOpaqueHandleError("handle_expired"); + } + return { ...stored.claims }; + } + + resolve( + token: string, + expectedKind: AidenOpaqueHandleKind, + current: AidenOpaqueHandleClaims, + options: { consume?: boolean; now?: number } = {}, + ): AidenOpaqueHandleClaims { + if (!new RegExp(`^${expectedKind}_[A-Za-z0-9_-]{43}$`).test(token)) { + throw new AidenOpaqueHandleError("handle_invalid"); + } + const tokenDigest = digest(token); + const stored = this.handles.get(tokenDigest); + if (!stored || stored.kind !== expectedKind || stored.consumed) { + throw new AidenOpaqueHandleError("handle_invalid"); + } + const claims = stored.claims; + const now = options.now ?? this.now(); + if (now >= claims.expiresAt) { + this.handles.delete(tokenDigest); + throw new AidenOpaqueHandleError("handle_expired"); + } + this.prune(now, tokenDigest); + if (claims.instanceId !== current.instanceId || claims.deviceId !== current.deviceId) { + throw new AidenOpaqueHandleError("handle_wrong_device"); + } + if (expectedKind === "file" && (!claims.workspaceId || !current.workspaceId || claims.workspaceId !== current.workspaceId)) { + throw new AidenOpaqueHandleError("root_policy_changed"); + } + if (claims.rootId !== current.rootId || claims.policyRevision !== current.policyRevision) { + throw new AidenOpaqueHandleError("root_policy_changed"); + } + const relative = path.relative(current.canonicalRootPath, current.canonicalPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new AidenOpaqueHandleError("path_outside_root"); + } + if ( + claims.canonicalRootPath !== current.canonicalRootPath || + claims.canonicalPath !== current.canonicalPath || + claims.filesystemDevice !== current.filesystemDevice || + claims.filesystemInode !== current.filesystemInode || + claims.kind !== current.kind || + claims.depth !== current.depth || + claims.snapshotId !== current.snapshotId || + claims.cursorOffset !== current.cursorOffset || + claims.parentHandleDigest !== current.parentHandleDigest || + claims.displayPath !== current.displayPath + ) { + throw new AidenOpaqueHandleError("filesystem_identity_changed"); + } + if (options.consume) stored.consumed = true; + return { ...claims }; + } + + consumeSelection( + token: string, + current: AidenOpaqueHandleClaims, + createWorkspaceSynchronously: (claims: AidenOpaqueHandleClaims) => T extends PromiseLike ? never : T, + now = Date.now(), + ): T { + if (current.kind !== "directory") throw new AidenOpaqueHandleError("handle_invalid"); + if (createWorkspaceSynchronously.constructor.name === "AsyncFunction") { + throw new AidenOpaqueHandleError("handle_invalid"); + } + const claims = this.resolve(token, "sel", current, { now, consume: true }); + const result = createWorkspaceSynchronously(claims); + if (result && typeof (result as { then?: unknown }).then === "function") { + throw new AidenOpaqueHandleError("handle_invalid"); + } + return result; + } + + storedTokenMaterialForTesting(): string[] { + return [...this.handles.keys()]; + } +} + +export async function inspectAidenFilesystemIdentity(rootPath: string, candidatePath: string): Promise> { + const [canonicalRootPath, canonicalPath] = await Promise.all([realpath(rootPath), realpath(candidatePath)]); + const relative = path.relative(canonicalRootPath, canonicalPath); + if (relative.startsWith("..") || path.isAbsolute(relative)) throw new AidenOpaqueHandleError("path_outside_root"); + const identity = await stat(canonicalPath); + return { + canonicalRootPath, + canonicalPath, + filesystemDevice: String(identity.dev), + filesystemInode: String(identity.ino), + kind: identity.isDirectory() ? "directory" : "file", + }; +} diff --git a/main/services/aiden-remote-operation-contract.test.ts b/main/services/aiden-remote-operation-contract.test.ts new file mode 100644 index 00000000..a79f071b --- /dev/null +++ b/main/services/aiden-remote-operation-contract.test.ts @@ -0,0 +1,867 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + AidenDurableOperationRegistry, + AidenIdempotencyLedger, + AidenOperationContractError, + AidenOperationUnknownOutcomeError, + assertRevision, + MAX_DURABLE_JSON_ARRAY_LENGTH, + MAX_DURABLE_JSON_DEPTH, + MAX_DURABLE_JSON_KEYS, + MAX_DURABLE_JSON_NODES, + MAX_DURABLE_JSON_RESULT_BYTES, + MAX_DURABLE_JSON_STRING_LENGTH, + MAX_DURABLE_LEDGER_SNAPSHOT_BYTES, + MAX_DURABLE_OPERATION_ENTRIES, + type AidenIdempotencySnapshotEntry, +} from "./aiden-remote-operation-contract.js"; + +test("idempotency is scoped and replays the original result while rejecting key reuse", async () => { + const ledger = new AidenIdempotencyLedger(); + const scope = { deviceId: "device-1", route: "/git/push", resourceId: "workspace-1", key: "key-0123456789abcdef" }; + let calls = 0; + const first = ledger.execute(scope, { branch: "main", nested: { a: 1, b: 2 } }, async () => ({ operationId: `op-${++calls}` })); + const replay = ledger.execute(scope, { nested: { b: 2, a: 1 }, branch: "main" }, async () => ({ operationId: `op-${++calls}` })); + assert.strictEqual(first, replay); + assert.deepEqual(await replay, { operationId: "op-1" }); + assert.throws(() => ledger.execute(scope, { branch: "other" }, async () => ({})), (error) => error instanceof AidenOperationContractError && error.code === "idempotency_conflict"); + assert.equal(calls, 1); +}); + +test("an explicitly unknown external outcome remains in-flight indefinitely", async () => { + let now = 1_000; + const scope = { + deviceId: "device-1", + route: "/chats/chat-1/turns", + resourceId: "chat-1", + key: "unknown-turn-key-001", + }; + const ledger = new AidenIdempotencyLedger(undefined, { now: () => now, ttlMs: 10 }); + await assert.rejects( + ledger.execute(scope, { text: "hello" }, async () => { + throw new AidenOperationUnknownOutcomeError(); + }), + (error: unknown) => error instanceof AidenOperationContractError && error.code === "idempotency_in_flight", + ); + assert.equal(ledger.snapshot().entries[0]?.state, "in_flight"); + now = 1_000_000; + assert.throws( + () => new AidenIdempotencyLedger(ledger.snapshot(), { now: () => now }).execute( + scope, + { text: "hello" }, + async () => ({ duplicated: true }), + ), + (error: unknown) => error instanceof AidenOperationContractError && error.code === "idempotency_in_flight", + ); +}); + +test("idempotency snapshots survive restart without retaining raw keys and stay TTL/capacity bounded", async () => { + let now = 1_000; + const options = { maxEntries: 2, ttlMs: 100, now: () => now }; + const scope = { deviceId: "device-1", route: "/scheduled-tasks/task-1/run", resourceId: "task-1", key: "secret-idempotency-key" }; + const ledger = new AidenIdempotencyLedger(undefined, options); + await ledger.execute(scope, { action: "run" }, async () => ({ runId: "run-1" })); + const snapshot = ledger.snapshot(); + assert.equal(JSON.stringify(snapshot).includes(scope.key), false); + + const restarted = new AidenIdempotencyLedger(snapshot, options); + let replayCalls = 0; + assert.deepEqual( + await restarted.execute(scope, { action: "run" }, async () => ({ runId: `run-${++replayCalls + 1}` })), + { runId: "run-1" }, + ); + assert.equal(replayCalls, 0); + + await restarted.execute({ ...scope, key: "key-2" }, { action: "run" }, async () => ({ runId: "run-2" })); + assert.throws( + () => restarted.execute({ ...scope, key: "key-3" }, { action: "run" }, async () => ({ runId: "run-3" })), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_capacity", + ); + assert.equal(restarted.sizeForTesting(), 2); + now = 1_101; + assert.equal(restarted.sizeForTesting(), 0); + assert.deepEqual( + await restarted.execute({ ...scope, key: "key-3" }, { action: "run" }, async () => ({ runId: "run-3" })), + { runId: "run-3" }, + ); +}); + +test("fulfilled idempotency results replay exactly after a JSON snapshot round trip", async () => { + let now = 1_000; + const options = { maxEntries: 1, ttlMs: 100, now: () => now }; + const scope = { deviceId: "device-1", route: "/git/push", resourceId: "workspace-1", key: "key-json-round-trip" }; + const original = { + operationId: "op-1", + accepted: true, + count: 3, + nested: { + message: "preserved", + values: [null, false, 0, "text", { key: "value" }], + }, + }; + const ledger = new AidenIdempotencyLedger(undefined, options); + assert.deepEqual(await ledger.execute(scope, { action: "push" }, async () => original), original); + + const snapshot = ledger.snapshot(); + const persisted = JSON.parse(JSON.stringify(snapshot)) as typeof snapshot; + assert.deepEqual(persisted, snapshot); + + const restarted = new AidenIdempotencyLedger(persisted, options); + assert.deepEqual( + await restarted.execute(scope, { action: "push" }, async () => ({ operationId: "must-not-run" })), + original, + ); + const replay = await restarted.execute(scope, { action: "push" }, async () => ({ operationId: "must-not-run" })); + (replay as typeof original).nested.values[4] = { key: "caller-mutated" }; + assert.deepEqual( + await restarted.execute(scope, { action: "push" }, async () => ({ operationId: "must-not-run" })), + original, + ); + assert.deepEqual(restarted.snapshot(), persisted); + now = 1_001; +}); + +test("non-durable fulfilled results retain an unknown operation instead of becoming retryable", async () => { + let getterCalls = 0; + const accessor: Record = {}; + Object.defineProperty(accessor, "value", { + enumerable: true, + get: () => { + getterCalls += 1; + return "must-not-be-read"; + }, + }); + const cycle: Record = {}; + cycle.self = cycle; + const shared = { value: 1 }; + const sparse: unknown[] = []; + sparse.length = 2; + sparse[1] = "present"; + const extraArrayProperty = ["value"] as unknown[] & { extra?: unknown }; + extraArrayProperty.extra = "omitted"; + const hiddenProperty: Record = { visible: true }; + Object.defineProperty(hiddenProperty, "hidden", { enumerable: false, value: true }); + const symbolKey = { visible: true }; + Object.defineProperty(symbolKey, Symbol("omitted"), { enumerable: true, value: true }); + class UnsupportedResult { + value = 1; + } + const unsafeResults: unknown[] = [ + undefined, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + -0, + 1n, + Symbol("unsupported"), + () => "unsupported", + { nested: undefined }, + { nested: Number.NaN }, + { nested: 1n }, + { nested: Symbol("unsupported") }, + accessor, + cycle, + new Date("2026-01-01T00:00:00.000Z"), + new Map([["key", "value"]]), + new Set(["value"]), + /unsupported/u, + new UnsupportedResult(), + Object.create(null), + sparse, + extraArrayProperty, + hiddenProperty, + symbolKey, + { left: shared, right: shared }, + new Proxy({ value: 1 }, {}), + ]; + + for (const [index, value] of unsafeResults.entries()) { + const ledger = new AidenIdempotencyLedger(undefined, { maxEntries: 1, now: () => 1_000 }); + const scope = { + deviceId: "device-1", + route: "/git/push", + resourceId: "workspace-1", + key: `key-unsafe-${index}`, + }; + await assert.rejects( + ledger.execute(scope, { action: "push" }, async () => value), + (error) => error instanceof AidenOperationContractError && error.code === "internal_error", + ); + const snapshot = ledger.snapshot(); + assert.equal(snapshot.entries[0]?.state, "in_flight"); + assert.equal("result" in (snapshot.entries[0] ?? {}), false); + assert.throws( + () => ledger.execute(scope, { action: "push" }, async () => "must-not-run"), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_in_flight", + ); + } + assert.equal(getterCalls, 0); +}); + +test("durable replay results enforce depth, node, key, array, and string bounds", async () => { + const deepResult: Record = { leaf: true }; + for (let index = 0; index <= MAX_DURABLE_JSON_DEPTH; index += 1) { + Object.assign(deepResult, { child: { ...deepResult } }); + } + const nodeResult: unknown[] = []; + const makeBinaryTree = (depth: number): unknown => { + if (depth === 0) return true; + return [makeBinaryTree(depth - 1), makeBinaryTree(depth - 1)]; + }; + nodeResult.push(makeBinaryTree(Math.ceil(Math.log2(MAX_DURABLE_JSON_NODES + 1)))); + const keyResult = Object.fromEntries( + Array.from({ length: MAX_DURABLE_JSON_KEYS + 1 }, (_, index) => [`key-${index}`, true]), + ); + const arrayResult = Array.from({ length: MAX_DURABLE_JSON_ARRAY_LENGTH + 1 }, () => true); + const stringResult = "x".repeat(MAX_DURABLE_JSON_STRING_LENGTH + 1); + const results: unknown[] = [deepResult, nodeResult, keyResult, arrayResult, stringResult]; + + for (const [index, value] of results.entries()) { + const ledger = new AidenIdempotencyLedger(undefined, { maxEntries: 1, now: () => 1_000 }); + const scope = { + deviceId: "device-1", + route: "/git/push", + resourceId: "workspace-1", + key: `key-bounded-result-${index}`, + }; + await assert.rejects( + ledger.execute(scope, { action: "push" }, async () => value), + (error) => error instanceof AidenOperationContractError && error.code === "internal_error", + ); + assert.deepEqual(ledger.snapshot().entries.map(({ state, errorCode }) => ({ state, errorCode })), [ + { state: "in_flight", errorCode: undefined }, + ]); + } +}); + +test("an oversized replay result stays unknown across TTL and restart until reconciled", async () => { + let now = 1_000; + const options = { maxEntries: 1, ttlMs: 10, now: () => now }; + const ledger = new AidenIdempotencyLedger(undefined, options); + const scope = { deviceId: "device-1", route: "/git/push", resourceId: "workspace-1", key: "key-10mb-result" }; + const oversized = "x".repeat(Math.max(10 * 1_048_576, MAX_DURABLE_JSON_RESULT_BYTES + 1)); + let calls = 0; + await assert.rejects( + ledger.execute(scope, { action: "push" }, async () => { + calls += 1; + return oversized; + }, { operationId: "operation-oversized-result" }), + (error) => error instanceof AidenOperationContractError && error.code === "internal_error", + ); + const snapshot = ledger.snapshot(); + assert.equal(snapshot.entries[0]?.state, "in_flight"); + now = 2_000; + const restarted = new AidenIdempotencyLedger(JSON.parse(JSON.stringify(snapshot)), options); + assert.throws( + () => restarted.execute(scope, { action: "push" }, async () => { + calls += 1; + return "must-not-run"; + }), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_in_flight", + ); + assert.equal(calls, 1); + assert.throws( + () => restarted.reconcile("operation-oversized-result", { + state: "fulfilled", + result: oversized, + }), + (error) => error instanceof AidenOperationContractError && error.code === "internal_error", + ); + assert.equal(restarted.snapshot().entries[0]?.state, "in_flight"); + restarted.reconcile("operation-oversized-result", { + state: "fulfilled", + result: { operationId: "operation-oversized-result", state: "done" }, + }); + assert.deepEqual( + await restarted.execute(scope, { action: "push" }, async () => "must-not-run"), + { operationId: "operation-oversized-result", state: "done" }, + ); + assert.equal(calls, 1); +}); + +test("an aggregate snapshot overflow retains an unknown operation for reconciliation", async () => { + let now = 1_000; + const options = { maxEntries: 3, maxSnapshotBytes: 1_500, now: () => now }; + const firstScope = { deviceId: "device-1", route: "/git/push", resourceId: "workspace-1", key: "key-aggregate-1" }; + const secondScope = { ...firstScope, key: "key-aggregate-2" }; + const result = "x".repeat(700); + const ledger = new AidenIdempotencyLedger(undefined, options); + assert.equal(await ledger.execute(firstScope, { action: "push" }, async () => result), result); + await assert.rejects( + ledger.execute(secondScope, { action: "push" }, async () => result), + (error) => error instanceof AidenOperationContractError && error.code === "internal_error", + ); + const snapshot = ledger.snapshot(); + assert.deepEqual(snapshot.entries.map(({ state, errorCode }) => ({ state, errorCode })), [ + { state: "fulfilled", errorCode: undefined }, + { state: "in_flight", errorCode: undefined }, + ]); + now = 1_001; + const restarted = new AidenIdempotencyLedger(JSON.parse(JSON.stringify(snapshot)), options); + assert.equal(await restarted.execute(firstScope, { action: "push" }, async () => "must-not-run"), result); + assert.throws( + () => restarted.execute(secondScope, { action: "push" }, async () => "must-not-run"), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_in_flight", + ); +}); + +test("bounded replay snapshots survive an exact JSON round trip", async () => { + assert.ok(MAX_DURABLE_LEDGER_SNAPSHOT_BYTES > MAX_DURABLE_JSON_RESULT_BYTES); + const ledger = new AidenIdempotencyLedger(undefined, { maxEntries: 1, now: () => 1_000 }); + const scope = { deviceId: "device-1", route: "/git/push", resourceId: "workspace-1", key: "key-bounded-round-trip" }; + const result = { unicode: "😀é", values: [null, false, 0, "text"], nested: { accepted: true } }; + assert.deepEqual(await ledger.execute(scope, { action: "push" }, async () => result), result); + const snapshot = ledger.snapshot(); + const persisted = JSON.parse(JSON.stringify(snapshot)); + assert.deepEqual(persisted, snapshot); + const restarted = new AidenIdempotencyLedger(persisted, { maxEntries: 1, now: () => 1_000 }); + assert.deepEqual(await restarted.execute(scope, { action: "push" }, async () => ({ changed: true })), result); +}); + +test("persisted high-water time prevents a pruned key from reopening after clock rollback", async () => { + let now = 1_000; + const options = { maxEntries: 1, ttlMs: 100, now: () => now }; + const scope = { deviceId: "device-1", route: "/scheduled-tasks/task-1/run", resourceId: "task-1", key: "key-clock-rollback" }; + let calls = 0; + const ledger = new AidenIdempotencyLedger(undefined, options); + + assert.deepEqual( + await ledger.execute(scope, { action: "run" }, async () => ({ runId: `run-${++calls}` })), + { runId: "run-1" }, + ); + + now = 1_101; + const prunedSnapshot = ledger.snapshot(); + assert.deepEqual(prunedSnapshot.entries, []); + assert.equal(prunedSnapshot.lastObservedAt, 1_101); + const persistedSnapshot = JSON.parse(JSON.stringify(prunedSnapshot)) as typeof prunedSnapshot; + + now = 1_050; + const restarted = new AidenIdempotencyLedger(persistedSnapshot, options); + assert.equal(restarted.sizeForTesting(), 0); + assert.throws( + () => restarted.execute(scope, { action: "run" }, async () => ({ runId: `duplicate-${++calls}` })), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_in_flight", + ); + assert.equal(calls, 1); + + now = 1_101; + assert.throws( + () => restarted.execute(scope, { action: "run" }, async () => ({ runId: `duplicate-${++calls}` })), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_in_flight", + ); + now = 1_102; + assert.deepEqual( + await restarted.execute(scope, { action: "run" }, async () => ({ runId: `run-${++calls}` })), + { runId: "run-2" }, + ); + assert.equal(calls, 2); +}); + +test("legacy array snapshots are rejected while an omitted snapshot remains fresh initialization", async () => { + let now = 1_000; + const options = { maxEntries: 1, ttlMs: 100, now: () => now }; + const scope = { deviceId: "device-1", route: "/scheduled-tasks/task-1/run", resourceId: "task-1", key: "key-legacy-array" }; + const ledger = new AidenIdempotencyLedger(undefined, options); + await ledger.execute(scope, { action: "run" }, async () => ({ runId: "run-1" })); + + now = 1_101; + const legacyArray = ledger.snapshot().entries; + assert.deepEqual(legacyArray, []); + assert.throws( + () => new AidenIdempotencyLedger(legacyArray as never, options), + (error) => error instanceof AidenOperationContractError && error.code === "internal_error", + ); + + const fresh = new AidenIdempotencyLedger(undefined, options); + assert.deepEqual( + await fresh.execute(scope, { action: "run" }, async () => ({ runId: "fresh-run" })), + { runId: "fresh-run" }, + ); +}); + +test("rejected idempotent actions persist a bounded safe failure instead of remaining in flight", async () => { + let now = 1_000; + const options = { maxEntries: 1, ttlMs: 100, now: () => now }; + const scope = { deviceId: "device-1", route: "/workspaces", resourceId: "registry", key: "key-rejected" }; + const ledger = new AidenIdempotencyLedger(undefined, options); + await assert.rejects( + ledger.execute(scope, { mode: "scratch" }, async () => { throw new AidenOperationContractError("revision_conflict"); }), + (error) => error instanceof AidenOperationContractError && error.code === "revision_conflict", + ); + const snapshot = ledger.snapshot(); + assert.deepEqual(snapshot.entries.map(({ state, errorCode }) => ({ state, errorCode })), [ + { state: "rejected", errorCode: "revision_conflict" }, + ]); + const restarted = new AidenIdempotencyLedger(snapshot, options); + assert.throws( + () => restarted.execute(scope, { mode: "scratch" }, async () => ({ workspaceId: "duplicate" })), + (error) => error instanceof AidenOperationContractError && error.code === "revision_conflict", + ); + assert.throws( + () => restarted.execute({ ...scope, key: "other-key" }, { mode: "scratch" }, async () => ({ workspaceId: "other" })), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_capacity", + ); + now = 1_101; + assert.deepEqual( + await restarted.execute({ ...scope, key: "other-key" }, { mode: "scratch" }, async () => ({ workspaceId: "other" })), + { workspaceId: "other" }, + ); +}); + +test("a rejection that cannot fit the snapshot budget remains durably in flight", async () => { + const scope = { + deviceId: "device-1", + route: "/workspaces", + resourceId: "registry", + key: "key-tight-rejection-budget", + }; + const input = { mode: "scratch" }; + let releaseProbe: (() => void) | undefined; + const probe = new AidenIdempotencyLedger(undefined, { maxEntries: 1, now: () => 1_000 }); + const pendingProbe = probe.execute( + scope, + input, + () => new Promise((resolve) => { releaseProbe = () => resolve(true); }), + { operationId: "operation-tight-budget" }, + ); + await Promise.resolve(); + const inFlightBytes = Buffer.byteLength(JSON.stringify(probe.snapshot()), "utf8"); + releaseProbe?.(); + await pendingProbe; + + const ledger = new AidenIdempotencyLedger(undefined, { + maxEntries: 1, + maxSnapshotBytes: inFlightBytes, + now: () => 1_000, + }); + await assert.rejects( + ledger.execute( + scope, + input, + async () => { throw new AidenOperationContractError("revision_conflict"); }, + { operationId: "operation-tight-budget" }, + ), + (error) => error instanceof AidenOperationContractError && error.code === "revision_conflict", + ); + const snapshot = ledger.snapshot(); + assert.equal(Buffer.byteLength(JSON.stringify(snapshot), "utf8"), inFlightBytes); + assert.deepEqual(snapshot.entries.map(({ state, errorCode }) => ({ state, errorCode })), [ + { state: "in_flight", errorCode: undefined }, + ]); + assert.throws( + () => ledger.reconcile("operation-tight-budget", { + state: "rejected", + errorCode: "revision_conflict", + }), + (error) => error instanceof AidenOperationContractError && error.code === "internal_error", + ); + assert.equal(ledger.snapshot().entries[0]?.state, "in_flight"); + assert.throws( + () => ledger.execute(scope, input, async () => "must-not-run"), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_in_flight", + ); +}); + +test("invalid runtime error codes are canonicalized before durable rejection storage", async () => { + const ledger = new AidenIdempotencyLedger(undefined, { maxEntries: 1, now: () => 1_000 }); + const scope = { + deviceId: "device-1", + route: "/workspaces", + resourceId: "registry", + key: "key-invalid-error-code", + }; + const malformed = new AidenOperationContractError("internal_error"); + Object.defineProperty(malformed, "code", { value: "not_a_contract_code" }); + await assert.rejects( + ledger.execute(scope, { mode: "scratch" }, async () => { throw malformed; }), + (error) => error === malformed, + ); + assert.deepEqual(ledger.snapshot().entries.map(({ state, errorCode }) => ({ state, errorCode })), [ + { state: "rejected", errorCode: "internal_error" }, + ]); +}); + +test("idempotency rejects overflow while every bounded entry is still in flight", async () => { + let now = 1_000; + const ledger = new AidenIdempotencyLedger(undefined, { maxEntries: 1, ttlMs: 10, now: () => now }); + let resolveFirst: ((value: string) => void) | undefined; + const firstScope = { deviceId: "device-1", route: "/git/push", resourceId: "workspace-1", key: "key-1" }; + const first = ledger.execute( + firstScope, + { branch: "main" }, + () => new Promise((resolve) => { resolveFirst = resolve; }), + ); + await Promise.resolve(); + now = 1_100; + assert.strictEqual(ledger.execute(firstScope, { branch: "main" }, async () => "duplicate"), first); + const restarted = new AidenIdempotencyLedger(ledger.snapshot(), { maxEntries: 1, ttlMs: 10, now: () => now }); + assert.throws( + () => restarted.execute(firstScope, { branch: "main" }, async () => "duplicate-after-restart"), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_in_flight", + ); + assert.throws( + () => restarted.execute(firstScope, { branch: "other" }, async () => "conflict-after-restart"), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_conflict", + ); + assert.throws( + () => ledger.execute( + { deviceId: "device-1", route: "/git/push", resourceId: "workspace-1", key: "key-2" }, + { branch: "main" }, + async () => "second", + ), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_capacity", + ); + resolveFirst?.("first"); + assert.equal(await first, "first"); + assert.equal(ledger.sizeForTesting(), 1); + now = 1_111; + assert.equal(ledger.sizeForTesting(), 0); +}); + +test("restarted in-flight entries retain a safe operation reference until authoritative reconciliation", async () => { + let now = 1_000; + const options = { maxEntries: 1, ttlMs: 100, now: () => now }; + const scope = { deviceId: "device-1", route: "/scheduled-tasks/task-1/run", resourceId: "task-1", key: "raw-idempotency-secret" }; + let release: ((value: { runId: string }) => void) | undefined; + const ledger = new AidenIdempotencyLedger(undefined, options); + const pending = ledger.execute( + scope, + { action: "run" }, + () => new Promise<{ runId: string }>((resolve) => { release = resolve; }), + { operationId: "op_run_authoritative_1" }, + ); + await Promise.resolve(); + const snapshot = ledger.snapshot(); + assert.equal(snapshot.entries[0]?.operationId, "op_run_authoritative_1"); + assert.equal(snapshot.entries[0]?.expiresAt, null); + assert.equal(JSON.stringify(snapshot).includes(scope.key), false); + assert.throws( + () => ledger.reconcile("op_run_authoritative_1", { state: "fulfilled", result: { runId: "must-not-race-live-promise" } }), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_in_flight", + ); + + now = 10_000; + const restarted = new AidenIdempotencyLedger(snapshot, options); + assert.equal(restarted.sizeForTesting(), 1); + assert.throws( + () => restarted.execute(scope, { action: "run" }, async () => ({ runId: "duplicate" })), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_in_flight", + ); + assert.throws( + () => restarted.execute({ ...scope, key: "other-key" }, { action: "run" }, async () => ({ runId: "duplicate" })), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_capacity", + ); + + restarted.finalize("op_run_authoritative_1", { state: "fulfilled", result: { runId: "run-authoritative" } }); + assert.deepEqual( + await restarted.execute(scope, { action: "run" }, async () => ({ runId: "duplicate-after-reconcile" })), + { runId: "run-authoritative" }, + ); + assert.doesNotThrow(() => restarted.reconcile("op_run_authoritative_1", { state: "fulfilled", result: { runId: "run-authoritative" } })); + assert.throws( + () => restarted.reconcile("op_run_authoritative_1", { state: "fulfilled", result: { runId: "different" } }), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_conflict", + ); + + release?.({ runId: "run-original" }); + assert.deepEqual(await pending, { runId: "run-original" }); +}); + +test("restarted in-flight entries survive a clock rollback when created in the future", async () => { + let now = 1_000; + const options = { maxEntries: 1, ttlMs: 100, now: () => now }; + const scope = { deviceId: "device-1", route: "/scheduled-tasks/task-1/run", resourceId: "task-1", key: "raw-idempotency-secret" }; + let release: ((value: { runId: string }) => void) | undefined; + const ledger = new AidenIdempotencyLedger(undefined, options); + const pending = ledger.execute( + scope, + { action: "run" }, + () => new Promise<{ runId: string }>((resolve) => { release = resolve; }), + { operationId: "op_run_clock_rollback_1" }, + ); + await Promise.resolve(); + const snapshot = ledger.snapshot(); + + now = 500; + const restarted = new AidenIdempotencyLedger(snapshot, options); + assert.equal(restarted.sizeForTesting(), 1); + assert.throws( + () => restarted.execute(scope, { action: "run" }, async () => ({ runId: "duplicate" })), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_in_flight", + ); + restarted.reconcile("op_run_clock_rollback_1", { state: "fulfilled", result: { runId: "authoritative" } }); + assert.deepEqual( + await restarted.execute(scope, { action: "run" }, async () => ({ runId: "duplicate-after-reconcile" })), + { runId: "authoritative" }, + ); + + release?.({ runId: "run-original" }); + assert.deepEqual(await pending, { runId: "run-original" }); +}); + +test("restart rejects malformed timestamps instead of silently reopening idempotency scopes", () => { + const common = { scopeDigest: "scope-1", requestDigest: "request-1", operationId: "op-1" }; + const snapshots: AidenIdempotencySnapshotEntry[] = [ + { ...common, state: "in_flight", createdAt: Number.NaN, expiresAt: null }, + { ...common, state: "in_flight", createdAt: Number.POSITIVE_INFINITY, expiresAt: null }, + { ...common, state: "fulfilled", createdAt: 2, expiresAt: Number.NaN, result: "result" }, + { ...common, state: "fulfilled", createdAt: 2, expiresAt: 1, result: "result" }, + ]; + for (const entry of snapshots) { + assert.throws( + () => new AidenIdempotencyLedger({ version: 1, lastObservedAt: 1, entries: [entry] }, { now: () => 1 }), + (error) => error instanceof AidenOperationContractError && error.code === "internal_error", + ); + } + + const malformedSnapshots = [ + { version: 1, lastObservedAt: Number.NaN, entries: [] }, + { version: 1, lastObservedAt: 1, entries: {} }, + { version: 2, lastObservedAt: 1, entries: [] }, + { version: 1, lastObservedAt: 1, entries: [{ ...common, state: "invalid", createdAt: 1, expiresAt: null }] }, + ]; + for (const snapshot of malformedSnapshots) { + assert.throws( + () => new AidenIdempotencyLedger(snapshot as never, { now: () => 1 }), + (error) => error instanceof AidenOperationContractError && error.code === "internal_error", + ); + } +}); + +test("restart rejects idempotency entries with state-incompatible or non-durable terminal fields", () => { + const common = { scopeDigest: "scope-1", requestDigest: "request-1", operationId: "op-1", createdAt: 1, expiresAt: 2 }; + const malformedEntries = [ + { ...common, state: "fulfilled" as const }, + { ...common, state: "fulfilled" as const, result: undefined }, + { ...common, state: "fulfilled" as const, result: "result", errorCode: "internal_error" as const }, + { ...common, state: "rejected" as const }, + { ...common, state: "rejected" as const, errorCode: "internal_error" as const, result: "incompatible" }, + { ...common, state: "rejected" as const, errorCode: undefined }, + { ...common, state: "in_flight" as const, expiresAt: null, result: "incompatible" }, + { ...common, state: "in_flight" as const, expiresAt: null, errorCode: "internal_error" as const }, + { ...common, state: "in_flight" as const, expiresAt: 2 }, + ]; + for (const entry of malformedEntries) { + assert.throws( + () => new AidenIdempotencyLedger({ version: 1, lastObservedAt: 1, entries: [entry] }, { now: () => 1 }), + (error) => error instanceof AidenOperationContractError && error.code === "internal_error", + ); + } + + const validResultValues = [null, false, 0, ""]; + for (const result of validResultValues) { + assert.doesNotThrow(() => new AidenIdempotencyLedger({ + version: 1, + lastObservedAt: 1, + entries: [{ ...common, state: "fulfilled" as const, result }], + }, { now: () => 1 })); + } +}); + +test("restart enforces exact snapshot and state-specific entry allowlists", () => { + const common = { scopeDigest: "scope-1", requestDigest: "request-1", operationId: "op-1", createdAt: 1, expiresAt: null }; + const validEntry = { ...common, state: "in_flight" as const }; + const snapshotFor = (entry: unknown): unknown => ({ version: 1, lastObservedAt: 1, entries: [entry] }); + let envelopeGetterCalls = 0; + let entryGetterCalls = 0; + + const envelopeExtra = { version: 1, lastObservedAt: 1, entries: [], extra: true }; + const envelopeSymbol = { version: 1, lastObservedAt: 1, entries: [] }; + Object.defineProperty(envelopeSymbol, Symbol("extra"), { enumerable: true, value: true }); + const envelopeHidden = { version: 1, lastObservedAt: 1, entries: [] }; + Object.defineProperty(envelopeHidden, "extra", { enumerable: false, value: true }); + const envelopeAccessor = { version: 1, lastObservedAt: 1, entries: [] }; + Object.defineProperty(envelopeAccessor, "extra", { + enumerable: true, + get: () => { + envelopeGetterCalls += 1; + return true; + }, + }); + + const entryExtra = { ...validEntry, extra: true }; + const entrySymbol = { ...validEntry }; + Object.defineProperty(entrySymbol, Symbol("extra"), { enumerable: true, value: true }); + const entryHidden = { ...validEntry }; + Object.defineProperty(entryHidden, "extra", { enumerable: false, value: true }); + const entryAccessor = { ...validEntry }; + Object.defineProperty(entryAccessor, "extra", { + enumerable: true, + get: () => { + entryGetterCalls += 1; + return true; + }, + }); + + const malformedSnapshots = [ + envelopeExtra, + envelopeSymbol, + envelopeHidden, + envelopeAccessor, + snapshotFor(entryExtra), + snapshotFor(entrySymbol), + snapshotFor(entryHidden), + snapshotFor(entryAccessor), + ]; + for (const snapshot of malformedSnapshots) { + assert.throws( + () => new AidenIdempotencyLedger(snapshot as never, { now: () => 1 }), + (error) => error instanceof AidenOperationContractError && error.code === "internal_error", + ); + } + assert.equal(envelopeGetterCalls, 0); + assert.equal(entryGetterCalls, 0); + + assert.doesNotThrow(() => new AidenIdempotencyLedger({ + version: 1, + lastObservedAt: 1, + entries: [{ ...common, state: "in_flight" as const }], + }, { now: () => 1 })); + assert.doesNotThrow(() => new AidenIdempotencyLedger({ + version: 1, + lastObservedAt: 1, + entries: [{ ...common, state: "fulfilled" as const, expiresAt: 2, result: null }], + }, { now: () => 1 })); + assert.doesNotThrow(() => new AidenIdempotencyLedger({ + version: 1, + lastObservedAt: 1, + entries: [{ ...common, state: "rejected" as const, expiresAt: 2, errorCode: "internal_error" as const }], + }, { now: () => 1 })); +}); + +test("restart refuses to evict active idempotency entries when a snapshot exceeds capacity", () => { + const snapshot = { + version: 1 as const, + lastObservedAt: 1_000, + entries: [ + { scopeDigest: "scope-1", requestDigest: "request-1", operationId: "op-1", state: "in_flight" as const, createdAt: 1, expiresAt: null }, + { scopeDigest: "scope-2", requestDigest: "request-2", operationId: "op-2", state: "in_flight" as const, createdAt: 1, expiresAt: null }, + ], + }; + assert.throws( + () => new AidenIdempotencyLedger(snapshot, { maxEntries: 1, now: () => 1_000 }), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_capacity", + ); +}); + +test("revision checks and durable operation ownership fail closed", () => { + assert.doesNotThrow(() => assertRevision("revision-2", "revision-2")); + assert.throws(() => assertRevision("revision-1", "revision-2"), (error) => error instanceof AidenOperationContractError && error.code === "revision_conflict"); + const operations = new AidenDurableOperationRegistry(); + operations.start("operation-1", "device-1"); + operations.start("operation-1", "device-1"); + assert.throws(() => operations.start("operation-1", "device-2"), (error) => error instanceof AidenOperationContractError && error.code === "capability_denied"); + operations.assertOwner("operation-1", "device-1"); + assert.throws(() => operations.assertOwner("operation-1", "device-2"), (error) => error instanceof AidenOperationContractError && error.code === "capability_denied"); + operations.assertOwner("operation-1", "device-1"); + const restarted = new AidenDurableOperationRegistry(operations.snapshot()); + restarted.assertOwner("operation-1", "device-1"); + assert.throws(() => restarted.assertOwner("operation-1", "device-2"), (error) => error instanceof AidenOperationContractError && error.code === "capability_denied"); +}); + +test("durable operation starts enforce the live 10,000-entry bound and owner completion frees capacity", () => { + const operations = new AidenDurableOperationRegistry(); + for (let index = 0; index < MAX_DURABLE_OPERATION_ENTRIES; index += 1) { + operations.start(`operation-${index}`, `device-${index}`); + } + operations.start("operation-0", "device-0"); + assert.throws( + () => operations.start("operation-over-capacity", "device-over-capacity"), + (error) => error instanceof AidenOperationContractError && error.code === "idempotency_capacity", + ); + + assert.throws( + () => operations.complete("operation-0", "wrong-device"), + (error) => error instanceof AidenOperationContractError && error.code === "capability_denied", + ); + operations.assertOwner("operation-0", "device-0"); + operations.complete("operation-0", "device-0"); + assert.throws( + () => operations.assertOwner("operation-0", "device-0"), + (error) => error instanceof AidenOperationContractError && error.code === "capability_denied", + ); + operations.start("operation-after-completion", "device-after-completion"); + assert.equal(operations.snapshot().length, MAX_DURABLE_OPERATION_ENTRIES); +}); + +test("a full durable operation snapshot remains JSON-round-trip restorable", () => { + const entries = Array.from({ length: MAX_DURABLE_OPERATION_ENTRIES }, (_, index) => ({ + operationId: `operation-${index}`, + deviceId: `device-${index}`, + })); + const operations = new AidenDurableOperationRegistry(entries); + const persisted = JSON.parse(JSON.stringify(operations.snapshot())) as typeof entries; + const restarted = new AidenDurableOperationRegistry(persisted); + restarted.assertOwner("operation-0", "device-0"); + restarted.assertOwner(`operation-${MAX_DURABLE_OPERATION_ENTRIES - 1}`, `device-${MAX_DURABLE_OPERATION_ENTRIES - 1}`); + assert.equal(restarted.snapshot().length, MAX_DURABLE_OPERATION_ENTRIES); +}); + +test("durable operation registry restores only exact bounded identities and rejects duplicate IDs", () => { + const accessorEntry: Record = { deviceId: "device-1" }; + Object.defineProperty(accessorEntry, "operationId", { + enumerable: true, + get: () => "operation-1", + }); + const sparseEntries: unknown[] = []; + sparseEntries.length = 1; + const outOfRangeNumericEntries = [{ operationId: "operation-1", deviceId: "device-1" }] as unknown[]; + Object.defineProperty(outOfRangeNumericEntries, "4294967295", { enumerable: true, value: true }); + const extraEntry = { operationId: "operation-1", deviceId: "device-1", extra: true }; + const symbolEntries = [{ operationId: "operation-1", deviceId: "device-1" }]; + Object.defineProperty(symbolEntries, Symbol("extra"), { enumerable: true, value: true }); + const malformedEntries: unknown[] = [ + null, + {}, + sparseEntries, + outOfRangeNumericEntries, + [{ operationId: "", deviceId: "device-1" }], + [{ operationId: "operation-1", deviceId: "" }], + [{ operationId: "o".repeat(129), deviceId: "device-1" }], + [{ operationId: "operation-1", deviceId: "d".repeat(129) }], + [extraEntry], + [accessorEntry], + [Object.create(null)], + symbolEntries, + ]; + for (const entries of malformedEntries) { + assert.throws( + () => new AidenDurableOperationRegistry(entries as never), + (error) => error instanceof AidenOperationContractError && error.code === "internal_error", + ); + } + + const duplicateEntries = [ + [ + { operationId: "operation-1", deviceId: "device-1" }, + { operationId: "operation-1", deviceId: "device-1" }, + ], + [ + { operationId: "operation-1", deviceId: "device-1" }, + { operationId: "operation-1", deviceId: "device-2" }, + ], + ]; + for (const entries of duplicateEntries) { + assert.throws( + () => new AidenDurableOperationRegistry(entries), + (error) => error instanceof AidenOperationContractError && error.code === "internal_error", + ); + } + + const valid = new AidenDurableOperationRegistry([ + { operationId: "operation-1", deviceId: "device-1" }, + { operationId: "operation-2", deviceId: "device-1" }, + ]); + valid.assertOwner("operation-1", "device-1"); + valid.assertOwner("operation-2", "device-1"); + assert.throws( + () => valid.start("", "device-1"), + (error) => error instanceof AidenOperationContractError && error.code === "internal_error", + ); + assert.throws( + () => valid.start("operation-3", ""), + (error) => error instanceof AidenOperationContractError && error.code === "internal_error", + ); +}); diff --git a/main/services/aiden-remote-operation-contract.ts b/main/services/aiden-remote-operation-contract.ts new file mode 100644 index 00000000..15338932 --- /dev/null +++ b/main/services/aiden-remote-operation-contract.ts @@ -0,0 +1,954 @@ +import { createHash, randomBytes } from "node:crypto"; +import { types as utilTypes } from "node:util"; + +export const MAX_DURABLE_OPERATION_ENTRIES = 10_000; +export const MAX_DURABLE_JSON_DEPTH = 128; +export const MAX_DURABLE_JSON_NODES = 100_000; +export const MAX_DURABLE_JSON_KEYS = 16_384; +export const MAX_DURABLE_JSON_ARRAY_LENGTH = 16_384; +export const MAX_DURABLE_JSON_STRING_LENGTH = 1_048_576; +export const MAX_DURABLE_JSON_RESULT_BYTES = 1_048_576; +export const MAX_DURABLE_LEDGER_SNAPSHOT_BYTES = 16 * 1_048_576; + +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => `${JSON.stringify(key)}:${canonical(child)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export type AidenOperationContractErrorCode = + | "idempotency_conflict" + | "idempotency_capacity" + | "idempotency_in_flight" + | "revision_conflict" + | "capability_denied" + | "internal_error"; + +export class AidenOperationContractError extends Error { + constructor(readonly code: AidenOperationContractErrorCode) { + super(code); + } +} + +/** The external mutation may have committed, so its idempotency admission must never expire into a retry. */ +export class AidenOperationUnknownOutcomeError extends Error { + constructor() { + super("The operation outcome requires authoritative reconciliation."); + this.name = "AidenOperationUnknownOutcomeError"; + } +} + +export interface AidenIdempotencySnapshotEntry { + scopeDigest: string; + requestDigest: string; + operationId: string; + state: "in_flight" | "fulfilled" | "rejected"; + result?: unknown; + errorCode?: AidenOperationContractErrorCode; + createdAt: number; + expiresAt: number | null; +} + +export interface AidenIdempotencySnapshot { + version: 1; + lastObservedAt: number; + entries: AidenIdempotencySnapshotEntry[]; +} + +// A fresh ledger has no snapshot. The versioned envelope is the only format +// that carries the wall-clock high-water mark required for safe restoration. +type AidenIdempotencySnapshotInput = AidenIdempotencySnapshot | undefined; + +export interface AidenIdempotencyOperationReference { + operationId: string; +} + +export type AidenIdempotencyOutcome = + | { state: "fulfilled"; result: T } + | { state: "rejected"; errorCode: AidenOperationContractErrorCode }; + +interface AidenIdempotencyEntry { + requestDigest: string; + result?: Promise; + operationId: string; + createdAt: number; + expiresAt: number | null; + settled: boolean; + settledResult?: unknown; + rejectionCode?: AidenOperationContractErrorCode; +} + +function operationId(): string { + return `op_${randomBytes(24).toString("base64url")}`; +} + +function assertOperationId(value: unknown): asserts value is string { + if (typeof value !== "string" || value.length === 0 || value.length > 128) { + throw new AidenOperationContractError("internal_error"); + } +} + +function boundedPositiveIntegerOption(value: number | undefined, fallback: number, maximum: number): number { + if (value === undefined) return fallback; + if (!Number.isFinite(value)) throw new AidenOperationContractError("internal_error"); + return Math.min(maximum, Math.max(1, Math.floor(value))); +} + +type DurableJsonValue = null | boolean | number | string | DurableJsonValue[] | { [key: string]: DurableJsonValue }; + +interface DurableJsonLimits { + maxDepth: number; + maxNodes: number; + maxKeys: number; + maxArrayLength: number; + maxStringLength: number; +} + +const DURABLE_JSON_LIMITS: DurableJsonLimits = { + maxDepth: MAX_DURABLE_JSON_DEPTH, + maxNodes: MAX_DURABLE_JSON_NODES, + maxKeys: MAX_DURABLE_JSON_KEYS, + maxArrayLength: MAX_DURABLE_JSON_ARRAY_LENGTH, + maxStringLength: MAX_DURABLE_JSON_STRING_LENGTH, +}; + +// A snapshot envelope contains one small object per entry. Its structural +// limits are intentionally larger than a replay result's limits so that the +// hard 10,000-entry ledger bound remains usable; the aggregate UTF-8 budget +// below is the final memory/disk bound for the complete envelope. +const DURABLE_SNAPSHOT_JSON_LIMITS: DurableJsonLimits = { + maxDepth: MAX_DURABLE_JSON_DEPTH, + maxNodes: MAX_DURABLE_JSON_NODES + MAX_DURABLE_OPERATION_ENTRIES, + maxKeys: MAX_DURABLE_JSON_KEYS + MAX_DURABLE_OPERATION_ENTRIES * 8, + maxArrayLength: MAX_DURABLE_OPERATION_ENTRIES, + maxStringLength: MAX_DURABLE_JSON_STRING_LENGTH, +}; + +interface DurableJsonValidationState { + readonly seen: Set; + nodes: number; + keys: number; +} + +function isDenseArrayIndexKey(key: string, length: number): boolean { + if (!/^(?:0|[1-9]\d*)$/u.test(key)) return false; + const index = Number(key); + return Number.isSafeInteger(index) && index >= 0 && index < length && String(index) === key; +} + +function assertExactOwnEnumerableDataKeys(value: object, expectedKeys: readonly string[]): void { + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + if ( + keys.length !== expectedKeys.length || + keys.some((key) => typeof key !== "string" || !expectedKeys.includes(key)) || + expectedKeys.some((key) => !Object.prototype.hasOwnProperty.call(descriptors, key)) + ) { + throw new AidenOperationContractError("internal_error"); + } + for (const key of expectedKeys) { + const descriptor = descriptors[key]; + if ( + !descriptor || + descriptor.enumerable !== true || + !("value" in descriptor) || + "get" in descriptor || + "set" in descriptor + ) { + throw new AidenOperationContractError("internal_error"); + } + } +} + +function durableStringLength(value: string): number { + let length = 0; + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const nextCodeUnit = value.charCodeAt(index + 1); + if (Number.isNaN(nextCodeUnit) || nextCodeUnit < 0xdc00 || nextCodeUnit > 0xdfff) { + throw new AidenOperationContractError("internal_error"); + } + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + throw new AidenOperationContractError("internal_error"); + } + length += 1; + } + return length; +} + +function assertDurableString(value: string, maxLength: number): void { + if (durableStringLength(value) > maxLength) throw new AidenOperationContractError("internal_error"); +} + +/** + * Clone only values whose shape and values survive JSON.stringify/parse + * unchanged. A structured clone is deliberately not sufficient here: it + * accepts values (for example Date, undefined, and non-finite numbers) that + * either disappear or change when the idempotency snapshot is persisted. + */ +function cloneDurableJson(value: unknown, limits: DurableJsonLimits = DURABLE_JSON_LIMITS): DurableJsonValue { + const state: DurableJsonValidationState = { seen: new Set(), nodes: 0, keys: 0 }; + + const visit = (candidate: unknown, depth: number): DurableJsonValue => { + if (depth > limits.maxDepth) throw new AidenOperationContractError("internal_error"); + state.nodes += 1; + if (state.nodes > limits.maxNodes) throw new AidenOperationContractError("internal_error"); + if (candidate === null) return null; + if (typeof candidate === "boolean") return candidate; + if (typeof candidate === "string") { + assertDurableString(candidate, limits.maxStringLength); + return candidate; + } + if (typeof candidate === "number") { + if (!Number.isFinite(candidate) || Object.is(candidate, -0)) { + throw new AidenOperationContractError("internal_error"); + } + return candidate; + } + if (typeof candidate !== "object" || utilTypes.isProxy(candidate)) { + throw new AidenOperationContractError("internal_error"); + } + if (state.seen.has(candidate)) throw new AidenOperationContractError("internal_error"); + state.seen.add(candidate); + + if (Array.isArray(candidate)) { + if (candidate.length > limits.maxArrayLength) throw new AidenOperationContractError("internal_error"); + const descriptors = Object.getOwnPropertyDescriptors(candidate); + const keys = Reflect.ownKeys(descriptors); + const lengthDescriptor = Object.getOwnPropertyDescriptor(candidate, "length"); + const length = lengthDescriptor?.value; + if ( + keys.some( + (key) => + key !== "length" && + (typeof key !== "string" || !isDenseArrayIndexKey(key, length as number)), + ) || Object.getPrototypeOf(candidate) !== Array.prototype + ) { + throw new AidenOperationContractError("internal_error"); + } + if ( + !lengthDescriptor || + !Number.isSafeInteger(length) || + length < 0 || + lengthDescriptor.enumerable || + !("value" in lengthDescriptor) || + "get" in lengthDescriptor || + "set" in lengthDescriptor + ) { + throw new AidenOperationContractError("internal_error"); + } + const result: DurableJsonValue[] = []; + result.length = length; + for (let index = 0; index < length; index += 1) { + const descriptor = descriptors[String(index)]; + if ( + !descriptor || + descriptor.enumerable !== true || + !("value" in descriptor) || + "get" in descriptor || + "set" in descriptor + ) { + throw new AidenOperationContractError("internal_error"); + } + result[index] = visit(descriptor.value, depth + 1); + } + return result; + } + + if (Object.getPrototypeOf(candidate) !== Object.prototype) { + throw new AidenOperationContractError("internal_error"); + } + const descriptors = Object.getOwnPropertyDescriptors(candidate); + const result: { [key: string]: DurableJsonValue } = {}; + for (const key of Reflect.ownKeys(descriptors)) { + if (typeof key !== "string") throw new AidenOperationContractError("internal_error"); + const descriptor = descriptors[key]; + if ( + !descriptor || + descriptor.enumerable !== true || + !("value" in descriptor) || + "get" in descriptor || + "set" in descriptor + ) { + throw new AidenOperationContractError("internal_error"); + } + assertDurableString(key, limits.maxStringLength); + state.keys += 1; + if (state.keys > limits.maxKeys) throw new AidenOperationContractError("internal_error"); + Object.defineProperty(result, key, { + value: visit(descriptor.value, depth + 1), + enumerable: true, + configurable: true, + writable: true, + }); + } + return result; + }; + + try { + return visit(value, 0); + } catch (error) { + if (error instanceof AidenOperationContractError) throw error; + throw new AidenOperationContractError("internal_error"); + } +} + +function durableJsonUtf8Bytes(value: DurableJsonValue): number { + let serialized: string; + try { + serialized = JSON.stringify(value); + } catch { + throw new AidenOperationContractError("internal_error"); + } + if (typeof serialized !== "string") throw new AidenOperationContractError("internal_error"); + const bytes = Buffer.byteLength(serialized, "utf8"); + if (bytes > MAX_DURABLE_JSON_RESULT_BYTES) throw new AidenOperationContractError("internal_error"); + return bytes; +} + +function cloneDurableResult(value: unknown): DurableJsonValue { + const clone = cloneDurableJson(value); + durableJsonUtf8Bytes(clone); + return clone; +} + +function serializedSnapshotBytes(snapshot: AidenIdempotencySnapshot): number { + let serialized: string; + try { + serialized = JSON.stringify(snapshot); + } catch { + throw new AidenOperationContractError("internal_error"); + } + if (typeof serialized !== "string") throw new AidenOperationContractError("internal_error"); + return Buffer.byteLength(serialized, "utf8"); +} + +const operationContractErrorCodes = new Set([ + "idempotency_conflict", + "idempotency_capacity", + "idempotency_in_flight", + "revision_conflict", + "capability_denied", + "internal_error", +]); + +function assertSnapshotString(value: unknown): asserts value is string { + if (typeof value !== "string" || value.length === 0) { + throw new AidenOperationContractError("internal_error"); + } + assertDurableString(value, MAX_DURABLE_JSON_STRING_LENGTH); +} + +function assertFiniteTimestamp(value: unknown): asserts value is number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new AidenOperationContractError("internal_error"); + } +} + +function assertSnapshotErrorCode(value: unknown): asserts value is AidenOperationContractErrorCode | undefined { + if (value !== undefined && (typeof value !== "string" || !operationContractErrorCodes.has(value as AidenOperationContractErrorCode))) { + throw new AidenOperationContractError("internal_error"); + } +} + +function assertSnapshotEntry(value: unknown): asserts value is AidenIdempotencySnapshotEntry { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new AidenOperationContractError("internal_error"); + } + cloneDurableJson(value); + const entry = value as Partial; + const state = entry.state; + const expectedKeys = state === "in_flight" + ? ["scopeDigest", "requestDigest", "operationId", "state", "createdAt", "expiresAt"] + : state === "fulfilled" + ? ["scopeDigest", "requestDigest", "operationId", "state", "result", "createdAt", "expiresAt"] + : state === "rejected" + ? ["scopeDigest", "requestDigest", "operationId", "state", "errorCode", "createdAt", "expiresAt"] + : []; + if (expectedKeys.length === 0) throw new AidenOperationContractError("internal_error"); + assertExactOwnEnumerableDataKeys(value, expectedKeys); + const hasResult = Object.prototype.hasOwnProperty.call(entry, "result"); + const hasErrorCode = Object.prototype.hasOwnProperty.call(entry, "errorCode"); + assertSnapshotString(entry.scopeDigest); + assertSnapshotString(entry.requestDigest); + assertOperationId(entry.operationId); + if (entry.state !== "in_flight" && entry.state !== "fulfilled" && entry.state !== "rejected") { + throw new AidenOperationContractError("internal_error"); + } + assertFiniteTimestamp(entry.createdAt); + if (entry.expiresAt !== null) assertFiniteTimestamp(entry.expiresAt); + if (entry.state === "in_flight") { + if (entry.expiresAt !== null || hasResult || hasErrorCode) { + throw new AidenOperationContractError("internal_error"); + } + return; + } + if (typeof entry.expiresAt !== "number" || entry.createdAt > entry.expiresAt) { + throw new AidenOperationContractError("internal_error"); + } + if (entry.state === "fulfilled") { + // Undefined is not a durable result: JSON.stringify omits it, so an + // explicitly undefined result would become indistinguishable from a + // missing result after restart. + if (!hasResult || entry.result === undefined || hasErrorCode) { + throw new AidenOperationContractError("internal_error"); + } + cloneDurableResult(entry.result); + return; + } + if (!hasErrorCode || hasResult) { + throw new AidenOperationContractError("internal_error"); + } + assertSnapshotErrorCode(entry.errorCode); + if (entry.errorCode === undefined) { + throw new AidenOperationContractError("internal_error"); + } +} + +function assertSnapshot(value: unknown): asserts value is AidenIdempotencySnapshot { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new AidenOperationContractError("internal_error"); + } + cloneDurableJson(value, DURABLE_SNAPSHOT_JSON_LIMITS); + assertExactOwnEnumerableDataKeys(value, ["version", "lastObservedAt", "entries"]); + const snapshot = value as Partial; + if ( + snapshot.version !== 1 || + !Array.isArray(snapshot.entries) || + snapshot.entries.length > MAX_DURABLE_OPERATION_ENTRIES + ) { + throw new AidenOperationContractError("internal_error"); + } + assertFiniteTimestamp(snapshot.lastObservedAt); + for (const entry of snapshot.entries) assertSnapshotEntry(entry); + if (serializedSnapshotBytes(snapshot as AidenIdempotencySnapshot) > MAX_DURABLE_LEDGER_SNAPSHOT_BYTES) { + throw new AidenOperationContractError("internal_error"); + } +} + +export class AidenIdempotencyLedger { + private readonly entries = new Map(); + private lastObservedAt: number; + private clockRollbackDetected = false; + + constructor( + snapshot: AidenIdempotencySnapshotInput = undefined, + private readonly options: { + maxEntries?: number; + ttlMs?: number; + now?: () => number; + maxSnapshotBytes?: number; + } = {}, + ) { + const now = this.readWallClock(); + // Do not accept the pre-envelope array shape here. A legacy array has no + // persisted lastObservedAt value, so restoring it could reopen a key after + // a forward clock jump followed by a rollback. Fresh initialization is + // represented by an omitted/undefined snapshot instead. + const restoredSnapshot = snapshot === undefined ? undefined : (assertSnapshot(snapshot), snapshot); + if ( + restoredSnapshot && + serializedSnapshotBytes(restoredSnapshot) > this.maxSnapshotBytes + ) { + throw new AidenOperationContractError("internal_error"); + } + this.lastObservedAt = restoredSnapshot?.lastObservedAt ?? now; + if (now < this.lastObservedAt) this.clockRollbackDetected = true; + else this.lastObservedAt = now; + const snapshotEntries: readonly AidenIdempotencySnapshotEntry[] = restoredSnapshot?.entries ?? []; + const retained: AidenIdempotencySnapshotEntry[] = []; + for (const candidate of snapshotEntries) { + // In-flight work has no local expiration. A clock rollback or a writer + // whose clock is ahead must never turn an unresolved operation into a + // fresh execution opportunity. + if ( + candidate.state === "in_flight" + || this.clockRollbackDetected + || (typeof candidate.expiresAt === "number" && candidate.expiresAt > now) + ) retained.push(candidate); + } + if (retained.length > this.maxEntries) { + throw new AidenOperationContractError("idempotency_capacity"); + } + for (const entry of retained) { + const settled = entry.state !== "in_flight"; + const result = entry.state === "fulfilled" ? cloneDurableResult(entry.result) : undefined; + const restoredOperationId = entry.operationId; + assertOperationId(restoredOperationId); + if ([...this.entries.values()].some((existing) => existing.operationId === restoredOperationId)) { + throw new AidenOperationContractError("idempotency_conflict"); + } + if (this.entries.has(entry.scopeDigest)) { + throw new AidenOperationContractError("idempotency_conflict"); + } + this.entries.set(entry.scopeDigest, { + requestDigest: entry.requestDigest, + operationId: restoredOperationId, + result: entry.state === "fulfilled" ? Promise.resolve().then(() => cloneDurableResult(result)) : undefined, + createdAt: entry.createdAt, + expiresAt: settled ? entry.expiresAt : null, + settled, + settledResult: entry.state === "fulfilled" ? result : undefined, + rejectionCode: entry.state === "rejected" ? entry.errorCode ?? "internal_error" : undefined, + }); + } + this.assertSnapshotBudget(this.buildSnapshot()); + } + + private get maxEntries(): number { + return boundedPositiveIntegerOption( + this.options.maxEntries, + MAX_DURABLE_OPERATION_ENTRIES, + MAX_DURABLE_OPERATION_ENTRIES, + ); + } + + private get maxSnapshotBytes(): number { + return boundedPositiveIntegerOption( + this.options.maxSnapshotBytes, + MAX_DURABLE_LEDGER_SNAPSHOT_BYTES, + MAX_DURABLE_LEDGER_SNAPSHOT_BYTES, + ); + } + + private get ttlMs(): number { + return Math.max(1, this.options.ttlMs ?? 24 * 60 * 60 * 1_000); + } + + private now(): number { + const now = this.readWallClock(); + if (now < this.lastObservedAt) this.clockRollbackDetected = true; + else if (now > this.lastObservedAt) { + this.lastObservedAt = now; + this.clockRollbackDetected = false; + } + return now; + } + + private readWallClock(): number { + const now = this.options.now?.() ?? Date.now(); + assertFiniteTimestamp(now); + return now; + } + + private effectiveNow(now: number): number { + return Math.max(now, this.lastObservedAt); + } + + private prune(now: number): void { + if (this.clockRollbackDetected) return; + for (const [key, entry] of this.entries) { + if (entry.settled && entry.expiresAt !== null && entry.expiresAt <= now) this.entries.delete(key); + } + } + + private snapshotEntry(scopeDigest: string, entry: AidenIdempotencyEntry): AidenIdempotencySnapshotEntry { + return { + scopeDigest, + requestDigest: entry.requestDigest, + operationId: entry.operationId, + state: !entry.settled + ? "in_flight" + : entry.rejectionCode + ? "rejected" + : "fulfilled", + ...(entry.rejectionCode + ? { errorCode: entry.rejectionCode } + : entry.settled + ? { result: cloneDurableResult(entry.settledResult) } + : {}), + createdAt: entry.createdAt, + expiresAt: entry.settled ? entry.expiresAt : null, + }; + } + + private buildSnapshot(candidate?: { scopeDigest: string; entry: AidenIdempotencyEntry }): AidenIdempotencySnapshot { + let candidateIncluded = false; + const entries = [...this.entries].map(([scopeDigest, entry]) => { + if (candidate && scopeDigest === candidate.scopeDigest) { + candidateIncluded = true; + return this.snapshotEntry(scopeDigest, candidate.entry); + } + return this.snapshotEntry(scopeDigest, entry); + }); + if (candidate && !candidateIncluded) { + entries.push(this.snapshotEntry(candidate.scopeDigest, candidate.entry)); + } + return { + version: 1, + lastObservedAt: this.lastObservedAt, + entries, + }; + } + + private assertSnapshotBudget(snapshot: AidenIdempotencySnapshot): void { + if (serializedSnapshotBytes(snapshot) > this.maxSnapshotBytes) { + throw new AidenOperationContractError("internal_error"); + } + } + + private assertPersistableSnapshot(snapshot: AidenIdempotencySnapshot): void { + assertSnapshot(snapshot); + this.assertSnapshotBudget(snapshot); + } + + private retainUnknownOutcome(entry: AidenIdempotencyEntry): void { + entry.result = undefined; + entry.settledResult = undefined; + entry.rejectionCode = undefined; + entry.settled = false; + entry.expiresAt = null; + } + + private rejectionCode(error: unknown): AidenOperationContractErrorCode { + try { + if (error instanceof AidenOperationContractError) { + const code = error.code; + if (operationContractErrorCodes.has(code)) return code; + } + } catch { + // Hostile or malformed error objects never gain durable code authority. + } + return "internal_error"; + } + + private settleRejected( + scopeDigest: string, + entry: AidenIdempotencyEntry, + error: unknown, + ): boolean { + if (entry.settled) return true; + const rejectedEntry: AidenIdempotencyEntry = { + ...entry, + result: undefined, + settledResult: undefined, + rejectionCode: this.rejectionCode(error), + settled: true, + expiresAt: this.effectiveNow(this.now()) + this.ttlMs, + }; + try { + this.assertPersistableSnapshot( + this.buildSnapshot({ scopeDigest, entry: rejectedEntry }), + ); + } catch { + this.retainUnknownOutcome(entry); + return false; + } + entry.result = undefined; + entry.settledResult = undefined; + entry.rejectionCode = rejectedEntry.rejectionCode; + entry.settled = true; + entry.expiresAt = rejectedEntry.expiresAt; + return true; + } + + private admit(): void { + if (this.entries.size < this.maxEntries) return; + throw new AidenOperationContractError("idempotency_capacity"); + } + + execute( + scope: { deviceId: string; route: string; resourceId: string; key: string }, + input: unknown, + action: () => Promise, + reference?: AidenIdempotencyOperationReference, + ): Promise { + const now = this.now(); + this.prune(now); + const ledgerKey = createHash("sha256").update(canonical(scope)).digest("base64url"); + const requestDigest = createHash("sha256").update(canonical(input)).digest("base64url"); + const existing = this.entries.get(ledgerKey); + if (existing) { + if (existing.requestDigest !== requestDigest) throw new AidenOperationContractError("idempotency_conflict"); + if (existing.rejectionCode) throw new AidenOperationContractError(existing.rejectionCode); + if (!existing.result) throw new AidenOperationContractError("idempotency_in_flight"); + if (existing.settled) { + return Promise.resolve().then(() => cloneDurableResult(existing.settledResult)) as Promise; + } + return existing.result as Promise; + } + if (this.clockRollbackDetected) throw new AidenOperationContractError("idempotency_in_flight"); + this.admit(); + const restoredOperationId = reference?.operationId ?? operationId(); + assertOperationId(restoredOperationId); + if ([...this.entries.values()].some((entry) => entry.operationId === restoredOperationId)) { + throw new AidenOperationContractError("idempotency_conflict"); + } + const entry: AidenIdempotencyEntry = { + requestDigest, + operationId: restoredOperationId, + createdAt: this.effectiveNow(now), + expiresAt: null, + settled: false, + }; + this.assertSnapshotBudget(this.buildSnapshot({ scopeDigest: ledgerKey, entry })); + this.entries.set(ledgerKey, entry); + const result = Promise.resolve() + .then(action) + .then( + (value) => { + let durableValue: DurableJsonValue; + let settledAt: number; + try { + durableValue = cloneDurableResult(value); + settledAt = this.effectiveNow(this.now()) + this.ttlMs; + const settledEntry: AidenIdempotencyEntry = { + ...entry, + expiresAt: settledAt, + settled: true, + settledResult: durableValue, + rejectionCode: undefined, + }; + this.assertPersistableSnapshot( + this.buildSnapshot({ scopeDigest: ledgerKey, entry: settledEntry }), + ); + } catch (error) { + // The action may already have committed an external mutation. If + // its result cannot be durably represented, retain the stable + // operation reference as an indefinite unknown outcome. Expiring + // it into a retry would risk executing the mutation twice. + this.retainUnknownOutcome(entry); + throw error instanceof AidenOperationContractError + ? error + : new AidenOperationContractError("internal_error"); + } + entry.settledResult = durableValue; + entry.settled = true; + entry.expiresAt = settledAt; + entry.rejectionCode = undefined; + // Keep the stored value isolated from callers that mutate a result + // after fulfillment. The snapshot owns its separate durable clone. + return cloneDurableResult(durableValue); + }, + (error) => { + if (error instanceof AidenOperationUnknownOutcomeError) { + this.retainUnknownOutcome(entry); + throw new AidenOperationContractError("idempotency_in_flight"); + } + this.settleRejected(ledgerKey, entry, error); + throw error; + }, + ); + entry.result = result; + return result as Promise; + } + + reconcile(operationIdToFinalize: string, outcome: AidenIdempotencyOutcome): void { + assertOperationId(operationIdToFinalize); + this.prune(this.now()); + const entryRecord = [...this.entries].find(([, candidate]) => candidate.operationId === operationIdToFinalize); + if (!entryRecord) throw new AidenOperationContractError("idempotency_in_flight"); + const [ledgerKey, entry] = entryRecord; + if (entry.settled) { + const durableOutcomeResult = outcome.state === "fulfilled" ? cloneDurableResult(outcome.result) : undefined; + if (outcome.state === "rejected") assertSnapshotErrorCode(outcome.errorCode); + const sameOutcome = outcome.state === "fulfilled" + ? !entry.rejectionCode && canonical(entry.settledResult) === canonical(durableOutcomeResult) + : entry.rejectionCode === outcome.errorCode; + if (!sameOutcome) throw new AidenOperationContractError("idempotency_conflict"); + return; + } + if (entry.result) throw new AidenOperationContractError("idempotency_in_flight"); + if (outcome.state === "fulfilled") { + let durableResult: DurableJsonValue; + let settledAt: number; + try { + durableResult = cloneDurableResult(outcome.result); + settledAt = this.effectiveNow(this.now()) + this.ttlMs; + const settledEntry: AidenIdempotencyEntry = { + ...entry, + expiresAt: settledAt, + settled: true, + settledResult: durableResult, + rejectionCode: undefined, + }; + this.assertPersistableSnapshot( + this.buildSnapshot({ scopeDigest: ledgerKey, entry: settledEntry }), + ); + } catch (error) { + this.retainUnknownOutcome(entry); + throw error instanceof AidenOperationContractError + ? error + : new AidenOperationContractError("internal_error"); + } + entry.settledResult = durableResult; + entry.result = Promise.resolve().then(() => cloneDurableResult(entry.settledResult)); + entry.rejectionCode = undefined; + entry.settled = true; + entry.expiresAt = settledAt; + } else { + assertSnapshotErrorCode(outcome.errorCode); + if (outcome.errorCode === undefined) { + throw new AidenOperationContractError("internal_error"); + } + if (!this.settleRejected( + ledgerKey, + entry, + new AidenOperationContractError(outcome.errorCode), + )) { + throw new AidenOperationContractError("internal_error"); + } + } + } + + finalize(operationIdToFinalize: string, outcome: AidenIdempotencyOutcome): void { + this.reconcile(operationIdToFinalize, outcome); + } + + snapshot(): AidenIdempotencySnapshot { + this.prune(this.now()); + const snapshot = this.buildSnapshot(); + this.assertSnapshotBudget(snapshot); + return snapshot; + } + + sizeForTesting(): number { + this.prune(this.now()); + return this.entries.size; + } +} + +export function assertRevision(expected: string, current: string): void { + if (expected !== current) throw new AidenOperationContractError("revision_conflict"); +} + +export class AidenDurableOperationRegistry { + private readonly owners = new Map(); + + constructor(entries: readonly { operationId: string; deviceId: string }[] = []) { + assertDurableOperationEntries(entries); + for (const entry of entries) { + if (this.owners.has(entry.operationId)) { + throw new AidenOperationContractError("internal_error"); + } + this.owners.set(entry.operationId, entry.deviceId); + } + } + + start(operationId: string, deviceId: string): void { + assertOperationId(operationId); + assertOperationId(deviceId); + const existing = this.owners.get(operationId); + if (existing && existing !== deviceId) throw new AidenOperationContractError("capability_denied"); + if (existing) return; + if (this.owners.size >= MAX_DURABLE_OPERATION_ENTRIES) { + throw new AidenOperationContractError("idempotency_capacity"); + } + this.owners.set(operationId, deviceId); + } + + complete(operationId: string, deviceId: string): void { + assertOperationId(operationId); + assertOperationId(deviceId); + if (this.owners.get(operationId) !== deviceId) { + throw new AidenOperationContractError("capability_denied"); + } + this.owners.delete(operationId); + } + + remove(operationId: string, deviceId: string): void { + this.complete(operationId, deviceId); + } + + assertOwner(operationId: string, deviceId: string): void { + assertOperationId(operationId); + assertOperationId(deviceId); + if (this.owners.get(operationId) !== deviceId) { + throw new AidenOperationContractError("capability_denied"); + } + } + + snapshot(): { operationId: string; deviceId: string }[] { + return [...this.owners].map(([operationId, deviceId]) => ({ operationId, deviceId })); + } +} + +function assertDurableOperationEntries( + value: unknown, +): asserts value is readonly { operationId: string; deviceId: string }[] { + try { + if ( + !Array.isArray(value) || + utilTypes.isProxy(value) || + Object.getPrototypeOf(value) !== Array.prototype || + value.length > MAX_DURABLE_OPERATION_ENTRIES + ) { + throw new AidenOperationContractError("internal_error"); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); + if ( + !lengthDescriptor || + lengthDescriptor.enumerable || + !("value" in lengthDescriptor) || + "get" in lengthDescriptor || + "set" in lengthDescriptor || + !Number.isSafeInteger(lengthDescriptor.value) || + lengthDescriptor.value < 0 || + keys.some( + (key) => + key !== "length" && + (typeof key !== "string" || !isDenseArrayIndexKey(key, value.length)), + ) + ) { + throw new AidenOperationContractError("internal_error"); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = descriptors[String(index)]; + if ( + !descriptor || + descriptor.enumerable !== true || + !("value" in descriptor) || + "get" in descriptor || + "set" in descriptor + ) { + throw new AidenOperationContractError("internal_error"); + } + assertDurableOperationEntry(descriptor.value); + } + } catch (error) { + if (error instanceof AidenOperationContractError) throw error; + throw new AidenOperationContractError("internal_error"); + } +} + +function assertDurableOperationEntry(value: unknown): asserts value is { operationId: string; deviceId: string } { + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) || + utilTypes.isProxy(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + throw new AidenOperationContractError("internal_error"); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + const keys = Reflect.ownKeys(descriptors); + if ( + keys.length !== 2 || + !keys.every((key) => key === "operationId" || key === "deviceId") + ) { + throw new AidenOperationContractError("internal_error"); + } + for (const key of ["operationId", "deviceId"] as const) { + const descriptor = descriptors[key]; + if ( + !descriptor || + descriptor.enumerable !== true || + !("value" in descriptor) || + "get" in descriptor || + "set" in descriptor + ) { + throw new AidenOperationContractError("internal_error"); + } + assertOperationId(descriptor.value); + } +} diff --git a/main/services/aiden-remote-pairing.test.ts b/main/services/aiden-remote-pairing.test.ts new file mode 100644 index 00000000..f6d9ba96 --- /dev/null +++ b/main/services/aiden-remote-pairing.test.ts @@ -0,0 +1,341 @@ +import assert from "node:assert/strict"; +import { createDecipheriv, hkdfSync } from "node:crypto"; +import test from "node:test"; +import { + AidenRemotePairingService, + normalizeAidenManualPairingCode, + parseAidenRemotePairingExchangeInput, +} from "./aiden-remote-pairing.js"; +import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; + +const endpoint = "https://aiden.example.test/api/aiden/v1"; +const fingerprint = `sha256/${Buffer.alloc(32, 4).toString("base64")}`; + +function fixture(options: { issueFails?: boolean } = {}) { + let now = 1_000; + let issued = 0; + let randomCounter = 0; + let statusChanges = 0; + const service = new AidenRemotePairingService( + "instance-1", + { + issueDevice: async (input) => { + issued += 1; + if (options.issueFails) throw new Error("disk failed"); + return { + credential: Buffer.alloc(32, 8).toString("base64url"), + device: { + id: "device-1", + name: input.name, + type: input.type, + clientVersion: input.clientVersion, + capabilities: [...(input.capabilities ?? [])], + createdAt: now, + lastSeenAt: now, + }, + }; + }, + }, + { + now: () => now, + randomBytes: (size) => Buffer.alloc(size, ++randomCounter), + }, + () => { + statusChanges += 1; + }, + () => "Studio Mac", + ); + return { + service, + issued: () => issued, + statusChanges: () => statusChanges, + advance: (milliseconds: number) => { + now += milliseconds; + }, + }; +} + +function exchange(secret: string, acceptsDisplayName = true) { + return { + secret, + deviceName: "Sambit’s iPhone", + deviceType: "iphone" as const, + clientVersion: "1.0", + ...(acceptsDisplayName ? { acceptsDisplayName: true } : {}), + }; +} + +test("pairing opens for exactly five minutes and consumes its 256-bit secret once", async () => { + const pairing = fixture(); + const opened = pairing.service.begin(endpoint, fingerprint); + const { bootstrap } = opened; + assert.match(opened.sessionId, /^pairing_[A-Za-z0-9_-]{32}$/u); + assert.equal(bootstrap.expiresAt, new Date(301_000).toISOString()); + assert.equal(bootstrap.secret.length, 43); + assert.match(opened.manualCode, /^[0-9A-HJKMNP-TV-Z]{4}(?:-[0-9A-HJKMNP-TV-Z]{4}){4}$/u); + const result = await pairing.service.exchange(exchange(bootstrap.secret), "lan:phone"); + assert.equal(result.deviceId, "device-1"); + assert.equal(result.endpoint, endpoint); + assert.equal(result.displayName, "Studio Mac"); + assert.equal(result.capabilities.includes("workspace:manage"), true); + assert.deepEqual(pairing.service.status(), { + sessionId: opened.sessionId, + state: "finishing", + deviceId: "device-1", + }); + assert.equal(pairing.statusChanges(), 3); + await assert.rejects( + pairing.service.exchange(exchange(bootstrap.secret), "lan:phone"), + (error: unknown) => + error instanceof AidenRemoteServiceError && + error.code === "pairing_already_used", + ); + assert.equal(pairing.issued(), 1); +}); + +test("manual setup code decrypts the canonical payload without crossing the wire", () => { + const pairing = fixture(); + const opened = pairing.service.begin(endpoint, fingerprint); + const payload = JSON.stringify({ + kind: "aiden-pairing-v1", + bootstrap: opened.bootstrap, + trust: { mode: "system" }, + }); + pairing.service.sealManualPayload(opened.sessionId, payload); + const sealed = pairing.service.manualBootstrap(); + const code = normalizeAidenManualPairingCode(opened.manualCode); + const salt = Buffer.from(sealed.salt, "base64url"); + const nonce = Buffer.from(sealed.nonce, "base64url"); + const key = Buffer.from(hkdfSync( + "sha256", + Buffer.from(code, "ascii"), + salt, + Buffer.from(`aiden-manual-pairing-v1\n${sealed.sessionId}`, "utf8"), + 32, + )); + const decipher = createDecipheriv("aes-256-gcm", key, nonce, { authTagLength: 16 }); + decipher.setAAD(Buffer.from( + `aiden-manual-pairing-v1\n${sealed.sessionId}\n${sealed.expiresAt}`, + "utf8", + )); + decipher.setAuthTag(Buffer.from(sealed.tag, "base64url")); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(sealed.ciphertext, "base64url")), + decipher.final(), + ]).toString("utf8"); + + assert.equal(plaintext, payload); + assert.equal(JSON.stringify(sealed).includes(code), false); + assert.equal(JSON.stringify(sealed).includes(opened.bootstrap.secret), false); + assert.equal(Buffer.from(sealed.salt, "base64url").length, 16); + assert.equal(Buffer.from(sealed.nonce, "base64url").length, 12); + assert.equal(Buffer.from(sealed.tag, "base64url").length, 16); +}); + +test("manual bootstrap follows pairing expiry, replacement, consumption, and exact code grammar", async () => { + assert.equal( + normalizeAidenManualPairingCode("0123-4567-89AB-CDEF-GHJK"), + "0123456789ABCDEFGHJK", + ); + for (const invalid of [ + "0123-4567-89AB-CDEF-GHJI", + "0123-4567-89AB-CDEF-GHJK", + "0123-4567-89AB-CDEF", + ]) { + assert.throws( + () => normalizeAidenManualPairingCode(invalid), + (error: unknown) => error instanceof AidenRemoteServiceError && error.code === "invalid_request", + ); + } + + const pairing = fixture(); + assert.throws( + () => pairing.service.manualBootstrap(), + (error: unknown) => error instanceof AidenRemoteServiceError && error.code === "pairing_closed", + ); + const first = pairing.service.begin(endpoint, fingerprint); + pairing.service.sealManualPayload(first.sessionId, "first"); + const firstCiphertext = pairing.service.manualBootstrap().ciphertext; + const second = pairing.service.begin(endpoint, fingerprint); + pairing.service.sealManualPayload(second.sessionId, "second"); + assert.notEqual(pairing.service.manualBootstrap().ciphertext, firstCiphertext); + await pairing.service.exchange(exchange(second.bootstrap.secret), "phone"); + assert.throws( + () => pairing.service.manualBootstrap(), + (error: unknown) => error instanceof AidenRemoteServiceError && error.code === "pairing_already_used", + ); + + const expiring = fixture(); + const expiringWindow = expiring.service.begin(endpoint, fingerprint); + expiring.service.sealManualPayload(expiringWindow.sessionId, "payload"); + expiring.advance(5 * 60_000); + assert.throws( + () => expiring.service.manualBootstrap(), + (error: unknown) => error instanceof AidenRemoteServiceError && error.code === "pairing_expired", + ); +}); + +test("pairing emits additive display metadata only to clients that opt in", async () => { + const legacy = fixture(); + const legacyWindow = legacy.service.begin(endpoint, fingerprint); + const legacyResult = await legacy.service.exchange( + exchange(legacyWindow.bootstrap.secret, false), + "legacy-client", + ); + assert.equal("displayName" in legacyResult, false); + + const current = fixture(); + const currentWindow = current.service.begin(endpoint, fingerprint); + const currentResult = await current.service.exchange( + exchange(currentWindow.bootstrap.secret), + "current-client", + ); + assert.equal(currentResult.displayName, "Studio Mac"); +}); + +test("an expired, closed, or invalid pairing window fails with stable safe codes", async () => { + const pairing = fixture(); + await assert.rejects( + pairing.service.exchange(exchange("x".repeat(43)), "source"), + (error: unknown) => + error instanceof AidenRemoteServiceError && error.code === "pairing_closed", + ); + const opened = pairing.service.begin(endpoint, fingerprint); + const { bootstrap } = opened; + await assert.rejects( + pairing.service.exchange(exchange("x".repeat(43)), "source"), + (error: unknown) => + error instanceof AidenRemoteServiceError && + error.code === "authentication_required", + ); + pairing.advance(5 * 60_000); + assert.equal(pairing.service.status()?.state, "expired"); + await assert.rejects( + pairing.service.exchange(exchange(bootstrap.secret), "source"), + (error: unknown) => + error instanceof AidenRemoteServiceError && error.code === "pairing_expired", + ); +}); + +test("pairing persistence failure still burns the one-time secret", async () => { + const pairing = fixture({ issueFails: true }); + const opened = pairing.service.begin(endpoint, fingerprint); + const { bootstrap } = opened; + await assert.rejects( + pairing.service.exchange(exchange(bootstrap.secret), "source"), + /disk failed/u, + ); + await assert.rejects( + pairing.service.exchange(exchange(bootstrap.secret), "source"), + (error: unknown) => + error instanceof AidenRemoteServiceError && + error.code === "pairing_already_used", + ); + assert.equal(pairing.issued(), 1); + assert.equal(pairing.service.status()?.state, "failed"); +}); + +test("pairing close is conditional on the exact main-owned session", () => { + const pairing = fixture(); + const first = pairing.service.begin(endpoint, fingerprint); + const second = pairing.service.begin(endpoint, fingerprint); + assert.equal(pairing.service.close(first.sessionId), false); + assert.equal(pairing.service.status()?.sessionId, second.sessionId); + assert.equal(pairing.service.close(second.sessionId), true); + assert.equal(pairing.service.status(), undefined); +}); + +test("closing or replacing a consumed window fences deferred credential issuance", async () => { + for (const action of ["close", "replace"] as const) { + let randomCounter = 0; + let releaseIssuance: (() => void) | undefined; + let issuanceStarted: (() => void) | undefined; + const started = new Promise((resolve) => { issuanceStarted = resolve; }); + const release = new Promise((resolve) => { releaseIssuance = resolve; }); + let committed = 0; + const service = new AidenRemotePairingService( + "instance-1", + { + issueDevice: async (input) => { + issuanceStarted?.(); + await release; + if (!input.authorizeCommit?.()) { + throw new AidenRemoteServiceError( + "pairing_closed", + "This pairing window was closed before the device was created.", + 403, + ); + } + committed += 1; + return { + credential: Buffer.alloc(32, 8).toString("base64url"), + device: { + id: "device-race", + name: input.name, + type: input.type, + clientVersion: input.clientVersion, + capabilities: [...(input.capabilities ?? [])], + createdAt: 1_000, + lastSeenAt: 0, + }, + }; + }, + }, + { + now: () => 1_000, + randomBytes: (size) => Buffer.alloc(size, ++randomCounter), + }, + ); + const opened = service.begin(endpoint, fingerprint); + const pending = service.exchange(exchange(opened.bootstrap.secret), "phone"); + await started; + assert.equal(service.status()?.state, "finishing"); + if (action === "close") { + assert.equal(service.close(opened.sessionId), true); + } else { + service.begin(endpoint, fingerprint); + } + releaseIssuance?.(); + await assert.rejects( + pending, + (error: unknown) => error instanceof AidenRemoteServiceError && error.code === "pairing_closed", + ); + assert.equal(committed, 0); + if (action === "close") assert.equal(service.status(), undefined); + else assert.notEqual(service.status()?.sessionId, opened.sessionId); + } +}); + +test("a credential issued before bootstrap expiry remains finishing after the scan deadline", async () => { + const pairing = fixture(); + const opened = pairing.service.begin(endpoint, fingerprint); + pairing.advance(5 * 60_000 - 1); + await pairing.service.exchange(exchange(opened.bootstrap.secret), "source"); + pairing.advance(2); + + assert.deepEqual(pairing.service.status(), { + sessionId: opened.sessionId, + state: "finishing", + deviceId: "device-1", + }); +}); + +test("pairing DTOs are exact and per-source attempts are rate limited", async () => { + assert.throws( + () => parseAidenRemotePairingExchangeInput({ ...exchange("x".repeat(43)), extra: true }), + (error: unknown) => + error instanceof AidenRemoteServiceError && error.code === "invalid_request", + ); + const pairing = fixture(); + pairing.service.begin(endpoint, fingerprint); + for (let index = 0; index < 10; index += 1) { + await assert.rejects( + pairing.service.exchange(exchange("x".repeat(43)), "attacker"), + ); + } + await assert.rejects( + pairing.service.exchange(exchange("x".repeat(43)), "attacker"), + (error: unknown) => + error instanceof AidenRemoteServiceError && error.code === "rate_limited", + ); +}); diff --git a/main/services/aiden-remote-pairing.ts b/main/services/aiden-remote-pairing.ts new file mode 100644 index 00000000..9a4e681c --- /dev/null +++ b/main/services/aiden-remote-pairing.ts @@ -0,0 +1,508 @@ +import { + createCipheriv, + createHash, + hkdfSync, + randomBytes, + timingSafeEqual, +} from "node:crypto"; +import { + AIDEN_REMOTE_CAPABILITIES, + AIDEN_REMOTE_PROTOCOL_VERSION, + assertAidenRemoteEndpoint, + type AidenRemoteCapability, +} from "./aiden-remote-protocol.js"; +import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; +import type { + AidenRemoteDeviceType, + AidenRemoteStateRegistry, +} from "./aiden-remote-state.js"; + +const PAIRING_WINDOW_MS = 5 * 60_000; +const PAIRING_ATTEMPTS_PER_SOURCE = 10; +const PAIRING_RATE_WINDOW_MS = 60_000; +const MAX_RATE_LIMIT_SOURCES = 1_024; +const MANUAL_PAIRING_CODE_CHARACTERS = 20; +const MANUAL_PAIRING_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +const MANUAL_PAIRING_KIND = "aiden-manual-pairing-v1" as const; +const MANUAL_PAIRING_SALT_BYTES = 16; +const MANUAL_PAIRING_NONCE_BYTES = 12; +const MANUAL_PAIRING_TAG_BYTES = 16; +const MAX_PAIRING_PAYLOAD_BYTES = 4_096; + +export interface AidenRemotePairingBootstrap { + protocolVersion: typeof AIDEN_REMOTE_PROTOCOL_VERSION; + instanceId: string; + endpoint: string; + serverSpkiSha256: string; + secret: string; + expiresAt: string; +} + +export interface AidenRemotePairingExchangeInput { + secret: string; + deviceName: string; + deviceType: AidenRemoteDeviceType; + clientVersion: string; + acceptsDisplayName?: boolean; +} + +export interface AidenRemotePairingExchangeResponse { + protocolVersion: typeof AIDEN_REMOTE_PROTOCOL_VERSION; + instanceId: string; + deviceId: string; + credential: string; + capabilities: AidenRemoteCapability[]; + endpoint: string; + serverSpkiSha256: string; + displayName?: string; +} + +interface PairingWindow { + sessionId: string; + secretDigest: Buffer; + endpoint: string; + serverSpkiSha256: string; + expiresAt: number; + consumed: boolean; + cancelled: boolean; + issuedDeviceId?: string; + issuanceFailed: boolean; + manualCodeKey: Buffer; + manualSalt: Buffer; + manualBootstrap?: AidenRemoteManualPairingBootstrap; +} + +export interface AidenRemotePairingWindowStatus { + sessionId: string; + state: "awaiting_scan" | "finishing" | "failed" | "expired"; + deviceId?: string; +} + +export interface AidenRemoteDesktopPairing { + sessionId: string; + bootstrap: AidenRemotePairingBootstrap; + manualCode: string; + qrPayload?: string; +} + +export interface AidenRemoteManualPairingBootstrap { + kind: typeof MANUAL_PAIRING_KIND; + protocolVersion: typeof AIDEN_REMOTE_PROTOCOL_VERSION; + sessionId: string; + expiresAt: string; + salt: string; + nonce: string; + ciphertext: string; + tag: string; +} + +export interface AidenRemotePairingDependencies { + now(): number; + randomBytes(size: number): Buffer; +} + +function characterLength(value: string): number { + let length = 0; + for (const _character of value) length += 1; + return length; +} + +function bounded(value: unknown, maximum: number): value is string { + return ( + typeof value === "string" && + value.length > 0 && + characterLength(value) <= maximum + ); +} + +function digestSecret(secret: string): Buffer { + return createHash("sha256").update(secret).digest(); +} + +function sameDigest(left: Buffer, right: Buffer): boolean { + return left.length === right.length && timingSafeEqual(left, right); +} + +function encodeCrockfordBase32(bytes: Buffer, characters: number): string { + let bits = 0; + let value = 0; + let result = ""; + for (const byte of bytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5 && result.length < characters) { + bits -= 5; + result += MANUAL_PAIRING_CODE_ALPHABET[(value >>> bits) & 31]; + value &= (1 << bits) - 1; + } + } + if (result.length < characters && bits > 0) { + result += MANUAL_PAIRING_CODE_ALPHABET[(value << (5 - bits)) & 31]; + } + if (result.length !== characters) throw new Error("Manual pairing code generation failed."); + return result; +} + +export function formatAidenManualPairingCode(code: string): string { + return code.match(/.{1,4}/gu)?.join("-") ?? code; +} + +export function normalizeAidenManualPairingCode(value: string): string { + const normalized = value.replace(/[ -]/gu, "").toUpperCase(); + if ( + normalized.length !== MANUAL_PAIRING_CODE_CHARACTERS + || [...normalized].some((character) => !MANUAL_PAIRING_CODE_ALPHABET.includes(character)) + ) { + throw new AidenRemoteServiceError( + "invalid_request", + "The manual pairing code is invalid.", + 400, + ); + } + return normalized; +} + +function manualPairingInfo(sessionId: string): Buffer { + return Buffer.from(`${MANUAL_PAIRING_KIND}\n${sessionId}`, "utf8"); +} + +function manualPairingAdditionalData(sessionId: string, expiresAt: string): Buffer { + return Buffer.from(`${MANUAL_PAIRING_KIND}\n${sessionId}\n${expiresAt}`, "utf8"); +} + +function deriveManualPairingKey(code: string, salt: Buffer, sessionId: string): Buffer { + return Buffer.from(hkdfSync( + "sha256", + Buffer.from(code, "ascii"), + salt, + manualPairingInfo(sessionId), + 32, + )); +} + +export function parseAidenRemotePairingExchangeInput( + value: unknown, +): AidenRemotePairingExchangeInput { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new AidenRemoteServiceError( + "invalid_request", + "Pairing details are invalid.", + 400, + ); + } + const record = value as Record; + const allowed = new Set([ + "secret", + "deviceName", + "deviceType", + "clientVersion", + "acceptsDisplayName", + ]); + if ( + Object.keys(record).length < 4 || + Object.keys(record).length > allowed.size || + Object.keys(record).some((key) => !allowed.has(key)) || + typeof record.secret !== "string" || + !/^[A-Za-z0-9_-]{43}$/u.test(record.secret) || + !bounded(record.deviceName, 80) || + (record.deviceType !== "iphone" && record.deviceType !== "ipad") || + !bounded(record.clientVersion, 40) || + (record.acceptsDisplayName !== undefined && typeof record.acceptsDisplayName !== "boolean") + ) { + throw new AidenRemoteServiceError( + "invalid_request", + "Pairing details are invalid.", + 400, + ); + } + return { + secret: record.secret, + deviceName: record.deviceName, + deviceType: record.deviceType, + clientVersion: record.clientVersion, + ...(record.acceptsDisplayName === true ? { acceptsDisplayName: true } : {}), + }; +} + +export class AidenRemotePairingService { + private window: PairingWindow | null = null; + private attempts = new Map(); + + constructor( + private readonly instanceId: string, + private readonly devices: Pick, + private readonly dependencies: AidenRemotePairingDependencies = { + now: Date.now, + randomBytes, + }, + private readonly onStatusChanged: () => void = () => undefined, + private readonly displayName: () => string = () => "Aiden Agent", + ) {} + + begin( + endpoint: string, + serverSpkiSha256: string, + ): AidenRemoteDesktopPairing { + assertAidenRemoteEndpoint(endpoint); + if (!/^sha256\/[A-Za-z0-9+/]{43}=$/u.test(serverSpkiSha256)) { + throw new Error("Aiden Remote server fingerprint is invalid."); + } + if (this.window) this.window.cancelled = true; + this.eraseManualKeyMaterial(this.window); + const secret = this.dependencies.randomBytes(32).toString("base64url"); + const manualCode = encodeCrockfordBase32( + this.dependencies.randomBytes(13), + MANUAL_PAIRING_CODE_CHARACTERS, + ); + const sessionId = `pairing_${this.dependencies.randomBytes(24).toString("base64url")}`; + const expiresAt = this.dependencies.now() + PAIRING_WINDOW_MS; + const manualSalt = this.dependencies.randomBytes(MANUAL_PAIRING_SALT_BYTES); + this.window = { + sessionId, + secretDigest: digestSecret(secret), + endpoint, + serverSpkiSha256, + expiresAt, + consumed: false, + cancelled: false, + issuanceFailed: false, + manualCodeKey: deriveManualPairingKey(manualCode, manualSalt, sessionId), + manualSalt, + }; + this.onStatusChanged(); + return { + sessionId, + manualCode: formatAidenManualPairingCode(manualCode), + bootstrap: { + protocolVersion: AIDEN_REMOTE_PROTOCOL_VERSION, + instanceId: this.instanceId, + endpoint, + serverSpkiSha256, + secret, + expiresAt: new Date(expiresAt).toISOString(), + }, + }; + } + + sealManualPayload(sessionId: string, payload: string): AidenRemoteManualPairingBootstrap { + const current = this.window; + if (!current || current.sessionId !== sessionId) { + throw new Error("The pairing window changed before its manual payload was prepared."); + } + if (current.manualBootstrap) { + throw new Error("The manual pairing payload is already prepared."); + } + const plaintext = Buffer.from(payload, "utf8"); + if (plaintext.length === 0 || plaintext.length > MAX_PAIRING_PAYLOAD_BYTES) { + throw new Error("Aiden Remote pairing payload is too large."); + } + const nonce = this.dependencies.randomBytes(MANUAL_PAIRING_NONCE_BYTES); + const expiresAt = new Date(current.expiresAt).toISOString(); + const cipher = createCipheriv("aes-256-gcm", current.manualCodeKey, nonce, { + authTagLength: MANUAL_PAIRING_TAG_BYTES, + }); + cipher.setAAD(manualPairingAdditionalData(sessionId, expiresAt)); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const bootstrap: AidenRemoteManualPairingBootstrap = { + kind: MANUAL_PAIRING_KIND, + protocolVersion: AIDEN_REMOTE_PROTOCOL_VERSION, + sessionId, + expiresAt, + salt: current.manualSalt.toString("base64url"), + nonce: nonce.toString("base64url"), + ciphertext: ciphertext.toString("base64url"), + tag: cipher.getAuthTag().toString("base64url"), + }; + current.manualBootstrap = bootstrap; + current.manualCodeKey.fill(0); + current.manualSalt.fill(0); + return bootstrap; + } + + manualBootstrap(): AidenRemoteManualPairingBootstrap { + const current = this.window; + if (!current) { + throw new AidenRemoteServiceError( + "pairing_closed", + "Open a new pairing window in Aiden Settings.", + 403, + ); + } + if (this.dependencies.now() >= current.expiresAt) { + this.onStatusChanged(); + throw new AidenRemoteServiceError( + "pairing_expired", + "This pairing code expired. Open a new pairing window.", + 403, + ); + } + if (current.consumed) { + throw new AidenRemoteServiceError( + "pairing_already_used", + "This pairing code was already used.", + 409, + ); + } + if (!current.manualBootstrap) { + throw new AidenRemoteServiceError( + "pairing_closed", + "The manual pairing payload is unavailable. Open a new pairing window.", + 503, + true, + ); + } + return { ...current.manualBootstrap }; + } + + close(sessionId?: string): boolean { + if (sessionId && this.window?.sessionId !== sessionId) return false; + if (!this.window) return false; + this.window.cancelled = true; + this.eraseManualKeyMaterial(this.window); + this.window = null; + this.onStatusChanged(); + return true; + } + + private eraseManualKeyMaterial(window: PairingWindow | null): void { + window?.manualCodeKey.fill(0); + window?.manualSalt.fill(0); + } + + status(): AidenRemotePairingWindowStatus | undefined { + const current = this.window; + if (!current) return undefined; + if (current.issuedDeviceId) { + return { + sessionId: current.sessionId, + state: "finishing", + deviceId: current.issuedDeviceId, + }; + } + if (current.issuanceFailed) { + return { sessionId: current.sessionId, state: "failed" }; + } + if (current.consumed) { + return { + sessionId: current.sessionId, + state: "finishing", + }; + } + if (this.dependencies.now() >= current.expiresAt) { + return { sessionId: current.sessionId, state: "expired" }; + } + return { + sessionId: current.sessionId, + state: "awaiting_scan", + }; + } + + private admitAttempt(source: string): void { + const now = this.dependencies.now(); + const cutoff = now - PAIRING_RATE_WINDOW_MS; + for (const [key, timestamps] of this.attempts) { + const retained = timestamps.filter((timestamp) => timestamp > cutoff); + if (retained.length === 0) this.attempts.delete(key); + else if (retained.length !== timestamps.length) this.attempts.set(key, retained); + } + if (!this.attempts.has(source) && this.attempts.size >= MAX_RATE_LIMIT_SOURCES) { + throw new AidenRemoteServiceError( + "rate_limited", + "Too many pairing attempts. Try again shortly.", + 429, + true, + { retryAfterSeconds: 60 }, + ); + } + const timestamps = this.attempts.get(source) ?? []; + if (timestamps.length >= PAIRING_ATTEMPTS_PER_SOURCE) { + throw new AidenRemoteServiceError( + "rate_limited", + "Too many pairing attempts. Try again shortly.", + 429, + true, + { retryAfterSeconds: 60 }, + ); + } + timestamps.push(now); + this.attempts.set(source, timestamps); + } + + async exchange( + value: unknown, + source: string, + ): Promise { + this.admitAttempt(source); + const input = parseAidenRemotePairingExchangeInput(value); + const current = this.window; + if (!current) { + throw new AidenRemoteServiceError( + "pairing_closed", + "Open a new pairing window in Aiden Settings.", + 403, + ); + } + if (this.dependencies.now() >= current.expiresAt) { + this.onStatusChanged(); + throw new AidenRemoteServiceError( + "pairing_expired", + "This pairing code expired. Open a new pairing window.", + 403, + ); + } + if (current.consumed) { + throw new AidenRemoteServiceError( + "pairing_already_used", + "This pairing code was already used.", + 409, + ); + } + if (!sameDigest(current.secretDigest, digestSecret(input.secret))) { + throw new AidenRemoteServiceError( + "authentication_required", + "The pairing code is not valid.", + 401, + ); + } + + // Consume synchronously before any durable work. Concurrent exchanges and + // persistence failures can never turn this high-authority secret reusable. + current.consumed = true; + this.onStatusChanged(); + let issued: Awaited>; + try { + issued = await this.devices.issueDevice({ + name: input.deviceName, + type: input.deviceType, + clientVersion: input.clientVersion, + capabilities: AIDEN_REMOTE_CAPABILITIES, + authorizeCommit: () => this.window === current && !current.cancelled, + }); + if (this.window !== current || current.cancelled) { + throw new AidenRemoteServiceError( + "pairing_closed", + "This pairing window was closed before the device was created.", + 403, + ); + } + current.issuedDeviceId = issued.device.id; + this.onStatusChanged(); + } catch (error) { + if (this.window === current && !current.cancelled) { + current.issuanceFailed = true; + this.onStatusChanged(); + } + throw error; + } + return { + protocolVersion: AIDEN_REMOTE_PROTOCOL_VERSION, + instanceId: this.instanceId, + deviceId: issued.device.id, + credential: issued.credential, + capabilities: [...issued.device.capabilities], + endpoint: current.endpoint, + serverSpkiSha256: current.serverSpkiSha256, + ...(input.acceptsDisplayName ? { displayName: this.displayName() } : {}), + }; + } +} diff --git a/main/services/aiden-remote-protocol.test.ts b/main/services/aiden-remote-protocol.test.ts new file mode 100644 index 00000000..d1231368 --- /dev/null +++ b/main/services/aiden-remote-protocol.test.ts @@ -0,0 +1,742 @@ +import assert from "node:assert/strict"; +import { createDecipheriv, hkdfSync } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { + AIDEN_REMOTE_BASE_PATH, + AIDEN_REMOTE_CAPABILITIES, + AIDEN_REMOTE_ERROR_CODES, + AIDEN_REMOTE_EVENT_TYPES, + AIDEN_REMOTE_MAX_SSE_FRAME_BYTES, + AIDEN_REMOTE_PROTOCOL_VERSION, + parseAidenRemoteStreamEvent, + parseAidenSseFrames, + reconcileAidenSseFrames, + parseAidenRemoteContractFixture, +} from "./aiden-remote-protocol.js"; + +const protocolRoot = path.resolve(process.cwd(), "protocol/aiden-remote/v1"); + +async function json(relativePath: string): Promise { + return JSON.parse(await readFile(path.join(protocolRoot, relativePath), "utf8")); +} + +function record(value: unknown, label: string): Record { + assert(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); + return value as Record; +} + +// Keep this authority vector byte-for-byte aligned with +// AidenRemotePhase0Tests.testEndpointAuthorityGrammarMatchesDesktopVectors. +const endpointAuthorityVectors: readonly [string, boolean][] = [ + ["aiden.example.test", true], + ["localhost", true], + ["aiden-lan.local", true], + ["192.168.1.42", true], + ["192.0.2.1:443", true], + ["aiden.0", false], + ["aiden.123", false], + ["aiden.example.test:1", true], + ["aiden.example.test:65535", true], + ["[::]", true], + ["[::1]", true], + ["[2001:db8::1]:443", true], + ["[::ffff:192.0.2.1]", true], + ["aiden.example.test:0443", false], + ["aiden.example.test:00001", false], + ["aiden.example.test:0", false], + ["aiden.example.test:65536", false], + ["aiden.example.test:abc", false], + ["aiden.example.test:", false], + [":443", false], + ["aiden.example.test:1:2", false], + ["aiden.example.test%2eexample.test", false], + ["aiden.example.test%25", false], + ["aiden.example.test", false], + ["aiden\u{0301}.example.test", false], + ["aiden.example.test\u{0009}", false], + ["aiden.example.test\u{001f}", false], + ["aiden.example.test\u{007f}", false], + ["aiden..example.test", false], + ["-aiden.example.test", false], + ["aiden-.example.test", false], + ["aiden_example.test", false], + ["123", false], + ["192.168.001.1", false], + ["256.1.1.1", false], + ["[fe80::1%25en0]", false], + ["[v1.fe]", false], + ["[::1", false], + ["[::1]x", false], + ["::1", false], + ["[::1]:00001", false], + ["[::1]:65536", false], + ["[2001:db8::1::2]", false], + ["[192.0.2.1::]", false], + ["[::ffff:192.000.2.1]", false], + ["[2001:db8:0:0:0:0:0]", false], +]; + +test("shared Aiden Remote v1 fixture is complete, ordered, and contains no unsafe wire keys", async () => { + const fixture = parseAidenRemoteContractFixture(await json("fixtures/contract.json")); + assert.equal(fixture.protocolVersion, AIDEN_REMOTE_PROTOCOL_VERSION); + assert.deepEqual(fixture.capabilities, AIDEN_REMOTE_CAPABILITIES); + assert.deepEqual( + new Set(fixture.events.map((event) => event.type)), + new Set(AIDEN_REMOTE_EVENT_TYPES), + ); + assert.equal(JSON.stringify(fixture).includes("/Users/"), false); + assert.equal(JSON.stringify(fixture).includes("BEGIN PRIVATE KEY"), false); +}); + +test("shared manual pairing vector decrypts with the frozen cross-platform construction", async () => { + const vector = record(await json("fixtures/manual-pairing-vector.json"), "manual vector"); + const bootstrap = record(vector.bootstrap, "manual bootstrap"); + const code = String(vector.code).replace(/-/gu, ""); + const payload = String(vector.payload); + assert.match(code, /^[0-9A-HJKMNP-TV-Z]{20}$/u); + assert.equal(bootstrap.kind, "aiden-manual-pairing-v1"); + assert.equal(bootstrap.protocolVersion, 1); + assert.match(String(bootstrap.sessionId), /^pairing_[A-Za-z0-9_-]{32}$/u); + + const key = Buffer.from(hkdfSync( + "sha256", + Buffer.from(code, "ascii"), + Buffer.from(String(bootstrap.salt), "base64url"), + Buffer.from(`aiden-manual-pairing-v1\n${String(bootstrap.sessionId)}`, "utf8"), + 32, + )); + const decipher = createDecipheriv( + "aes-256-gcm", + key, + Buffer.from(String(bootstrap.nonce), "base64url"), + { authTagLength: 16 }, + ); + decipher.setAAD(Buffer.from( + `aiden-manual-pairing-v1\n${String(bootstrap.sessionId)}\n${String(bootstrap.expiresAt)}`, + "utf8", + )); + decipher.setAuthTag(Buffer.from(String(bootstrap.tag), "base64url")); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(String(bootstrap.ciphertext), "base64url")), + decipher.final(), + ]).toString("utf8"); + assert.equal(plaintext, payload); + assert.equal(JSON.stringify(bootstrap).includes(code), false); + const decrypted = record(JSON.parse(payload), "pairing payload"); + const pairingBootstrap = record(decrypted.bootstrap, "pairing bootstrap"); + assert.match(String(pairingBootstrap.secret), /^[A-Za-z0-9_-]{43}$/u); + assert.equal(JSON.stringify(bootstrap).includes(String(pairingBootstrap.secret)), false); +}); + +test("OpenAPI freezes every planned route under authenticated Aiden v1 semantics", async () => { + const document = record(await json("openapi.json"), "OpenAPI"); + assert.equal(document.openapi, "3.1.0"); + const info = record(document.info, "OpenAPI info"); + assert.equal(info.version, "1.0.0"); + const paths = record(document.paths, "OpenAPI paths"); + const requiredPaths = [ + "/health", + "/pairing/manual-bootstrap", + "/pairing/exchange", + "/server", + "/workspaces", + "/workspaces/{workspaceId}", + "/workspace-browser/roots", + "/workspace-browser/children", + "/workspace-browser/selections", + "/chats", + "/chats/{chatId}", + "/chats/{chatId}/move", + "/chats/{chatId}/turns", + "/chats/{chatId}/attachments", + "/chats/{chatId}/attachments/{attachmentId}", + "/chats/{chatId}/attachments/{attachmentId}/content", + "/streams/{streamId}", + "/streams/{streamId}/events", + "/streams/{streamId}/approval", + "/streams/{streamId}/cancel", + "/approvals/{approvalId}/respond", + "/models", + "/usage", + "/workspaces/{workspaceId}/files", + "/workspaces/{workspaceId}/files/{fileId}", + "/workspaces/{workspaceId}/git/review", + "/workspaces/{workspaceId}/git/diff", + "/workspaces/{workspaceId}/git/branches", + "/workspaces/{workspaceId}/git/checkout", + "/workspaces/{workspaceId}/git/commit", + "/workspaces/{workspaceId}/git/push-capability", + "/workspaces/{workspaceId}/git/push", + "/workspaces/{workspaceId}/git/compare", + "/workspaces/{workspaceId}/git/comparison-diff", + "/workspaces/{workspaceId}/git/worktrees", + "/workspaces/{workspaceId}/git/managed-worktree", + "/scheduled-tasks", + "/scheduled-tasks/{taskId}", + "/scheduled-tasks/{taskId}/pause", + "/scheduled-tasks/{taskId}/resume", + "/scheduled-tasks/{taskId}/run", + "/scheduled-tasks/{taskId}/runs", + "/scheduled-tasks/preview", + "/scheduled-tasks/scripts", + "/scheduled-tasks/mcp-servers", + "/scheduled-tasks/settings", + ]; + assert.deepEqual(Object.keys(paths), requiredPaths); + assert.deepEqual(document.security, [{ deviceBearer: [], protocolVersion: [] }]); + const securitySchemes = record(record(document.components, "components").securitySchemes, "security schemes"); + assert.deepEqual(securitySchemes.protocolVersion, { + type: "apiKey", + in: "header", + name: "Aiden-Protocol-Version", + description: "Must be exactly 1.", + }); + const schemas = record(record(document.components, "components").schemas, "schemas"); + const providerSchema = record(schemas.Provider, "Provider schema"); + const providerModels = record(record(providerSchema.properties, "Provider properties").models, "Provider models"); + const modelProperties = record(record(record(providerModels.items, "Provider model").properties, "Provider model properties"), "Provider model properties"); + assert.deepEqual(Object.keys(modelProperties), [ + "id", + "label", + "thinkingLevels", + "defaultThinkingLevel", + "thinkingCanDisable", + "hidden", + ]); + const healthGet = record(record(paths["/health"], "health").get, "health get"); + assert.deepEqual(healthGet.security, []); + const pairingPost = record(record(paths["/pairing/exchange"], "pairing").post, "pairing post"); + assert.deepEqual(pairingPost.security, []); + const manualPairingPost = record( + record(paths["/pairing/manual-bootstrap"], "manual pairing").post, + "manual pairing post", + ); + assert.deepEqual(manualPairingPost.security, []); + + const methods = new Set(["get", "post", "put", "patch", "delete"]); + for (const [route, pathValue] of Object.entries(paths)) { + if (route !== "/health" && route !== "/pairing/exchange" && route !== "/pairing/manual-bootstrap") { + const inherited = (record(pathValue, route).parameters as Array> | undefined) ?? []; + assert(inherited.some((parameter) => parameter.$ref === "#/components/parameters/ProtocolVersion"), `${route} must require the exact protocol-version header`); + } + for (const [method, operationValue] of Object.entries(record(pathValue, route))) { + if (!methods.has(method)) continue; + const operationRecord = record(operationValue, `${method} ${route}`); + if (route === "/health" || route === "/pairing/exchange" || route === "/pairing/manual-bootstrap") continue; + assert( + AIDEN_REMOTE_CAPABILITIES.includes( + operationRecord["x-aiden-capability"] as (typeof AIDEN_REMOTE_CAPABILITIES)[number], + ), + `${method} ${route} must name a known capability`, + ); + } + } + + const resolveLocalReference = (reference: string): unknown => { + assert.match(reference, /^#\//, `Only local OpenAPI references are allowed: ${reference}`); + return reference + .slice(2) + .split("/") + .map((segment) => segment.split("~1").join("/").split("~0").join("~")) + .reduce((current, segment) => record(current, reference)[segment], document); + }; + const inspectReferences = (value: unknown): void => { + if (Array.isArray(value)) { + value.forEach(inspectReferences); + return; + } + if (!value || typeof value !== "object") return; + for (const [key, child] of Object.entries(value as Record)) { + if (key === "$ref") { + if (typeof child !== "string") throw new Error("OpenAPI $ref must be a string."); + assert.notEqual(resolveLocalReference(child), undefined, `Unresolved OpenAPI reference ${child}`); + } else { + inspectReferences(child); + } + } + }; + inspectReferences(document); +}); + +test("mutation contracts require idempotency or revision preconditions", async () => { + const document = record(await json("openapi.json"), "OpenAPI"); + const paths = record(document.paths, "paths"); + const operation = (route: string, method: string) => + record(record(paths[route], route)[method], `${method} ${route}`); + const parameterRefs = (route: string, method: string) => + ((operation(route, method).parameters as Array> | undefined) ?? []).map( + (parameter) => parameter.$ref, + ); + + for (const [route, method] of [ + ["/workspaces", "post"], + ["/chats", "post"], + ["/chats/{chatId}/move", "post"], + ["/chats/{chatId}/turns", "post"], + ["/streams/{streamId}/cancel", "post"], + ["/approvals/{approvalId}/respond", "post"], + ["/workspaces/{workspaceId}/git/branches", "post"], + ["/workspaces/{workspaceId}/git/checkout", "post"], + ["/workspaces/{workspaceId}/git/commit", "post"], + ["/workspaces/{workspaceId}/git/push", "post"], + ["/workspaces/{workspaceId}/git/worktrees", "post"], + ["/workspaces/{workspaceId}/git/managed-worktree", "delete"], + ["/scheduled-tasks", "post"], + ["/scheduled-tasks/{taskId}/pause", "post"], + ["/scheduled-tasks/{taskId}/resume", "post"], + ["/scheduled-tasks/{taskId}/run", "post"], + ] as const) { + assert(parameterRefs(route, method).includes("#/components/parameters/IdempotencyKey")); + } + + for (const [route, method] of [ + ["/workspaces/{workspaceId}", "patch"], + ["/workspaces/{workspaceId}", "delete"], + ["/chats/{chatId}", "patch"], + ["/chats/{chatId}", "delete"], + ["/scheduled-tasks/{taskId}", "patch"], + ["/scheduled-tasks/{taskId}", "delete"], + ["/scheduled-tasks/settings", "patch"], + ["/scheduled-tasks/{taskId}/pause", "post"], + ["/scheduled-tasks/{taskId}/resume", "post"], + ] as const) { + assert(parameterRefs(route, method).includes("#/components/parameters/IfMatch")); + } +}); + +test("wire schemas are allowlists and pairing requires pinned HTTPS identity", async () => { + const document = record(await json("openapi.json"), "OpenAPI"); + const components = record(document.components, "components"); + const schemas = record(components.schemas, "schemas"); + for (const name of [ + "Server", + "Workspace", + "Chat", + "MessageAttachment", + "FileIndex", + "FileDocument", + "GitResult", + "ScheduledTask", + "ErrorEnvelope", + ]) { + assert.equal(record(schemas[name], name).additionalProperties, false, `${name} must be an allowlist`); + } + for (const name of [ + "PairingBootstrap", + "GitDiffRequest", + "GitCreateBranchRequest", + "GitCheckoutRequest", + "GitCommitRequest", + "GitPushRequest", + "GitCompareRequest", + "GitComparisonDiffRequest", + "GitCreateWorktreeRequest", + ]) { + assert.equal(record(schemas[name], name).additionalProperties, false, `${name} must be an allowlist`); + } + const attachmentUploadVariants = record(schemas.AttachmentUpload, "AttachmentUpload").oneOf; + assert(Array.isArray(attachmentUploadVariants)); + assert.equal(attachmentUploadVariants.length, 2); + for (const [index, variant] of attachmentUploadVariants.entries()) { + assert.equal(record(variant, `AttachmentUpload.oneOf[${index}]`).additionalProperties, false); + } + const gitProjectionVariants = record(schemas.GitProjection, "GitProjection").oneOf; + assert(Array.isArray(gitProjectionVariants)); + assert(gitProjectionVariants.length > 0); + for (const [index, variant] of gitProjectionVariants.entries()) { + assert.equal(record(variant, `GitProjection.oneOf[${index}]`).additionalProperties, false); + } + const fixture = parseAidenRemoteContractFixture(await json("fixtures/contract.json")); + const bootstrap = fixture.pairingBootstrap; + const pairing = record(fixture.pairingExchange, "pairing exchange fixture"); + assert.match(bootstrap.endpoint, /^https:\/\//); + assert.match(bootstrap.serverSpkiSha256, /^sha256\/[A-Za-z0-9+/]{43}=$/); + assert.equal(bootstrap.secret.length >= 32, true); + assert.equal(pairing.endpoint, bootstrap.endpoint); + assert.equal(pairing.serverSpkiSha256, bootstrap.serverSpkiSha256); + assert.equal(record(schemas.PairingBootstrap, "PairingBootstrap").additionalProperties, false); + const pairingBootstrapEndpointPattern = record( + record(record(schemas.PairingBootstrap, "PairingBootstrap").properties, "PairingBootstrap properties").endpoint, + "PairingBootstrap endpoint", + ).pattern; + const pairingExchangeEndpointPattern = record( + record(record(schemas.PairingExchangeResponse, "PairingExchangeResponse").properties, "PairingExchangeResponse properties").endpoint, + "PairingExchangeResponse endpoint", + ).pattern; + for (const pattern of [pairingBootstrapEndpointPattern, pairingExchangeEndpointPattern]) { + assert.equal(typeof pattern, "string"); + const endpoint = new RegExp(pattern as string); + for (const [authority, valid] of endpointAuthorityVectors) { + assert.equal( + endpoint.test(`https://${authority}${AIDEN_REMOTE_BASE_PATH}`), + valid, + `OpenAPI endpoint pattern disagrees for ${authority}`, + ); + } + assert.equal(endpoint.test("https://user:secret@aiden.example.test/api/aiden/v1"), false); + } + assert.deepEqual(record(schemas.WorkspacePatch, "WorkspacePatch").required, ["confirmedForeground"]); + assert.deepEqual(record(schemas.ScheduleSettingsMutation, "ScheduleSettingsMutation").required, ["confirmedForeground"]); + const messageRoles = record(record(record(schemas.Message, "Message").properties, "Message properties").role, "Message role").enum; + assert(Array.isArray(messageRoles)); + assert.equal(messageRoles.includes("system"), false); + assert.equal(record(schemas.ErrorDetails, "ErrorDetails").additionalProperties, false); + const streamEvent = record(schemas.StreamEvent, "StreamEvent"); + assert.equal(streamEvent.additionalProperties, true, "SSE envelopes must remain additively extensible"); + const streamEventVariants = streamEvent.allOf; + assert(Array.isArray(streamEventVariants)); + const conditionalForType = (type: string) => streamEventVariants.find((variant) => { + const condition = record(record(record(variant, "StreamEvent conditional").if, "if").properties, "if properties"); + const typeCondition = record(condition.type, "type condition"); + return typeCondition.const === type; + }); + for (const [type, terminal] of [ + ["heartbeat", false], + ["done", true], + ["error", true], + ["cancelled", true], + ] as const) { + const conditional = record(conditionalForType(type), `${type} conditional`); + const thenProperties = record(record(conditional.then, `${type} then`).properties, `${type} then properties`); + assert.equal(record(thenProperties.terminal, `${type} terminal`).const, terminal); + } + const unknownConditional = streamEventVariants.find((variant) => { + const condition = record(record(record(variant, "unknown conditional").if, "if").properties, "if properties"); + const typeCondition = record(condition.type, "unknown type condition"); + return "not" in typeCondition; + }); + const unknownThen = record(record(record(unknownConditional, "unknown conditional").then, "unknown then").properties, "unknown then properties"); + assert.equal(record(unknownThen.terminal, "unknown terminal").const, false); + const streamStates = record(record(record(schemas.StreamStatus, "StreamStatus").properties, "StreamStatus properties").state, "StreamStatus state").enum; + assert(Array.isArray(streamStates)); + assert(streamStates.includes("reconciling")); + const statusConditional = record(conditionalForType("status"), "status conditional"); + const statusThenProperties = record(record(statusConditional.then, "status then").properties, "status then properties"); + const statusPayloadStates = record(record(record(statusThenProperties.payload, "status payload").properties, "status payload properties").state, "status payload state").enum; + assert.deepEqual(statusPayloadStates, ["queued", "running", "waiting_for_approval", "reconciling"]); + assert.deepEqual(record(schemas.ErrorCode, "ErrorCode").enum, AIDEN_REMOTE_ERROR_CODES); + const remotePattern = record(record(record(schemas.GitPushRequest, "GitPushRequest").properties, "GitPushRequest properties").remote, "Git remote").pattern; + assert.equal(typeof remotePattern, "string"); + const safeRemote = new RegExp(remotePattern as string); + assert.equal(safeRemote.test("origin"), true); + assert.equal(safeRemote.test("https://user:secret@example.test/repo.git"), false); +}); + +test("pairing, typed SSE payloads, and error details fail closed", async () => { + const source = record(await json("fixtures/contract.json"), "fixture"); + const clone = () => structuredClone(source); + const bootstrapWithExtraField = clone(); + record(bootstrapWithExtraField.pairingBootstrap, "bootstrap").unexpected = true; + assert.throws(() => parseAidenRemoteContractFixture(bootstrapWithExtraField), /unsupported field/); + const exchangeWithExtraField = clone(); + record(exchangeWithExtraField.pairingExchange, "exchange").unexpected = true; + assert.throws(() => parseAidenRemoteContractFixture(exchangeWithExtraField), /unsupported field/); + const errorEnvelopeWithExtraField = clone(); + record(errorEnvelopeWithExtraField.error, "error envelope").unexpected = true; + assert.throws(() => parseAidenRemoteContractFixture(errorEnvelopeWithExtraField), /unsupported field/); + const errorBodyWithExtraField = clone(); + record(record(errorBodyWithExtraField.error, "error envelope").error, "error").unexpected = true; + assert.throws(() => parseAidenRemoteContractFixture(errorBodyWithExtraField), /unsupported field/); + const errorDetailsWithExtraField = clone(); + record(record(errorDetailsWithExtraField.error, "error envelope").error, "error").details = { unexpected: true }; + assert.throws(() => parseAidenRemoteContractFixture(errorDetailsWithExtraField), /unsupported field/); + + const oversizedBootstrapInstance = clone(); + record(oversizedBootstrapInstance.pairingBootstrap, "bootstrap").instanceId = "i".repeat(129); + assert.throws(() => parseAidenRemoteContractFixture(oversizedBootstrapInstance), /instanceId.*128/); + const oversizedExchangeInstance = clone(); + record(oversizedExchangeInstance.pairingExchange, "exchange").instanceId = "i".repeat(129); + assert.throws(() => parseAidenRemoteContractFixture(oversizedExchangeInstance), /instanceId.*128/); + const oversizedDeviceId = clone(); + record(oversizedDeviceId.pairingExchange, "exchange").deviceId = "d".repeat(129); + assert.throws(() => parseAidenRemoteContractFixture(oversizedDeviceId), /deviceId.*128/); + const oversizedUtf8Endpoint = clone(); + record(oversizedUtf8Endpoint.pairingBootstrap, "bootstrap").endpoint = + `https://${"é".repeat(1_020)}.test/api/aiden/v1`; + assert.throws(() => parseAidenRemoteContractFixture(oversizedUtf8Endpoint), /UTF-8 bytes/); + + const weakSecret = clone(); + record(weakSecret.pairingBootstrap, "bootstrap").secret = "predictable-secret-that-is-long-enough"; + assert.throws(() => parseAidenRemoteContractFixture(weakSecret), /32 random bytes/); + const wrongExchange = clone(); + record(wrongExchange.pairingExchange, "exchange").endpoint = "https://other.example.test/api/aiden/v1"; + assert.throws(() => parseAidenRemoteContractFixture(wrongExchange), /does not match bootstrap/); + const userInfoEndpoint = clone(); + record(userInfoEndpoint.pairingBootstrap, "bootstrap").endpoint = "https://user:secret@aiden-fixture.example.test/api/aiden/v1"; + assert.throws(() => parseAidenRemoteContractFixture(userInfoEndpoint), /canonical HTTPS Aiden v1 URL/); + for (const endpoint of [ + "https://:443/api/aiden/v1", + "https://aiden-fixture.example.test:0/api/aiden/v1", + "https://aiden-fixture.example.test:65536/api/aiden/v1", + "https://aiden-fixture.example.test:abc/api/aiden/v1", + "https://aiden-fixture.example.test/api/aiden/v1?", + "https://aiden-fixture.example.test/api/aiden/v1#", + "https://aiden-fixture.example.test/api/./aiden/v1", + "https://aiden-fixture.example.test/api/aiden/../aiden/v1", + "https://aiden-fixture.example.test/api/aiden/v1/../v1", + "https://aiden-fixture.example.test/api/aiden/v1/%2e%2e/v1", + "https://aiden-fixture.example.test/api/aiden/v1/%2E", + "https://aiden-fixture.example.test/%61pi/aiden/v1", + "https://aiden-fixture.example.test/api/aiden/%76%31", + ]) { + const nonCanonicalPath = clone(); + record(nonCanonicalPath.pairingBootstrap, "bootstrap").endpoint = endpoint; + assert.throws(() => parseAidenRemoteContractFixture(nonCanonicalPath), /canonical HTTPS Aiden v1 URL/); + } + const longLived = clone(); + record(longLived.pairingBootstrap, "bootstrap").expiresAt = "2026-08-18T19:06:00.000Z"; + assert.throws(() => parseAidenRemoteContractFixture(longLived), /five minutes/); + const permissiveExpiry = clone(); + record(permissiveExpiry.pairingBootstrap, "bootstrap").expiresAt = "August 18, 2026 19:05:00 GMT"; + assert.throws(() => parseAidenRemoteContractFixture(permissiveExpiry), /strict RFC 3339/); + const malformedServerTime = clone(); + record(malformedServerTime.server, "server").serverTime = "not-a-date"; + assert.throws(() => parseAidenRemoteContractFixture(malformedServerTime), /serverTime.*RFC 3339/); + const missingServerTime = clone(); + delete record(missingServerTime.server, "server").serverTime; + assert.throws(() => parseAidenRemoteContractFixture(missingServerTime), /serverTime.*RFC 3339/); + const unknownCapability = clone(); + record(unknownCapability.pairingExchange, "exchange").capabilities = ["admin:everything"]; + assert.throws(() => parseAidenRemoteContractFixture(unknownCapability), /Unknown pairing capability/); + const unsafeDetails = clone(); + record(record(unsafeDetails.error, "error envelope").error, "error").details = { absolutePath: "/private/secret" }; + assert.throws(() => parseAidenRemoteContractFixture(unsafeDetails), /unsupported field/); + for (const requiredField of ["message", "requestId", "retryable"]) { + const malformed = clone(); + delete record(record(malformed.error, "error envelope").error, "error")[requiredField]; + assert.throws(() => parseAidenRemoteContractFixture(malformed)); + } + for (const [field, value] of [ + ["retryAfterSeconds", 86_401], + ["limit", 1_000_001], + ["minimumClientVersion", "v".repeat(41)], + ["field", "f".repeat(121)], + ] as const) { + const malformed = clone(); + record(record(malformed.error, "error envelope").error, "error").details = { [field]: value }; + assert.throws(() => parseAidenRemoteContractFixture(malformed), new RegExp(`Error detail ${field} is invalid`)); + } + + const event = record((source.events as unknown[])[0], "event"); + assert.throws(() => parseAidenRemoteStreamEvent({ ...event, payload: { ...record(event.payload, "payload"), hiddenPrompt: "secret" } }), /unsupported field/); + assert.throws(() => parseAidenRemoteStreamEvent({ ...event, payload: { chatId: "chat_fixture_01" } }), /missing required field/); + assert.throws(() => parseAidenRemoteStreamEvent({ ...event, payload: { chatId: "chat_fixture_01", turnId: "turn_fixture_01", nextSequence: "2" } }), /must be positive/); + assert.throws(() => parseAidenRemoteStreamEvent({ ...event, protocolVersion: 2 }), /unsupported protocolVersion/); + assert.throws(() => parseAidenRemoteStreamEvent({ ...event, streamId: "" }), /non-empty string/); + assert.throws(() => parseAidenRemoteStreamEvent({ ...event, sequence: 0 }), /positive/); + assert.throws( + () => parseAidenRemoteStreamEvent({ ...event, timestamp: "August 18, 2026 19:01:01 GMT" }), + /strict RFC 3339/, + ); + assert.doesNotThrow(() => parseAidenRemoteStreamEvent({ ...event, futureEnvelopeMetadata: { ignored: true } })); + assert.doesNotThrow(() => parseAidenRemoteStreamEvent({ ...event, type: "status", payload: { state: "reconciling" } })); + assert.throws(() => parseAidenRemoteStreamEvent({ ...event, type: "status", payload: { state: "done" } }), /status state is invalid/); + assert.doesNotThrow(() => parseAidenRemoteStreamEvent({ ...event, type: "error", terminal: true, payload: { code: "idempotency_capacity", message: "Retry later." } })); + assert.doesNotThrow(() => parseAidenRemoteStreamEvent({ ...event, type: "error", terminal: true, payload: { code: "idempotency_in_flight", message: "Still reconciling." } })); + assert.doesNotThrow(() => parseAidenRemoteStreamEvent({ ...event, type: "error", terminal: true, payload: { code: "handle_capacity", message: "Browse again later." } })); + assert.equal(parseAidenRemoteStreamEvent({ ...event, type: "future_progress", terminal: false, payload: {}, futureEnvelopeMetadata: true }), null); + assert.throws(() => parseAidenRemoteStreamEvent({ ...event, type: "future_progress", terminal: false, payload: undefined }), /payload must be an object/); + assert.throws( + () => parseAidenRemoteStreamEvent({ ...event, type: "future_progress", terminal: false, payload: { absolutePath: "/private/secret" } }), + /Forbidden Aiden Remote wire key/, + ); + assert.throws(() => parseAidenRemoteStreamEvent({ ...event, type: "future_terminal", terminal: true, payload: {} }), /Unknown terminal/); +}); + +test("endpoint authority grammar stays exact across LAN and tailnet host forms", async () => { + const source = record(await json("fixtures/contract.json"), "fixture"); + for (const [authority, valid] of endpointAuthorityVectors) { + const candidate = structuredClone(source); + const endpoint = `https://${authority}${AIDEN_REMOTE_BASE_PATH}`; + record(candidate.pairingBootstrap, "bootstrap").endpoint = endpoint; + if (valid) { + record(candidate.pairingExchange, "exchange").endpoint = endpoint; + assert.doesNotThrow(() => parseAidenRemoteContractFixture(candidate), authority); + } else { + assert.throws( + () => parseAidenRemoteContractFixture(candidate), + /canonical HTTPS Aiden v1 URL/, + authority, + ); + } + } +}); + +test("OpenAPI and runtime stream sequence bounds stay at the JSON safe-integer maximum", async () => { + const document = record(await json("openapi.json"), "OpenAPI"); + const paths = record(document.paths, "paths"); + const streamEvents = record(paths["/streams/{streamId}/events"], "stream events"); + const get = record(streamEvents.get, "stream events get"); + const parameters = get.parameters as Array>; + const parameterSchema = (name: string): Record => { + const parameter = parameters.find((candidate) => candidate.name === name); + assert(parameter, `missing stream sequence parameter ${name}`); + return record(parameter.schema, `${name} schema`); + }; + for (const name of ["Last-Event-ID", "after"]) { + assert.equal(parameterSchema(name).maximum, Number.MAX_SAFE_INTEGER, `${name} maximum`); + } + + const schemas = record(record(document.components, "components").schemas, "schemas"); + const streamStatusProperties = record(record(schemas.StreamStatus, "StreamStatus").properties, "StreamStatus properties"); + assert.equal(record(streamStatusProperties.lastSequence, "lastSequence").maximum, Number.MAX_SAFE_INTEGER); + assert.equal(Object.prototype.hasOwnProperty.call(streamStatusProperties, "approval"), false); + const pendingApproval = record(schemas.PendingApproval, "PendingApproval"); + assert.deepEqual(pendingApproval.required, [ + "approvalId", "streamId", "chatId", "summary", "toolCallId", "toolName", "expiresAt", "canAllow", + ]); + const approvalSnapshot = record(schemas.StreamApprovalSnapshot, "StreamApprovalSnapshot"); + assert.deepEqual(approvalSnapshot.required, ["approval"]); + const streamEvent = record(schemas.StreamEvent, "StreamEvent"); + const streamEventVariants = streamEvent.allOf as Array>; + const streamEventBaseProperties = record( + streamEvent.properties, + "StreamEvent base properties", + ); + assert.equal(record(streamEventBaseProperties.sequence, "sequence").maximum, Number.MAX_SAFE_INTEGER); + const snapshotConditional = streamEventVariants.find((variant) => { + const condition = record(record(variant.if, "snapshot if").properties, "snapshot if properties"); + return record(condition.type, "snapshot type").const === "snapshot"; + }); + assert(snapshotConditional, "missing snapshot conditional"); + const snapshotPayload = record( + record(record(snapshotConditional.then, "snapshot then").properties, "snapshot then properties").payload, + "snapshot payload", + ); + const snapshotProperties = record( + snapshotPayload.properties, + "snapshot payload properties", + ); + assert.equal(record(snapshotProperties.nextSequence, "nextSequence").maximum, Number.MAX_SAFE_INTEGER); + + const fixture = parseAidenRemoteContractFixture(await json("fixtures/contract.json")); + const event = record(fixture.events[0], "event"); + assert.doesNotThrow(() => parseAidenRemoteStreamEvent({ + ...event, + sequence: Number.MAX_SAFE_INTEGER, + })); + assert.throws( + () => parseAidenRemoteStreamEvent({ + ...event, + sequence: 9007199254740992, + }), + /safe integer/, + ); + assert.doesNotThrow(() => parseAidenRemoteStreamEvent({ + ...event, + payload: { + chatId: "chat_fixture_01", + turnId: "turn_fixture_01", + nextSequence: Number.MAX_SAFE_INTEGER, + }, + })); + assert.throws( + () => parseAidenRemoteStreamEvent({ + ...event, + payload: { + chatId: "chat_fixture_01", + turnId: "turn_fixture_01", + nextSequence: 9007199254740992, + }, + }), + /positive/, + ); +}); + +test("JSON entry points reject non-finite values, invalid UTF-16, and unsupported object graphs", async () => { + const fixture = parseAidenRemoteContractFixture(await json("fixtures/contract.json")); + const event = record(fixture.events[0], "event"); + + for (const value of [Infinity, Number.NaN]) { + assert.throws( + () => parseAidenRemoteStreamEvent({ ...event, futureEnvelopeMetadata: { value } }), + /numbers must be finite/, + ); + } + for (const token of ["Infinity", "NaN", "1e400"]) { + assert.throws( + () => parseAidenSseFrames(`id: 1\ndata: ${token}\n\n`), + /Malformed Aiden SSE JSON data/, + ); + } + + let deepMetadata: Record = {}; + for (let index = 0; index < 129; index += 1) { + deepMetadata = { nested: deepMetadata }; + } + assert.throws( + () => parseAidenRemoteStreamEvent({ ...event, futureEnvelopeMetadata: deepMetadata }), + /maximum nesting depth/, + ); + + const tooManyKeys = Object.fromEntries( + Array.from({ length: 16_385 }, (_, index) => [`key${index}`, index]), + ); + assert.throws( + () => parseAidenRemoteStreamEvent({ ...event, futureEnvelopeMetadata: tooManyKeys }), + /maximum object-key count/, + ); + + const cyclicMetadata: Record = {}; + cyclicMetadata.self = cyclicMetadata; + assert.throws( + () => parseAidenRemoteStreamEvent({ ...event, futureEnvelopeMetadata: cyclicMetadata }), + /cycles are not supported/, + ); + + for (const escape of ["\\ud800", "\\udc00"]) { + assert.throws( + () => parseAidenSseFrames(`id: 1\ndata: {"value":"${escape}"}\n\n`), + /Malformed Aiden SSE JSON data/, + ); + } + const validPair = parseAidenSseFrames('id: 1\ndata: {"value":"\\ud83d\\ude00"}\n\n'); + assert.deepEqual(validPair[0]?.data, { value: "😀" }); + assert.throws( + () => parseAidenSseFrames('id: 1\ndata: {"😀":1,"\\ud83d\\ude00":2}\n\n'), + /Malformed Aiden SSE JSON data/, + ); +}); + +test("SSE framing resumes by id, ignores duplicates and unknown nonterminal events, and reconciles gaps", async () => { + const fixture = parseAidenRemoteContractFixture(await json("fixtures/contract.json")); + const [first, second] = fixture.events; + const wire = `id: ${first.sequence}\ndata: ${JSON.stringify(first)}\n\nid: ${second.sequence}\ndata: ${JSON.stringify(second)}\n\n`; + const frames = parseAidenSseFrames(wire); + assert.throws( + () => parseAidenSseFrames('id: 1\ndata: {"a":1,"a":2}\n\n'), + /Malformed Aiden SSE JSON data/, + ); + assert.throws( + () => parseAidenSseFrames('id: 1\ndata: {"outer":{"a":1,"\\u0061":2}}\n\n'), + /Malformed Aiden SSE JSON data/, + ); + assert.throws( + () => parseAidenSseFrames(`id: 1\ndata: ${"x".repeat(AIDEN_REMOTE_MAX_SSE_FRAME_BYTES)}\n\n`), + /exceeds the byte limit/, + ); + assert.deepEqual(reconcileAidenSseFrames(frames, 0, first.streamId), { events: [first, second], reconcileRequired: false }); + assert.deepEqual(reconcileAidenSseFrames(frames, 1, first.streamId), { events: [second], reconcileRequired: false }); + assert.equal(reconcileAidenSseFrames([{ ...frames[1], id: "3" }], 1, first.streamId).reconcileRequired, true); + assert.equal(reconcileAidenSseFrames([{ ...frames[1], data: { ...record(frames[1].data, "data"), sequence: 3 } }], 1, first.streamId).reconcileRequired, true); + const future = { ...record(frames[0].data, "data"), type: "future_progress", terminal: false, payload: {} }; + assert.deepEqual(reconcileAidenSseFrames([{ id: "1", data: future }], 0, first.streamId), { events: [], reconcileRequired: false }); + const futureBetween = { ...future, sequence: 2 }; + const third = { ...second, sequence: 3 }; + assert.deepEqual(reconcileAidenSseFrames([{ id: "1", data: first }, { id: "2", data: futureBetween }, { id: "3", data: third }], 0, first.streamId), { events: [first, third], reconcileRequired: false }); + const terminal = { ...first, sequence: 1, type: "done", terminal: true, payload: { messageId: "message-terminal" } }; + const afterTerminal = { ...second, sequence: 2, type: "heartbeat", terminal: false, payload: {} }; + const terminalResult = reconcileAidenSseFrames([{ id: "1", data: terminal }, { id: "2", data: afterTerminal }], 0, first.streamId); + assert.equal(terminalResult.reconcileRequired, true); + assert.deepEqual(terminalResult.events.map((event) => event.type), ["done"]); + assert.equal( + reconcileAidenSseFrames([{ id: "1", data: { ...first, streamId: "stream_other" } }], 0, first.streamId).reconcileRequired, + true, + ); + assert.equal( + reconcileAidenSseFrames([{ id: "1", data: { ...future, streamId: "stream_other" } }], 0, first.streamId).reconcileRequired, + true, + ); +}); diff --git a/main/services/aiden-remote-protocol.ts b/main/services/aiden-remote-protocol.ts new file mode 100644 index 00000000..6cd980fc --- /dev/null +++ b/main/services/aiden-remote-protocol.ts @@ -0,0 +1,977 @@ +import { parseGenerationTimeline } from "../../renderer/shared/generation-timeline.js"; + +export const AIDEN_REMOTE_PROTOCOL_VERSION = 1 as const; +export const AIDEN_REMOTE_BASE_PATH = "/api/aiden/v1" as const; +export const AIDEN_REMOTE_MAX_SSE_FRAME_BYTES = 1_048_576; + +const AIDEN_REMOTE_MAX_IDENTIFIER_LENGTH = 128; +const AIDEN_REMOTE_MAX_ENDPOINT_UTF8_BYTES = 2_048; +const AIDEN_REMOTE_MAX_ENDPOINT_PORT = 65_535; +const AIDEN_REMOTE_MAX_JSON_NESTING_DEPTH = 128; +const AIDEN_REMOTE_MAX_JSON_OBJECT_KEYS = 16_384; + +export const AIDEN_REMOTE_CAPABILITIES = [ + "server:read", + "chat:read", + "chat:write", + "approval:respond", + "workspace:read", + "workspace:browse", + "workspace:manage", + "files:read", + "files:write", + "git:read", + "git:write", + "schedule:read", + "schedule:write", +] as const; + +export type AidenRemoteCapability = (typeof AIDEN_REMOTE_CAPABILITIES)[number]; + +export const AIDEN_REMOTE_EVENT_TYPES = [ + "snapshot", + "status", + "text_delta", + "reasoning_delta", + "tool_started", + "tool_finished", + "timeline", + "approval_required", + "done", + "error", + "cancelled", + "heartbeat", +] as const; + +export type AidenRemoteEventType = (typeof AIDEN_REMOTE_EVENT_TYPES)[number]; +export type AidenRemoteTerminalEventType = "done" | "error" | "cancelled"; + +export const AIDEN_REMOTE_ERROR_CODES = [ + "invalid_request", + "payload_too_large", + "rate_limited", + "authentication_required", + "credential_revoked", + "capability_denied", + "pairing_closed", + "pairing_expired", + "pairing_already_used", + "server_identity_changed", + "not_found", + "already_exists", + "revision_conflict", + "idempotency_conflict", + "idempotency_capacity", + "idempotency_in_flight", + "workspace_unavailable", + "workspace_changing", + "permission_confirmation_required", + "handle_invalid", + "handle_expired", + "handle_wrong_device", + "root_policy_changed", + "filesystem_identity_changed", + "path_outside_root", + "handle_capacity", + "turn_already_active", + "stream_gone", + "approval_already_resolved", + "approval_expired", + "operation_in_progress", + "operation_stale", + "git_capability_denied", + "schedule_disabled", + "schedule_run_in_progress", + "server_interrupted", + "internal_error", +] as const; + +export type AidenRemoteErrorCode = (typeof AIDEN_REMOTE_ERROR_CODES)[number]; + +export interface AidenRemoteErrorEnvelope { + error: { + code: AidenRemoteErrorCode; + message: string; + requestId: string; + retryable: boolean; + details?: { + currentRevision?: string; + retryAfterSeconds?: number; + chatId?: string; + minimumClientVersion?: string; + limit?: number; + field?: string; + }; + }; +} + +export interface AidenRemoteStreamEvent { + protocolVersion: typeof AIDEN_REMOTE_PROTOCOL_VERSION; + streamId: string; + sequence: number; + timestamp: string; + type: AidenRemoteEventType; + terminal: boolean; + payload: Record; +} + +export interface AidenRemoteContractFixture { + contractRevision: number; + protocolVersion: typeof AIDEN_REMOTE_PROTOCOL_VERSION; + capabilities: AidenRemoteCapability[]; + health: { ok: true; protocolVersion: typeof AIDEN_REMOTE_PROTOCOL_VERSION }; + pairingBootstrap: { + protocolVersion: typeof AIDEN_REMOTE_PROTOCOL_VERSION; + instanceId: string; + endpoint: string; + serverSpkiSha256: string; + secret: string; + expiresAt: string; + }; + pairingExchange: { + protocolVersion: typeof AIDEN_REMOTE_PROTOCOL_VERSION; + instanceId: string; + deviceId: string; + credential: string; + capabilities: AidenRemoteCapability[]; + displayName: string; + endpoint: string; + serverSpkiSha256: string; + }; + events: AidenRemoteStreamEvent[]; + error: AidenRemoteErrorEnvelope; + [key: string]: unknown; +} + +export const AIDEN_REMOTE_FORBIDDEN_WIRE_KEYS = new Set([ + "authorization", + "credentialDigest", + "providerFingerprint", + "mcpServerBindings", + "folderPath", + "repositoryPath", + "worktreePath", + "worktreeGitDir", + "ownershipToken", + "worktreeDevice", + "worktreeInode", + "createdFromHead", + "canonicalPath", + "absolutePath", + "scriptPath", + "environment", + "stdout", + "stderr", +]); + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isValidJsonString(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const nextCodeUnit = value.charCodeAt(index + 1); + if ( + Number.isNaN(nextCodeUnit) || + nextCodeUnit < 0xdc00 || + nextCodeUnit > 0xdfff + ) return false; + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + return false; + } + } + return true; +} + +function assertAidenRemoteJsonValue(value: unknown, location: string): void { + let objectKeyCount = 0; + const activeObjects = new WeakSet(); + + function fail(message: string): never { + throw new Error(`Invalid Aiden Remote JSON value at ${location}: ${message}`); + } + + function visit(current: unknown, depth: number, currentLocation: string): void { + if (depth > AIDEN_REMOTE_MAX_JSON_NESTING_DEPTH) { + fail(`maximum nesting depth is ${AIDEN_REMOTE_MAX_JSON_NESTING_DEPTH}.`); + } + if (current === null) return; + if (typeof current === "string") { + if (!isValidJsonString(current)) fail("strings must not contain unpaired UTF-16 surrogates."); + return; + } + if (typeof current === "boolean") return; + if (typeof current === "number") { + if (!Number.isFinite(current)) fail("numbers must be finite."); + return; + } + if (typeof current !== "object") { + fail("value must be a JSON-compatible primitive, array, or plain object."); + } + + if (activeObjects.has(current)) fail("cycles are not supported."); + activeObjects.add(current); + + try { + if (Array.isArray(current)) { + const ownKeys = Reflect.ownKeys(current); + const arrayIndexes = new Set(); + for (const ownKey of ownKeys) { + if (ownKey === "length") { + const descriptor = Object.getOwnPropertyDescriptor(current, ownKey); + if ( + !descriptor || + descriptor.enumerable || + !Object.prototype.hasOwnProperty.call(descriptor, "value") || + descriptor.value !== current.length + ) { + fail("arrays must have the standard length property."); + } + continue; + } + if (typeof ownKey !== "string") fail("symbol array properties are not supported."); + const descriptor = Object.getOwnPropertyDescriptor(current, ownKey); + const index = Number(ownKey); + if ( + !descriptor || + !descriptor.enumerable || + !Object.prototype.hasOwnProperty.call(descriptor, "value") || + !Number.isSafeInteger(index) || + index < 0 || + index >= current.length || + String(index) !== ownKey || + arrayIndexes.has(index) + ) { + fail("arrays must contain only dense numeric JSON elements."); + } + arrayIndexes.add(index); + } + if (arrayIndexes.size !== current.length) { + fail("arrays must contain only dense numeric JSON elements."); + } + for (let index = 0; index < current.length; index += 1) { + visit(current[index], depth + 1, `${currentLocation}[${index}]`); + } + return; + } + + const prototype = Object.getPrototypeOf(current); + if (prototype !== Object.prototype && prototype !== null) { + fail("objects must use the plain JSON object prototype."); + } + const ownKeys = Reflect.ownKeys(current); + const objectEntries: Array<[string, unknown]> = []; + for (const ownKey of ownKeys) { + if (typeof ownKey !== "string") fail("symbol object properties are not supported."); + const descriptor = Object.getOwnPropertyDescriptor(current, ownKey); + if ( + !descriptor || + !descriptor.enumerable || + !Object.prototype.hasOwnProperty.call(descriptor, "value") + ) { + fail(`object property ${ownKey} must be an enumerable data property.`); + } + if (!isValidJsonString(ownKey)) fail("object keys must not contain unpaired UTF-16 surrogates."); + if (AIDEN_REMOTE_FORBIDDEN_WIRE_KEYS.has(ownKey)) { + throw new Error(`Forbidden Aiden Remote wire key ${ownKey} at ${currentLocation}.`); + } + objectKeyCount += 1; + if (objectKeyCount > AIDEN_REMOTE_MAX_JSON_OBJECT_KEYS) { + fail(`maximum object-key count is ${AIDEN_REMOTE_MAX_JSON_OBJECT_KEYS}.`); + } + objectEntries.push([ownKey, descriptor.value]); + } + for (const [key, child] of objectEntries) { + visit(child, depth + 1, `${currentLocation}.${key}`); + } + } finally { + activeObjects.delete(current); + } + } + + visit(value, 0, location); +} + +function requiredString(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Aiden Remote fixture field ${key} must be a non-empty string.`); + } + return value; +} + +function requiredInteger(record: Record, key: string): number { + const value = record[key]; + if (!Number.isSafeInteger(value)) { + throw new Error(`Aiden Remote fixture field ${key} must be a safe integer.`); + } + return value as number; +} + +function characterLength(value: string): number { + let length = 0; + for (const _character of value) length += 1; + return length; +} + +export function assertNoForbiddenWireKeys(value: unknown, location = "fixture"): void { + assertAidenRemoteJsonValue(value, location); +} + +const EVENT_PAYLOAD_KEYS: Record = { + snapshot: ["chatId", "turnId", "nextSequence"], + status: ["state"], + text_delta: ["text"], + reasoning_delta: ["text"], + tool_started: ["toolId", "name"], + tool_finished: ["toolId", "status"], + timeline: ["timeline"], + approval_required: ["approvalId", "summary", "expiresAt"], + done: ["messageId"], + error: ["code", "message"], + cancelled: ["source"], + heartbeat: [], +}; + +const TERMINAL_EVENT_TYPES = new Set(["done", "error", "cancelled"]); + +function assertExactKeys(record: Record, allowed: readonly string[], label: string): void { + for (const key of Object.keys(record)) { + if (!allowed.includes(key)) throw new Error(`${label} contains unsupported field ${key}.`); + } +} + +function assertBoundedString(record: Record, key: string, maxLength: number): string { + const value = requiredString(record, key); + if (characterLength(value) > maxLength) throw new Error(`Aiden Remote field ${key} exceeds ${maxLength} characters.`); + return value; +} + +function parseStrictRfc3339(value: string, label: string): number { + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})$/u.exec(value); + if (!match) throw new Error(`${label} must be a strict RFC 3339 date-time.`); + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const fraction = match[7] ?? ""; + const offset = match[8]!; + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]; + if ( + !Number.isInteger(daysInMonth) || + day < 1 || day > daysInMonth || + hour > 23 || minute > 59 || second > 59 + ) { + throw new Error(`${label} must be a valid strict RFC 3339 date-time.`); + } + + const offsetHours = offset === "Z" ? 0 : Number(offset.slice(1, 3)); + const offsetMinutes = offset === "Z" ? 0 : Number(offset.slice(4, 6)); + if (offsetHours > 23 || offsetMinutes > 59) { + throw new Error(`${label} must have a valid RFC 3339 UTC offset.`); + } + const milliseconds = Number(fraction.slice(1).padEnd(3, "0").slice(0, 3) || "0"); + const date = new Date(Date.UTC(2000, month - 1, day, hour, minute, second, milliseconds)); + date.setUTCFullYear(year); + const signedOffsetMinutes = offset === "Z" ? 0 : (offset[0] === "+" ? 1 : -1) * (offsetHours * 60 + offsetMinutes); + return date.getTime() - signedOffsetMinutes * 60_000; +} + +function validateEventPayload(type: AidenRemoteEventType, payload: Record): void { + const keys = EVENT_PAYLOAD_KEYS[type]; + assertExactKeys(payload, keys, `${type} payload`); + for (const key of keys) { + if (!(key in payload)) throw new Error(`${type} payload is missing required field ${key}.`); + } + if (type === "snapshot") { + assertBoundedString(payload, "chatId", 128); + assertBoundedString(payload, "turnId", 128); + if (!Number.isSafeInteger(payload.nextSequence) || (payload.nextSequence as number) < 1) throw new Error("snapshot nextSequence must be positive."); + } else if (type === "status") { + const state = requiredString(payload, "state"); + if (!["queued", "running", "waiting_for_approval", "reconciling"].includes(state)) throw new Error("status state is invalid."); + } else if (type === "text_delta" || type === "reasoning_delta") { + assertBoundedString(payload, "text", 200_000); + } else if (type === "tool_started") { + assertBoundedString(payload, "toolId", 128); + assertBoundedString(payload, "name", 120); + } else if (type === "tool_finished") { + assertBoundedString(payload, "toolId", 128); + if (!["succeeded", "failed", "cancelled"].includes(requiredString(payload, "status"))) throw new Error("tool status is invalid."); + } else if (type === "timeline") { + if (!parseGenerationTimeline(payload.timeline)) { + throw new Error("timeline payload must contain a renderer-safe generation timeline."); + } + } else if (type === "approval_required") { + assertBoundedString(payload, "approvalId", 128); + assertBoundedString(payload, "summary", 2_000); + parseStrictRfc3339(requiredString(payload, "expiresAt"), "Approval expiry"); + } else if (type === "done") { + assertBoundedString(payload, "messageId", 128); + } else if (type === "error") { + const code = assertBoundedString(payload, "code", 80); + if (!(AIDEN_REMOTE_ERROR_CODES as readonly string[]).includes(code)) throw new Error("stream error code is invalid."); + assertBoundedString(payload, "message", 2_000); + } else if (type === "cancelled") { + if (!["device", "server"].includes(requiredString(payload, "source"))) throw new Error("cancellation source is invalid."); + } +} + +function assertBase64Url32(value: string, label: string): void { + if (!/^[A-Za-z0-9_-]{43}$/.test(value) || Buffer.from(value, "base64url").length !== 32) { + throw new Error(`${label} must encode exactly 32 random bytes as unpadded base64url.`); + } +} + +function isCanonicalAidenIPv4(value: string): boolean { + const octets = value.split("."); + return octets.length === 4 && octets.every((octet) => { + if (!/^(?:0|[1-9][0-9]{0,2})$/u.test(octet)) return false; + const number = Number(octet); + return number >= 0 && number <= 255; + }); +} + +function parseAidenIPv6Side(value: string): number | null { + if (!value) return 0; + const groups = value.split(":"); + if (groups.some((group) => group.length === 0)) return null; + let count = 0; + for (const [index, group] of groups.entries()) { + if (group.includes(".")) { + if (index !== groups.length - 1 || !isCanonicalAidenIPv4(group)) return null; + count += 2; + } else { + if (!/^[0-9A-Fa-f]{1,4}$/u.test(group)) return null; + count += 1; + } + } + return count; +} + +function isCanonicalAidenIPv6(value: string): boolean { + if (!value || !/^[0-9A-Fa-f:.]+$/u.test(value)) return false; + const sides = value.split("::"); + if (sides.length > 2) return false; + if (sides.length === 2) { + if (sides[0]!.includes(".")) return false; + const left = parseAidenIPv6Side(sides[0]!); + const right = parseAidenIPv6Side(sides[1]!); + return left !== null && right !== null && left + right < 8; + } + return parseAidenIPv6Side(value) === 8; +} + +function isCanonicalAidenDnsHost(value: string): boolean { + if (!value || value.length > 253) return false; + const labels = value.split("."); + if (labels.some((label) => label.length === 0 || label.length > 63)) return false; + if (labels.every((label) => /^[0-9]+$/u.test(label))) { + // Numeric-only authorities are ambiguous under WHATWG URL parsing. Keep + // only canonical dotted-decimal IPv4 rather than letting `123` become + // `0.0.0.123` on one platform and a DNS name on another. + return isCanonicalAidenIPv4(value); + } + // A DNS authority must not end in a numeric-only label. This keeps + // `aiden.123` distinct from canonical IPv4 while retaining numeric labels + // in non-terminal positions. + if (/^[0-9]+$/u.test(labels[labels.length - 1]!)) return false; + return labels.every((label) => /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/u.test(label)); +} + +function isCanonicalAidenPort(value: string): boolean { + if (!/^[1-9][0-9]{0,4}$/u.test(value)) return false; + const port = Number(value); + return Number.isInteger(port) && port >= 1 && port <= AIDEN_REMOTE_MAX_ENDPOINT_PORT; +} + +function isCanonicalAidenAuthority(value: string): boolean { + // Keep the raw grammar ASCII-only. This rejects C0/DEL, all Unicode + // whitespace and normalization-sensitive host spellings before either URL + // implementation can decode or normalize them. + for (const character of value) { + const codePoint = character.codePointAt(0)!; + if (codePoint > 0x7f || codePoint <= 0x20 || codePoint === 0x7f) return false; + } + + let host: string; + let rawPort: string | undefined; + if (value.startsWith("[")) { + const closingBracket = value.indexOf("]"); + if (closingBracket <= 1 || value.indexOf("[", 1) >= 0 || value.indexOf("]", closingBracket + 1) >= 0) return false; + host = value.slice(1, closingBracket); + const suffix = value.slice(closingBracket + 1); + if (suffix) { + if (!suffix.startsWith(":")) return false; + rawPort = suffix.slice(1); + } + if (!isCanonicalAidenIPv6(host)) return false; + } else { + if (value.includes("[") || value.includes("]")) return false; + const firstColon = value.indexOf(":"); + if (firstColon >= 0) { + if (firstColon !== value.lastIndexOf(":")) return false; + host = value.slice(0, firstColon); + rawPort = value.slice(firstColon + 1); + } else { + host = value; + } + if (!isCanonicalAidenDnsHost(host)) return false; + } + + return rawPort === undefined || isCanonicalAidenPort(rawPort); +} + +function hasCanonicalRawEndpointSyntax(value: string): boolean { + // WHATWG URL parsing removes raw and percent-encoded dot segments and + // normalizes empty trailing query/fragment markers. Match the complete wire + // syntax first so only one exact HTTPS endpoint spelling reaches URL parsing. + const match = /^https:\/\/([^/?#]+)\/api\/aiden\/v1$/u.exec(value); + if (!match) return false; + + const authority = match[1]!; + return isCanonicalAidenAuthority(authority); +} + +export function assertAidenRemoteEndpoint(value: string): void { + if (Buffer.byteLength(value, "utf8") > AIDEN_REMOTE_MAX_ENDPOINT_UTF8_BYTES) { + throw new Error(`Pairing endpoint exceeds ${AIDEN_REMOTE_MAX_ENDPOINT_UTF8_BYTES} UTF-8 bytes.`); + } + if (!hasCanonicalRawEndpointSyntax(value)) { + throw new Error("Pairing endpoint must be the canonical HTTPS Aiden v1 URL."); + } + let endpoint: URL; + try { + endpoint = new URL(value); + } catch { + throw new Error("Pairing endpoint must be the canonical HTTPS Aiden v1 URL."); + } + if ( + endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.search || + endpoint.hash || endpoint.pathname !== AIDEN_REMOTE_BASE_PATH + ) throw new Error("Pairing endpoint must be the canonical HTTPS Aiden v1 URL."); +} + +export function parseAidenRemoteStreamEvent(value: unknown): AidenRemoteStreamEvent | null { + if (!isRecord(value)) throw new Error("Aiden Remote event must be an object."); + if ("payload" in value && !isRecord(value.payload)) { + throw new Error("Aiden Remote event payload must be an object."); + } + if (value.protocolVersion !== AIDEN_REMOTE_PROTOCOL_VERSION) { + throw new Error("Aiden Remote event has an unsupported protocolVersion."); + } + const streamId = assertBoundedString(value, "streamId", 128); + const sequence = requiredInteger(value, "sequence"); + if (sequence < 1) throw new Error("Aiden Remote event sequence must be positive."); + const timestamp = requiredString(value, "timestamp"); + parseStrictRfc3339(timestamp, "Aiden Remote event timestamp"); + const type = assertBoundedString(value, "type", 80); + if (typeof value.terminal !== "boolean") throw new Error("Aiden Remote event terminal must be boolean."); + if (!isRecord(value.payload)) throw new Error("Aiden Remote event payload must be an object."); + if (Object.keys(value.payload).length > 32) throw new Error("Aiden Remote event payload has too many properties."); + if (!(AIDEN_REMOTE_EVENT_TYPES as readonly string[]).includes(type)) { + if (value.terminal) throw new Error(`Unknown terminal Aiden Remote event type ${type}.`); + assertNoForbiddenWireKeys(value, "event"); + return null; + } + const knownType = type as AidenRemoteEventType; + if (value.terminal !== TERMINAL_EVENT_TYPES.has(knownType)) { + throw new Error(`Aiden Remote event ${type} has an invalid terminal classification.`); + } + validateEventPayload(knownType, value.payload); + assertNoForbiddenWireKeys(value, "event"); + return { + protocolVersion: AIDEN_REMOTE_PROTOCOL_VERSION, + streamId, + sequence, + timestamp, + type: knownType, + terminal: value.terminal, + payload: value.payload, + }; +} + +export function assertOrderedAidenRemoteEvents(events: readonly AidenRemoteStreamEvent[]): void { + const lastByStream = new Map(); + const terminalStreams = new Set(); + for (const event of events) { + if (terminalStreams.has(event.streamId)) { + throw new Error(`Aiden Remote stream ${event.streamId} contains an event after terminal state.`); + } + const previous = lastByStream.get(event.streamId) ?? 0; + if (event.sequence !== previous + 1) { + throw new Error( + `Aiden Remote stream ${event.streamId} expected sequence ${previous + 1}, received ${event.sequence}.`, + ); + } + lastByStream.set(event.streamId, event.sequence); + if (event.terminal) { + terminalStreams.add(event.streamId); + } + } +} + +export function parseAidenRemoteContractFixture(value: unknown): AidenRemoteContractFixture { + if (!isRecord(value)) throw new Error("Aiden Remote contract fixture must be an object."); + if (value.protocolVersion !== AIDEN_REMOTE_PROTOCOL_VERSION) { + throw new Error("Aiden Remote contract fixture protocolVersion must be 1."); + } + const contractRevision = requiredInteger(value, "contractRevision"); + if (contractRevision < 1) throw new Error("Aiden Remote contractRevision must be positive."); + if (!Array.isArray(value.capabilities)) throw new Error("Fixture capabilities must be an array."); + const capabilities = value.capabilities.map((entry) => { + if ( + typeof entry !== "string" || + !(AIDEN_REMOTE_CAPABILITIES as readonly string[]).includes(entry) + ) { + throw new Error(`Unknown Aiden Remote capability ${String(entry)}.`); + } + return entry as AidenRemoteCapability; + }); + if (!isRecord(value.health) || value.health.ok !== true || value.health.protocolVersion !== 1) { + throw new Error("Fixture health response is invalid."); + } + assertExactKeys(value.health, ["ok", "protocolVersion"], "Fixture health response"); + if (!isRecord(value.pairingBootstrap)) { + throw new Error("Fixture pairing bootstrap is invalid."); + } + const pairingBootstrap = value.pairingBootstrap; + assertExactKeys( + pairingBootstrap, + ["protocolVersion", "instanceId", "endpoint", "serverSpkiSha256", "secret", "expiresAt"], + "Fixture pairing bootstrap", + ); + if (pairingBootstrap.protocolVersion !== AIDEN_REMOTE_PROTOCOL_VERSION) { + throw new Error("Fixture pairing bootstrap protocolVersion must be 1."); + } + const instanceId = assertBoundedString(pairingBootstrap, "instanceId", AIDEN_REMOTE_MAX_IDENTIFIER_LENGTH); + const endpoint = requiredString(pairingBootstrap, "endpoint"); + const fingerprint = requiredString(pairingBootstrap, "serverSpkiSha256"); + const secret = requiredString(pairingBootstrap, "secret"); + assertAidenRemoteEndpoint(endpoint); + if (!/^sha256\/[A-Za-z0-9+/]{43}=$/.test(fingerprint)) { + throw new Error("Pairing bootstrap fingerprint must be a SHA-256 SPKI digest."); + } + assertBase64Url32(secret, "Pairing bootstrap secret"); + const expiresAt = requiredString(pairingBootstrap, "expiresAt"); + const expiryTime = parseStrictRfc3339(expiresAt, "Pairing bootstrap expiry"); + if (!isRecord(value.server) || typeof value.server.serverTime !== "string") { + throw new Error("Fixture serverTime must be a strict RFC 3339 date-time."); + } + const serverTime = parseStrictRfc3339(value.server.serverTime, "Fixture serverTime"); + if (expiryTime <= serverTime) throw new Error("Pairing bootstrap must not be expired."); + if (expiryTime - serverTime > 5 * 60_000) throw new Error("Pairing bootstrap TTL must not exceed five minutes."); + if (!isRecord(value.pairingExchange)) throw new Error("Fixture pairing exchange is invalid."); + const pairingExchange = value.pairingExchange; + assertExactKeys( + pairingExchange, + ["protocolVersion", "instanceId", "deviceId", "credential", "capabilities", "displayName", "endpoint", "serverSpkiSha256"], + "Fixture pairing exchange", + ); + if (pairingExchange.protocolVersion !== 1) throw new Error("Pairing exchange protocolVersion must be 1."); + if (assertBoundedString(pairingExchange, "instanceId", AIDEN_REMOTE_MAX_IDENTIFIER_LENGTH) !== instanceId) throw new Error("Pairing exchange instance does not match bootstrap."); + assertBoundedString(pairingExchange, "deviceId", AIDEN_REMOTE_MAX_IDENTIFIER_LENGTH); + assertBase64Url32(requiredString(pairingExchange, "credential"), "Pairing device credential"); + if (!Array.isArray(pairingExchange.capabilities)) throw new Error("Pairing exchange capabilities must be an array."); + const exchangeCapabilities = pairingExchange.capabilities.map((entry) => { + if (typeof entry !== "string" || !(AIDEN_REMOTE_CAPABILITIES as readonly string[]).includes(entry)) throw new Error(`Unknown pairing capability ${String(entry)}.`); + return entry as AidenRemoteCapability; + }); + if (new Set(exchangeCapabilities).size !== exchangeCapabilities.length) throw new Error("Pairing exchange capabilities must be unique."); + assertBoundedString(pairingExchange, "displayName", 80); + if (requiredString(pairingExchange, "endpoint") !== endpoint || requiredString(pairingExchange, "serverSpkiSha256") !== fingerprint) throw new Error("Pairing exchange identity does not match bootstrap."); + if (!Array.isArray(value.events)) throw new Error("Fixture events must be an array."); + const events = value.events.map(parseAidenRemoteStreamEvent).filter((event): event is AidenRemoteStreamEvent => event !== null); + assertOrderedAidenRemoteEvents(events); + if (!isRecord(value.error) || !isRecord(value.error.error)) { + throw new Error("Fixture error envelope is invalid."); + } + assertExactKeys(value.error, ["error"], "Error envelope"); + const errorBody = value.error.error; + assertExactKeys(errorBody, ["code", "message", "requestId", "retryable", "details"], "Error envelope error"); + const code = requiredString(errorBody, "code"); + if (!(AIDEN_REMOTE_ERROR_CODES as readonly string[]).includes(code)) { + throw new Error(`Unknown Aiden Remote error code ${code}.`); + } + if (characterLength(requiredString(errorBody, "message")) > 2_000) throw new Error("Error message is too long."); + if (characterLength(requiredString(errorBody, "requestId")) > 128) throw new Error("Error requestId is too long."); + if (typeof errorBody.retryable !== "boolean") throw new Error("Error retryable must be boolean."); + if (errorBody.details !== undefined) { + if (!isRecord(errorBody.details)) throw new Error("Error details must be an object."); + assertExactKeys(errorBody.details, ["currentRevision", "retryAfterSeconds", "chatId", "minimumClientVersion", "limit", "field"], "Error details"); + const stringDetailMaxima = { + currentRevision: 128, + chatId: 128, + minimumClientVersion: 40, + field: 120, + } as const; + for (const [key, maximum] of Object.entries(stringDetailMaxima)) { + const detail = errorBody.details[key]; + if (detail !== undefined && (typeof detail !== "string" || detail.length === 0 || characterLength(detail) > maximum)) throw new Error(`Error detail ${key} is invalid.`); + } + const boundedNumericDetails = { + retryAfterSeconds: 86_400, + limit: 1_000_000, + } as const; + for (const [key, maximum] of Object.entries(boundedNumericDetails)) { + const detail = errorBody.details[key]; + if (detail !== undefined && (!Number.isSafeInteger(detail) || (detail as number) < 0 || (detail as number) > maximum)) throw new Error(`Error detail ${key} is invalid.`); + } + } + assertNoForbiddenWireKeys(value); + return { + ...value, + contractRevision, + protocolVersion: AIDEN_REMOTE_PROTOCOL_VERSION, + capabilities, + health: { ok: true, protocolVersion: AIDEN_REMOTE_PROTOCOL_VERSION }, + pairingBootstrap: pairingBootstrap as unknown as AidenRemoteContractFixture["pairingBootstrap"], + pairingExchange: { ...(pairingExchange as unknown as AidenRemoteContractFixture["pairingExchange"]), capabilities: exchangeCapabilities }, + events, + error: value.error as unknown as AidenRemoteErrorEnvelope, + }; +} + +export interface AidenSseFrame { id: string; data: unknown } + +/** + * JSON.parse keeps only the last occurrence of a duplicate object key. Scan + * the raw JSON first so that no duplicate can hide an SSE envelope field. + * Keys are decoded while scanning, which also catches escaped-equivalent + * spellings such as "a" and "\\u0061" at every nesting level. + */ +export function parseAidenRemoteJson( + serialized: string, + label = "JSON data", +): unknown { + let offset = 0; + let objectKeys = 0; + + function fail(): never { + throw new Error(`Malformed Aiden ${label}.`); + } + + function skipWhitespace(): void { + while (serialized[offset] === " " || serialized[offset] === "\t" || serialized[offset] === "\n" || serialized[offset] === "\r") { + offset += 1; + } + } + + function readString(decode: boolean): string | undefined { + if (serialized[offset] !== '"') fail(); + offset += 1; + let segmentStart = offset; + let decoded = ""; + while (offset < serialized.length) { + const character = serialized[offset]!; + if (character === '"') { + decoded += serialized.slice(segmentStart, offset); + if (!isValidJsonString(decoded)) fail(); + offset += 1; + return decode ? decoded : undefined; + } + if (character === "\\") { + decoded += serialized.slice(segmentStart, offset); + offset += 1; + const escaped = serialized[offset]; + if (escaped === undefined) fail(); + if (escaped === "u") { + const hexadecimal = serialized.slice(offset + 1, offset + 5); + if (!/^[0-9a-fA-F]{4}$/u.test(hexadecimal)) fail(); + decoded += String.fromCharCode(Number.parseInt(hexadecimal, 16)); + offset += 5; + segmentStart = offset; + continue; + } + const replacement = escaped === '"' + ? '"' + : escaped === "\\" + ? "\\" + : escaped === "/" + ? "/" + : escaped === "b" + ? "\b" + : escaped === "f" + ? "\f" + : escaped === "n" + ? "\n" + : escaped === "r" + ? "\r" + : escaped === "t" + ? "\t" + : undefined; + if (replacement === undefined) fail(); + decoded += replacement; + offset += 1; + segmentStart = offset; + continue; + } + if (character.charCodeAt(0) < 0x20) fail(); + offset += 1; + } + fail(); + } + + function readNumber(): void { + const numberStart = offset; + if (serialized[offset] === "-") offset += 1; + if (serialized[offset] === "0") { + offset += 1; + } else { + const first = serialized.charCodeAt(offset); + if (first < 0x31 || first > 0x39) fail(); + do { + offset += 1; + } while (serialized.charCodeAt(offset) >= 0x30 && serialized.charCodeAt(offset) <= 0x39); + } + if (serialized[offset] === ".") { + offset += 1; + const firstFraction = serialized.charCodeAt(offset); + if (firstFraction < 0x30 || firstFraction > 0x39) fail(); + do { + offset += 1; + } while (serialized.charCodeAt(offset) >= 0x30 && serialized.charCodeAt(offset) <= 0x39); + } + if (serialized[offset] === "e" || serialized[offset] === "E") { + offset += 1; + if (serialized[offset] === "+" || serialized[offset] === "-") offset += 1; + const firstExponent = serialized.charCodeAt(offset); + if (firstExponent < 0x30 || firstExponent > 0x39) fail(); + do { + offset += 1; + } while (serialized.charCodeAt(offset) >= 0x30 && serialized.charCodeAt(offset) <= 0x39); + } + if (!Number.isFinite(Number(serialized.slice(numberStart, offset)))) fail(); + } + + function readValue(depth: number): void { + if (depth > AIDEN_REMOTE_MAX_JSON_NESTING_DEPTH) fail(); + skipWhitespace(); + const character = serialized[offset]; + if (character === "{") { + offset += 1; + skipWhitespace(); + const keys = new Set(); + if (serialized[offset] === "}") { + offset += 1; + return; + } + for (;;) { + const key = readString(true); + objectKeys += 1; + if (objectKeys > AIDEN_REMOTE_MAX_JSON_OBJECT_KEYS || key === undefined || keys.has(key)) fail(); + keys.add(key); + skipWhitespace(); + if (serialized[offset] !== ":") fail(); + offset += 1; + readValue(depth + 1); + skipWhitespace(); + if (serialized[offset] === "}") { + offset += 1; + return; + } + if (serialized[offset] !== ",") fail(); + offset += 1; + skipWhitespace(); + } + } + if (character === "[") { + offset += 1; + skipWhitespace(); + if (serialized[offset] === "]") { + offset += 1; + return; + } + for (;;) { + readValue(depth + 1); + skipWhitespace(); + if (serialized[offset] === "]") { + offset += 1; + return; + } + if (serialized[offset] !== ",") fail(); + offset += 1; + skipWhitespace(); + } + } + if (character === '"') { + readString(false); + return; + } + if (character === "-" || (character !== undefined && character >= "0" && character <= "9")) { + readNumber(); + return; + } + for (const literal of ["true", "false", "null"]) { + if (serialized.startsWith(literal, offset)) { + offset += literal.length; + return; + } + } + fail(); + } + + readValue(0); + skipWhitespace(); + if (offset !== serialized.length) fail(); + let parsed: unknown; + try { + parsed = JSON.parse(serialized) as unknown; + } catch { + throw new Error(`Malformed Aiden Remote ${label}.`); + } + assertNoForbiddenWireKeys(parsed, label); + return parsed; +} + +export function parseAidenSseFrames(input: string): AidenSseFrame[] { + return input.split(/\r?\n\r?\n/).filter(Boolean).map((frame) => { + if (Buffer.byteLength(frame, "utf8") > AIDEN_REMOTE_MAX_SSE_FRAME_BYTES) { + throw new Error("Aiden SSE frame exceeds the byte limit."); + } + let id: string | undefined; + const data: string[] = []; + for (const line of frame.split(/\r?\n/)) { + if (line.startsWith("id:")) id = line.slice(3).trim(); + if (line.startsWith("data:")) data.push(line.slice(5).trimStart()); + } + if (!id || data.length === 0) throw new Error("Malformed Aiden SSE frame."); + return { id, data: parseAidenRemoteJson(data.join("\n"), "SSE JSON data") }; + }); +} + +export function reconcileAidenSseFrames( + frames: readonly AidenSseFrame[], + lastEventId: number, + expectedStreamId: string, +): { events: AidenRemoteStreamEvent[]; reconcileRequired: boolean } { + if (expectedStreamId.length === 0 || expectedStreamId.length > 128) { + throw new Error("Aiden SSE expected streamId must be a bounded non-empty string."); + } + const events: AidenRemoteStreamEvent[] = []; + let expected = lastEventId + 1; + let terminalSeen = false; + for (const frame of frames) { + const event = parseAidenRemoteStreamEvent(frame.data); + const streamId = event?.streamId ?? (isRecord(frame.data) && typeof frame.data.streamId === "string" ? frame.data.streamId : undefined); + if (streamId !== expectedStreamId) return { events, reconcileRequired: true }; + const sequence = event?.sequence ?? (isRecord(frame.data) ? frame.data.sequence : undefined); + if (!Number.isSafeInteger(sequence) || frame.id !== String(sequence)) return { events, reconcileRequired: true }; + if ((sequence as number) <= lastEventId) continue; + if (sequence !== expected) return { events, reconcileRequired: true }; + if (terminalSeen) return { events, reconcileRequired: true }; + if (event) { + events.push(event); + terminalSeen = event.terminal; + } + expected += 1; + } + return { events, reconcileRequired: false }; +} diff --git a/main/services/aiden-remote-revocation.test.ts b/main/services/aiden-remote-revocation.test.ts new file mode 100644 index 00000000..f9de5df9 --- /dev/null +++ b/main/services/aiden-remote-revocation.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { revokeAidenRemoteRuntimeDevice } from "./aiden-remote-revocation.js"; +import { + AidenRemoteStateRegistry, + createDefaultAidenRemoteState, + defaultAidenRemoteStateDependencies, +} from "./aiden-remote-state.js"; +import { AidenRemoteStreamService, type AidenRemoteStreamSnapshot } from "./aiden-remote-streams.js"; + +test("runtime revocation is durable, cleans device resources, retries persistence, and preserves device B", async () => { + let stateDocument = createDefaultAidenRemoteState(() => Buffer.alloc(24, 4)); + let now = 1_000; + let randomCounter = 0; + const state = new AidenRemoteStateRegistry({ + load: async () => structuredClone(stateDocument), + save: async (document) => { stateDocument = structuredClone(document); }, + }, { + ...defaultAidenRemoteStateDependencies(), + now: () => now, + randomBytes: (size) => Buffer.alloc(size, ++randomCounter), + }); + await state.initialize(); + const deviceA = await state.issueDevice({ + name: "Personal iPhone", type: "iphone", clientVersion: "1", + }); + const deviceB = await state.issueDevice({ + name: "Travel iPhone", type: "iphone", clientVersion: "1", + }); + + let streamSnapshot: AidenRemoteStreamSnapshot = { version: 1, streams: [] }; + let failStreamSave = false; + const deniedApprovals: string[] = []; + const streams = new AidenRemoteStreamService({ + now: () => now, + cancel: () => true, + approve: (approvalId, decision) => { + deniedApprovals.push(`${approvalId}:${decision}`); + return true; + }, + persist: async (snapshot) => { + if (failStreamSave) throw new Error("stream disk unavailable"); + streamSnapshot = structuredClone(snapshot); + }, + }); + const ownerA = streams.create(deviceA.device.id, "stream-a", "chat-a", "turn-a"); + ownerA.owner.send("chat:approval", { approvalId: "approval-a", summary: "Write a file" }); + streams.create(deviceB.device.id, "stream-b", "chat-b", "turn-b"); + await streams.settlePersistence(); + + const chatRevocations: string[] = []; + const ownerRevocations: string[] = []; + const resources = { + state, + streams, + chats: { revokeDevice: (deviceId: string) => { chatRevocations.push(deviceId); } }, + workspaceOwners: { revokeDevice: (deviceId: string) => { ownerRevocations.push(deviceId); } }, + }; + + failStreamSave = true; + await assert.rejects( + revokeAidenRemoteRuntimeDevice(resources, deviceA.device.id), + /stream disk unavailable/u, + ); + assert.equal((await state.authenticate(deviceA.credential))?.revoked, true); + assert.equal((await state.authenticate(deviceB.credential))?.revoked, false); + assert.throws(() => streams.status(deviceA.device.id, "stream-a")); + assert.equal(streams.status(deviceB.device.id, "stream-b").state, "queued"); + assert.deepEqual(chatRevocations, [deviceA.device.id]); + assert.deepEqual(ownerRevocations, [deviceA.device.id]); + assert.deepEqual(deniedApprovals, ["approval-a:deny"]); + + failStreamSave = false; + now += 1; + assert.equal(await revokeAidenRemoteRuntimeDevice(resources, deviceA.device.id), false); + assert.equal(streamSnapshot.streams.some(({ deviceId }) => deviceId === deviceA.device.id), false); + assert.equal(streamSnapshot.streams.some(({ deviceId }) => deviceId === deviceB.device.id), true); + + const restartedStreams = new AidenRemoteStreamService({ + now: () => now, + cancel: () => false, + approve: () => false, + snapshot: streamSnapshot, + }); + assert.throws(() => restartedStreams.status(deviceA.device.id, "stream-a")); + assert.equal(restartedStreams.status(deviceB.device.id, "stream-b").state, "interrupted"); +}); diff --git a/main/services/aiden-remote-revocation.ts b/main/services/aiden-remote-revocation.ts new file mode 100644 index 00000000..ff6bbd57 --- /dev/null +++ b/main/services/aiden-remote-revocation.ts @@ -0,0 +1,30 @@ +export interface AidenRemoteRevocationState { + revokeDevice(deviceId: string): Promise; + snapshot(): Promise<{ devices: Array<{ id: string; revokedAt?: number }> }>; +} + +export interface AidenRemoteRevocationResources { + state: AidenRemoteRevocationState; + streams?: { revokeDevice(deviceId: string): Promise }; + chats?: { revokeDevice(deviceId: string): void }; + workspaceOwners: { revokeDevice(deviceId: string): void }; +} + +/** + * Main-process revocation transaction. Device state is committed first so a + * crash can never revive the credential. Cleanup is then retried even for an + * already-revoked device, and stream-journal durability is part of success. + */ +export async function revokeAidenRemoteRuntimeDevice( + resources: AidenRemoteRevocationResources, + deviceId: string, +): Promise { + const newlyRevoked = await resources.state.revokeDevice(deviceId); + const device = (await resources.state.snapshot()).devices.find(({ id }) => id === deviceId); + if (device?.revokedAt === undefined) return false; + + resources.chats?.revokeDevice(deviceId); + resources.workspaceOwners.revokeDevice(deviceId); + await resources.streams?.revokeDevice(deviceId); + return newlyRevoked; +} diff --git a/main/services/aiden-remote-router.test.ts b/main/services/aiden-remote-router.test.ts new file mode 100644 index 00000000..9f883cd8 --- /dev/null +++ b/main/services/aiden-remote-router.test.ts @@ -0,0 +1,882 @@ +import assert from "node:assert/strict"; +import { createServer, request as httpRequest } from "node:http"; +import test from "node:test"; +import { createAidenRemoteRequestHandler } from "./aiden-remote-router.js"; +import type { AidenRemoteCapability } from "./aiden-remote-protocol.js"; +import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; + +async function fixture(options: { + authenticate?: "valid" | "revoked" | "denied" | "invalid"; + capabilities?: AidenRemoteCapability[]; + authorizationBlocked?: () => boolean; +} = {}) { + const logs: unknown[] = []; + const calls: string[] = []; + const workspace = { + id: "workspace-1", + name: "Project", + permission: "ask" as const, + hasFolder: false, + isManagedWorktree: false, + createdAt: new Date(1_000).toISOString(), + updatedAt: new Date(2_000).toISOString(), + revision: `rev_${"r".repeat(43)}`, + }; + const chat = { + id: "chat-1", + workspaceId: "workspace-1", + title: "Chat", + providerId: "provider-1", + modelId: "model-1", + messages: [], + createdAt: new Date(1_000).toISOString(), + updatedAt: new Date(2_000).toISOString(), + revision: `rev_${"c".repeat(43)}`, + }; + const handler = createAidenRemoteRequestHandler({ + instanceId: "instance-1", + displayName: () => "Studio Mac", + appVersion: "0.30.0", + devices: { + acquireDeviceAuthorization: () => { + if (options.authorizationBlocked?.()) { + throw new AidenRemoteServiceError( + "credential_revoked", + "This device was revoked in Aiden Settings.", + 403, + ); + } + return () => undefined; + }, + authenticate: async (credential) => { + if (credential !== "a".repeat(43) || options.authenticate === "invalid") { + return null; + } + return { + id: "device-authorized-12345678", + revoked: options.authenticate === "revoked", + capabilities: new Set( + options.authenticate === "denied" + ? [] + : (options.capabilities ?? ["server:read" as const]), + ), + }; + }, + }, + workspaces: { + list: async () => ({ workspaces: [workspace] }), + get: async (id) => ({ ...workspace, id }), + create: async (deviceId, key) => { + calls.push(`create:${deviceId}:${key}`); + return workspace; + }, + update: async (id, revision) => { + calls.push(`update:${id}:${revision}`); + return { ...workspace, id }; + }, + remove: async (id, revision) => { + calls.push(`remove:${id}:${revision}`); + }, + }, + workspaceBrowser: { + listRoots: async (deviceId) => ({ + roots: [{ + id: "root-1", + label: "Projects", + location: `loc_${"l".repeat(43)}`, + policyRevision: "policy-1", + }], + deviceId, + }) as never, + listChildren: async (deviceId, location, cursor) => { + calls.push(`children:${deviceId}:${location}:${cursor ?? ""}`); + return { rootId: "root-1", label: "Projects", breadcrumbs: [], entries: [] }; + }, + createSelection: async (deviceId, location) => { + calls.push(`selection:${deviceId}:${location}`); + return { + selection: `sel_${"s".repeat(43)}`, + displayName: "Projects", + expiresAt: new Date(60_000).toISOString(), + }; + }, + }, + chats: { + list: async (workspaceId) => { + calls.push(`chat-list:${workspaceId ?? ""}`); + return { chats: [chat] }; + }, + get: async (id) => ({ ...chat, id }), + create: async (deviceId, key) => { + calls.push(`chat-create:${deviceId}:${key}`); + return chat; + }, + rename: async (id, revision) => { + calls.push(`chat-rename:${id}:${revision}`); + return { ...chat, id, title: "Renamed" }; + }, + move: async (deviceId, id, revision, key) => { + calls.push(`chat-move:${deviceId}:${id}:${revision}:${key}`); + return { ...chat, id, workspaceId: "workspace-2" }; + }, + remove: async (id, revision) => { + calls.push(`chat-remove:${id}:${revision}`); + }, + startTurn: async (deviceId, id, key) => { + calls.push(`turn:${deviceId}:${id}:${key}`); + return { + turnId: "turn-1", + streamId: "stream-1", + status: "accepted" as const, + message: { + id: "message-1", + role: "user" as const, + text: "Hello", + createdAt: new Date(3_000).toISOString(), + }, + }; + }, + }, + models: { + list: async () => ({ + providers: [{ id: "provider-1", label: "Provider", models: [{ id: "model-1", label: "Model" }] }], + defaults: { providerId: "provider-1", modelId: "model-1" }, + }), + }, + usage: { + summary: async (range) => ({ + range, + startDate: "2026-07-21", + endDate: "2026-08-19", + totals: { + requests: 12, completedRequests: 11, failedRequests: 1, cancelledRequests: 0, + reportedTokenRequests: 10, unmeteredRequests: 2, localRequests: 3, + costedRequests: 8, unpricedHostedRequests: 1, hostedCostUsd: 1.25, + activeDays: 4, currentStreak: 2, longestStreak: 3, + tokens: { input: 100, output: 50, cacheRead: 10, cacheWrite: 2, reasoning: 8, total: 170 }, + }, + days: [], + models: [], + }), + }, + streams: { + status: (_deviceId, streamId) => ({ + streamId, + chatId: "chat-1", + turnId: "turn-1", + state: "running" as const, + lastSequence: 2, + updatedAt: new Date(4_000).toISOString(), + }), + pendingApproval: (_deviceId, streamId) => ({ + approvalId: "approval-1", + streamId, + chatId: "chat-1", + summary: "Run a reviewed command", + toolCallId: "tool-1", + toolName: "bash", + expiresAt: new Date(60_000).toISOString(), + canAllow: false, + }), + cancel: async (deviceId, streamId, _key) => { + calls.push(`cancel:${deviceId}:${streamId}`); + return { + streamId, + chatId: "chat-1", + turnId: "turn-1", + state: "running" as const, + lastSequence: 2, + updatedAt: new Date(4_000).toISOString(), + }; + }, + respondApproval: async (deviceId, approvalId, decision, _key) => { + calls.push(`approval:${deviceId}:${approvalId}:${decision}`); + return { approvalId, decision, resolvedAt: new Date(5_000).toISOString() }; + }, + openEvents: (_deviceId, streamId, after, response) => { + calls.push(`events:${streamId}:${after}`); + const data = JSON.stringify({ streamId, sequence: after + 1 }); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end(`id: ${after + 1}\ndata: ${data}\n\n`); + }, + }, + files: { + list: async (deviceId, workspaceId) => { + calls.push(`files:${deviceId}:${workspaceId}`); + return { + snapshotId: "files-snapshot-1", + entries: [{ + id: `file_${"f".repeat(43)}`, + displayPath: "Sources/App.swift", + name: "App.swift", + kind: "file" as const, + size: 12, + language: "Swift", + }], + truncated: false, + maxEntries: 4_000 as const, + maxDepth: 20 as const, + }; + }, + read: async (deviceId, workspaceId, fileId) => { + calls.push(`file-read:${deviceId}:${workspaceId}:${fileId}`); + return { + id: fileId, + displayPath: "Sources/App.swift", + content: "let value = 1\n", + version: "a".repeat(64), + truncated: false as const, + }; + }, + write: async (deviceId, workspaceId, fileId) => { + calls.push(`file-write:${deviceId}:${workspaceId}:${fileId}`); + return { + id: fileId, + displayPath: "Sources/App.swift", + content: "let value = 2\n", + version: "b".repeat(64), + truncated: false as const, + }; + }, + }, + git: { + review: async (deviceId, workspaceId) => { + calls.push(`git-review:${deviceId}:${workspaceId}`); + return { + operationId: "op-review", + status: "snapshot", + snapshotId: `snap_${"s".repeat(43)}`, + result: { kind: "review", branch: "main", uncommitted: 0, files: [] }, + } as never; + }, + diff: async () => ({}) as never, + branches: async () => ({}) as never, + checkout: async () => ({}) as never, + createBranch: async () => ({}) as never, + commit: async () => ({}) as never, + pushCapability: async () => ({}) as never, + push: async () => ({}) as never, + compare: async () => ({}) as never, + comparisonDiff: async () => ({}) as never, + worktrees: async (deviceId, workspaceId) => { + calls.push(`git-worktrees:${deviceId}:${workspaceId}`); + return { + operationId: "op-worktrees", + status: "snapshot", + result: { kind: "worktrees", worktrees: [] }, + } as never; + }, + createWorktree: async (deviceId, workspaceId, key) => { + calls.push(`git-worktree-create:${deviceId}:${workspaceId}:${key}`); + return { + operationId: "op-worktree-create", + status: "succeeded", + result: { kind: "mutation", message: "Created managed worktree.", workspaceId: "workspace-2" }, + } as never; + }, + deleteManagedWorktree: async (deviceId, workspaceId, revision, key) => { + calls.push(`git-worktree-delete:${deviceId}:${workspaceId}:${revision}:${key}`); + return { + operationId: "op-worktree-delete", + status: "succeeded", + result: { kind: "mutation", message: "Removed managed worktree.", workspaceId }, + } as never; + }, + }, + schedules: { + list: async (deviceId) => { + calls.push(`schedule-list:${deviceId}`); + return { tasks: [] }; + }, + get: async () => ({}) as never, + create: async (deviceId, key) => { + calls.push(`schedule-create:${deviceId}:${key}`); + return { id: "task-1", revision: "rev-task-1" } as never; + }, + update: async () => ({}) as never, + remove: async (taskId, revision) => { + calls.push(`schedule-remove:${taskId}:${revision}`); + }, + pause: async (deviceId, taskId, revision, key) => { + calls.push(`schedule-pause:${deviceId}:${taskId}:${revision}:${key}`); + return { id: taskId, revision: "rev-task-2" } as never; + }, + resume: async () => ({}) as never, + run: async (deviceId, taskId, key) => { + calls.push(`schedule-run:${deviceId}:${taskId}:${key}`); + return { taskId, runId: "run-1", status: "accepted" as const, acceptedAt: new Date(1_000).toISOString() }; + }, + runs: async (taskId) => { + calls.push(`schedule-runs:${taskId}`); + return { runs: [] }; + }, + preview: () => ({ dates: [new Date(2_000).toISOString()] }), + scripts: async (deviceId, workspaceId) => { + calls.push(`schedule-scripts:${deviceId}:${workspaceId ?? ""}`); + return { scripts: [{ id: `script_${"s".repeat(43)}`, name: "daily.sh" }] }; + }, + mcpServers: async () => ({ servers: [{ id: "mcp-1", name: "GitHub" }] }), + settings: async () => ({ + revision: "rev-settings", enabled: true, defaultMode: "llm" as const, + defaultPermission: "read-only" as const, defaultMcpEnabled: false, + defaultNotify: true, defaultTimezone: "UTC", + }), + updateSettings: async () => ({}) as never, + }, + pairing: { + manualBootstrap: () => ({ + kind: "aiden-manual-pairing-v1", + protocolVersion: 1, + sessionId: `pairing_${"s".repeat(32)}`, + expiresAt: new Date(301_000).toISOString(), + salt: Buffer.alloc(16, 1).toString("base64url"), + nonce: Buffer.alloc(12, 2).toString("base64url"), + ciphertext: Buffer.from("sealed").toString("base64url"), + tag: Buffer.alloc(16, 3).toString("base64url"), + }), + exchange: async () => ({ + protocolVersion: 1, + instanceId: "instance-1", + deviceId: "device-1", + credential: "b".repeat(43), + capabilities: ["server:read" as const], + endpoint: "https://aiden.example.test/api/aiden/v1", + serverSpkiSha256: `sha256/${Buffer.alloc(32).toString("base64")}`, + }), + }, + connectionMode: () => "lan", + now: () => 1_000, + log: (entry) => logs.push(entry), + }); + const server = createServer(handler); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("test server did not bind"); + return { + base: `http://127.0.0.1:${address.port}/api/aiden/v1`, + logs, + calls, + close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())), + }; +} + +test("health is the only unauthenticated read and server projection requires both headers", async () => { + const app = await fixture(); + try { + const health = await fetch(`${app.base}/health`); + assert.equal(health.status, 200); + assert.deepEqual(await health.json(), { ok: true, protocolVersion: 1 }); + + const unauthenticated = await fetch(`${app.base}/server`); + assert.equal(unauthenticated.status, 400); + assert.equal((await unauthenticated.json()).error.code, "invalid_request"); + + const authenticated = await fetch(`${app.base}/server`, { + headers: { + authorization: `Bearer ${"a".repeat(43)}`, + "aiden-protocol-version": "1", + }, + }); + assert.equal(authenticated.status, 200); + const server = await authenticated.json(); + assert.equal(server.instanceId, "instance-1"); + assert.equal(server.name, "Studio Mac"); + assert.equal(server.connectionMode, "lan"); + assert.equal(JSON.stringify(server).includes("credential"), false); + } finally { + await app.close(); + } +}); + +test("authenticated workspace CRUD and browser routes preserve the frozen HTTP contract", async () => { + const app = await fixture({ + capabilities: ["workspace:read", "workspace:manage", "workspace:browse"], + }); + const headers = { + authorization: `Bearer ${"a".repeat(43)}`, + "aiden-protocol-version": "1", + }; + try { + const listed = await fetch(`${app.base}/workspaces`, { headers }); + assert.equal(listed.status, 200); + assert.equal((await listed.json()).workspaces[0].id, "workspace-1"); + + const created = await fetch(`${app.base}/workspaces`, { + method: "POST", + headers: { + ...headers, + "content-type": "application/json", + "idempotency-key": "workspace-create-key-0001", + }, + body: JSON.stringify({ mode: "folderless", name: "Project" }), + }); + assert.equal(created.status, 201); + + const revision = `rev_${"r".repeat(43)}`; + const updated = await fetch(`${app.base}/workspaces/workspace-1`, { + method: "PATCH", + headers: { ...headers, "content-type": "application/json", "if-match": revision }, + body: JSON.stringify({ confirmedForeground: true, name: "Renamed" }), + }); + assert.equal(updated.status, 200); + + const location = `loc_${"l".repeat(43)}`; + const cursor = `cur_${"c".repeat(43)}`; + const children = await fetch( + `${app.base}/workspace-browser/children?location=${location}&cursor=${cursor}`, + { headers }, + ); + assert.equal(children.status, 200); + const selection = await fetch(`${app.base}/workspace-browser/selections`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ location }), + }); + assert.equal(selection.status, 201); + + const removed = await fetch(`${app.base}/workspaces/workspace-1`, { + method: "DELETE", + headers: { ...headers, "if-match": revision }, + }); + assert.equal(removed.status, 204); + assert.deepEqual(app.calls, [ + "create:device-authorized-12345678:workspace-create-key-0001", + `update:workspace-1:${revision}`, + `children:device-authorized-12345678:${location}:${cursor}`, + `selection:device-authorized-12345678:${location}`, + `remove:workspace-1:${revision}`, + ]); + } finally { + await app.close(); + } +}); + +test("authenticated chat, model, turn, stream, cancel, and approval routes preserve the contract", async () => { + const app = await fixture({ + capabilities: ["chat:read", "chat:write", "approval:respond"], + }); + const headers = { + authorization: `Bearer ${"a".repeat(43)}`, + "aiden-protocol-version": "1", + }; + const revision = `rev_${"c".repeat(43)}`; + try { + const models = await fetch(`${app.base}/models`, { headers }); + assert.equal(models.status, 200); + assert.equal((await models.json()).defaults.modelId, "model-1"); + + const chats = await fetch(`${app.base}/chats?workspaceId=workspace-1`, { headers }); + assert.equal(chats.status, 200); + assert.equal((await chats.json()).chats[0].id, "chat-1"); + + const created = await fetch(`${app.base}/chats`, { + method: "POST", + headers: { ...headers, "content-type": "application/json", "idempotency-key": "chat-create-key-00001" }, + body: JSON.stringify({ workspaceId: "workspace-1" }), + }); + assert.equal(created.status, 201); + + const renamed = await fetch(`${app.base}/chats/chat-1`, { + method: "PATCH", + headers: { ...headers, "content-type": "application/json", "if-match": revision }, + body: JSON.stringify({ title: "Renamed" }), + }); + assert.equal(renamed.status, 200); + + const turn = await fetch(`${app.base}/chats/chat-1/turns`, { + method: "POST", + headers: { ...headers, "content-type": "application/json", "idempotency-key": "turn-start-key-000001" }, + body: JSON.stringify({ text: "Hello" }), + }); + assert.equal(turn.status, 202); + assert.equal((await turn.json()).streamId, "stream-1"); + + const status = await fetch(`${app.base}/streams/stream-1`, { headers }); + assert.equal(status.status, 200); + assert.equal((await status.json()).state, "running"); + + const approvalSnapshot = await fetch(`${app.base}/streams/stream-1/approval`, { headers }); + assert.equal(approvalSnapshot.status, 200); + assert.deepEqual(await approvalSnapshot.json(), { + approval: { + approvalId: "approval-1", + streamId: "stream-1", + chatId: "chat-1", + summary: "Run a reviewed command", + toolCallId: "tool-1", + toolName: "bash", + expiresAt: new Date(60_000).toISOString(), + canAllow: false, + }, + }); + + const events = await fetch(`${app.base}/streams/stream-1/events?after=1`, { headers }); + assert.equal(events.status, 200); + assert.match(await events.text(), /id: 2/u); + + const cancelled = await fetch(`${app.base}/streams/stream-1/cancel`, { + method: "POST", + headers: { ...headers, "idempotency-key": "cancel-stream-key-0001" }, + }); + assert.equal(cancelled.status, 202); + + const approval = await fetch(`${app.base}/approvals/approval-1/respond`, { + method: "POST", + headers: { ...headers, "content-type": "application/json", "idempotency-key": "approval-key-0000001" }, + body: JSON.stringify({ decision: "deny" }), + }); + assert.equal(approval.status, 200); + assert.equal((await approval.json()).decision, "deny"); + } finally { + await app.close(); + } +}); + +test("authenticated usage returns privacy-safe Mac aggregates with a bounded range", async () => { + const app = await fixture({ capabilities: ["server:read"] }); + const headers = { + authorization: `Bearer ${"a".repeat(43)}`, + "aiden-protocol-version": "1", + }; + try { + const response = await fetch(`${app.base}/usage?range=30d`, { headers }); + assert.equal(response.status, 200); + const summary = await response.json(); + assert.equal(summary.range, "30d"); + assert.equal(summary.totals.requests, 12); + assert.equal(JSON.stringify(summary).includes("chatId"), false); + + const invalid = await fetch(`${app.base}/usage?range=forever`, { headers }); + assert.equal(invalid.status, 400); + } finally { + await app.close(); + } +}); + +test("authenticated file index, read, and versioned write routes preserve opaque identifiers", async () => { + const app = await fixture({ capabilities: ["files:read", "files:write"] }); + const headers = { + authorization: `Bearer ${"a".repeat(43)}`, + "aiden-protocol-version": "1", + }; + const fileId = `file_${"f".repeat(43)}`; + try { + const index = await fetch(`${app.base}/workspaces/workspace-1/files`, { headers }); + assert.equal(index.status, 200); + assert.equal((await index.json()).entries[0].id, fileId); + + const document = await fetch(`${app.base}/workspaces/workspace-1/files/${fileId}`, { headers }); + assert.equal(document.status, 200); + assert.equal((await document.json()).displayPath, "Sources/App.swift"); + + const saved = await fetch(`${app.base}/workspaces/workspace-1/files/${fileId}`, { + method: "PUT", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ content: "let value = 2\n", expectedVersion: "a".repeat(64) }), + }); + assert.equal(saved.status, 200); + assert.equal((await saved.json()).version, "b".repeat(64)); + assert.deepEqual(app.calls, [ + "files:device-authorized-12345678:workspace-1", + `file-read:device-authorized-12345678:workspace-1:${fileId}`, + `file-write:device-authorized-12345678:workspace-1:${fileId}`, + ]); + } finally { + await app.close(); + } +}); + +test("authenticated Git review and confirmed managed-worktree routes preserve mutation preconditions", async () => { + const app = await fixture({ capabilities: ["git:read", "git:write"] }); + const headers = { + authorization: `Bearer ${"a".repeat(43)}`, + "aiden-protocol-version": "1", + }; + const createKey = "git-worktree-create-key-0001"; + const deleteKey = "git-worktree-delete-key-0001"; + const revision = `rev_${"r".repeat(43)}`; + try { + const review = await fetch(`${app.base}/workspaces/workspace-1/git/review`, { headers }); + assert.equal(review.status, 200); + assert.equal((await review.json()).result.kind, "review"); + + const created = await fetch(`${app.base}/workspaces/workspace-1/git/worktrees`, { + method: "POST", + headers: { ...headers, "content-type": "application/json", "idempotency-key": createKey }, + body: JSON.stringify({ branch: "feature/mobile", name: "Mobile", confirmedForeground: true }), + }); + assert.equal(created.status, 202); + + const missingRevision = await fetch(`${app.base}/workspaces/workspace-2/git/managed-worktree`, { + method: "DELETE", + headers: { ...headers, "content-type": "application/json", "idempotency-key": deleteKey }, + body: JSON.stringify({ confirmedForeground: true }), + }); + assert.equal(missingRevision.status, 400); + + const removed = await fetch(`${app.base}/workspaces/workspace-2/git/managed-worktree`, { + method: "DELETE", + headers: { + ...headers, + "content-type": "application/json", + "idempotency-key": deleteKey, + "if-match": revision, + }, + body: JSON.stringify({ confirmedForeground: true }), + }); + assert.equal(removed.status, 202); + assert.deepEqual(app.calls, [ + "git-review:device-authorized-12345678:workspace-1", + `git-worktree-create:device-authorized-12345678:workspace-1:${createKey}`, + `git-worktree-delete:device-authorized-12345678:workspace-2:${revision}:${deleteKey}`, + ]); + } finally { + await app.close(); + } +}); + +test("authenticated scheduled-task routes enforce capability and mutation preconditions", async () => { + const app = await fixture({ capabilities: ["schedule:read", "schedule:write"] }); + const headers = { + authorization: `Bearer ${"a".repeat(43)}`, + "aiden-protocol-version": "1", + }; + const key = "schedule-action-key-0001"; + try { + const listed = await fetch(`${app.base}/scheduled-tasks`, { headers }); + assert.equal(listed.status, 200); + + const scripts = await fetch(`${app.base}/scheduled-tasks/scripts?workspaceId=workspace-1`, { headers }); + assert.equal(scripts.status, 200); + assert.match((await scripts.json()).scripts[0].id, /^script_/u); + + const mcpServers = await fetch(`${app.base}/scheduled-tasks/mcp-servers`, { headers }); + assert.equal(mcpServers.status, 200); + assert.deepEqual(await mcpServers.json(), { servers: [{ id: "mcp-1", name: "GitHub" }] }); + + const created = await fetch(`${app.base}/scheduled-tasks`, { + method: "POST", + headers: { ...headers, "content-type": "application/json", "idempotency-key": key }, + body: JSON.stringify({ + name: "Daily", schedule: "0 8 * * *", timezone: "UTC", mode: "llm", + permission: "read-only", prompt: "Summarize", confirmedForeground: true, + }), + }); + assert.equal(created.status, 201); + + const missingRevision = await fetch(`${app.base}/scheduled-tasks/task-1/pause`, { + method: "POST", + headers: { ...headers, "idempotency-key": key }, + }); + assert.equal(missingRevision.status, 400); + + const paused = await fetch(`${app.base}/scheduled-tasks/task-1/pause`, { + method: "POST", + headers: { ...headers, "idempotency-key": key, "if-match": "rev-task-1" }, + }); + assert.equal(paused.status, 202); + + const run = await fetch(`${app.base}/scheduled-tasks/task-1/run`, { + method: "POST", + headers: { ...headers, "idempotency-key": key }, + }); + assert.equal(run.status, 202); + assert.equal((await run.json()).runId, "run-1"); + + const history = await fetch(`${app.base}/scheduled-tasks/task-1/runs`, { headers }); + assert.equal(history.status, 200); + assert.deepEqual(app.calls, [ + "schedule-list:device-authorized-12345678", + "schedule-scripts:device-authorized-12345678:workspace-1", + `schedule-create:device-authorized-12345678:${key}`, + `schedule-pause:device-authorized-12345678:task-1:rev-task-1:${key}`, + `schedule-run:device-authorized-12345678:task-1:${key}`, + "schedule-runs:task-1", + ]); + } finally { + await app.close(); + } +}); + +test("workspace routes reject query aliases, duplicate query keys, and missing mutation preconditions", async () => { + const app = await fixture({ + capabilities: ["workspace:read", "workspace:manage", "workspace:browse"], + }); + const headers = { + authorization: `Bearer ${"a".repeat(43)}`, + "aiden-protocol-version": "1", + }; + try { + const aliased = await fetch(`${app.base}/workspaces?workspaceId=secret`, { headers }); + assert.equal(aliased.status, 400); + assert.equal((await aliased.json()).error.code, "invalid_request"); + + const location = `loc_${"l".repeat(43)}`; + const duplicate = await fetch( + `${app.base}/workspace-browser/children?location=${location}&location=${location}`, + { headers }, + ); + assert.equal(duplicate.status, 400); + + const missingRevision = await fetch(`${app.base}/workspaces/workspace-1`, { + method: "DELETE", + headers, + }); + assert.equal(missingRevision.status, 400); + + const encodedAlias = await fetch( + `${app.base}/workspace-browser/children?location=loc_%61${"a".repeat(42)}`, + { headers }, + ); + assert.equal(encodedAlias.status, 400); + } finally { + await app.close(); + } +}); + +test("revoked and capability-limited credentials fail with stable classifications", async () => { + for (const [mode, code] of [ + ["revoked", "credential_revoked"], + ["denied", "capability_denied"], + ["invalid", "authentication_required"], + ] as const) { + const app = await fixture({ authenticate: mode }); + try { + const response = await fetch(`${app.base}/server`, { + headers: { + authorization: `Bearer ${"a".repeat(43)}`, + "aiden-protocol-version": "1", + }, + }); + assert.equal((await response.json()).error.code, code); + } finally { + await app.close(); + } + } +}); + +test("a body stalled across revocation cannot admit a turn after authorization is blocked", async () => { + let blocked = false; + const app = await fixture({ + capabilities: ["chat:write"], + authorizationBlocked: () => blocked, + }); + try { + const target = new URL(`${app.base}/chats/chat-1/turns`); + const response = new Promise<{ status: number; body: string }>((resolve, reject) => { + const request = httpRequest({ + host: target.hostname, + port: target.port, + path: target.pathname, + method: "POST", + headers: { + authorization: `Bearer ${"a".repeat(43)}`, + "aiden-protocol-version": "1", + "content-type": "application/json", + "idempotency-key": "turn-stalled-revocation-0001", + }, + }, (incoming) => { + const chunks: Buffer[] = []; + incoming.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + incoming.on("end", () => resolve({ + status: incoming.statusCode ?? 0, + body: Buffer.concat(chunks).toString("utf8"), + })); + }); + request.once("error", reject); + request.write("{"); + blocked = true; + request.end("}"); + }); + const result = await response; + assert.equal(result.status, 403); + assert.equal(JSON.parse(result.body).error.code, "credential_revoked"); + assert.equal(app.calls.some((entry) => entry.startsWith("turn:")), false); + } finally { + await app.close(); + } +}); + +test("pairing rejects duplicate JSON fields, browser origins, and oversized bodies", async () => { + const app = await fixture(); + try { + const duplicate = await fetch(`${app.base}/pairing/exchange`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: `{"secret":"${"x".repeat(43)}","secret":"${"y".repeat(43)}"}`, + }); + assert.equal(duplicate.status, 400); + assert.equal((await duplicate.json()).error.code, "invalid_request"); + + const browser = await fetch(`${app.base}/health`, { + headers: { origin: "https://attacker.example" }, + }); + assert.equal(browser.status, 403); + + const oversized = await fetch(`${app.base}/pairing/exchange`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ value: "x".repeat(1_048_576) }), + }); + assert.equal(oversized.status, 413); + assert.equal((await oversized.json()).error.code, "payload_too_large"); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(app.logs.length, 3); + } finally { + await app.close(); + } +}); + +test("manual pairing bootstrap is bounded, origin-rejecting, and does not accept input", async () => { + const app = await fixture(); + try { + const response = await fetch(`${app.base}/pairing/manual-bootstrap`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + assert.equal(response.status, 200); + const body = await response.json() as Record; + assert.equal(body.kind, "aiden-manual-pairing-v1"); + assert.equal("secret" in body, false); + assert.equal("manualCode" in body, false); + + const withInput = await fetch(`${app.base}/pairing/manual-bootstrap`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{"code":"do-not-send-codes"}', + }); + assert.equal(withInput.status, 400); + assert.equal((await withInput.json()).error.code, "invalid_request"); + + const browser = await fetch(`${app.base}/pairing/manual-bootstrap`, { + method: "POST", + headers: { + "content-type": "application/json", + origin: "https://attacker.example", + }, + body: "{}", + }); + assert.equal(browser.status, 403); + } finally { + await app.close(); + } +}); + +test("unknown routes and query aliases fail without reflecting untrusted input", async () => { + const app = await fixture(); + try { + const canary = "do-not-reflect-this-secret"; + const response = await fetch(`${app.base}/health?value=${canary}`); + const body = await response.text(); + assert.equal(response.status, 400); + assert.equal(body.includes(canary), false); + const missing = await fetch(`${app.base}/missing`); + assert.equal((await missing.json()).error.code, "not_found"); + } finally { + await app.close(); + } +}); diff --git a/main/services/aiden-remote-router.ts b/main/services/aiden-remote-router.ts new file mode 100644 index 00000000..2dae1882 --- /dev/null +++ b/main/services/aiden-remote-router.ts @@ -0,0 +1,1213 @@ +import { randomBytes } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + AIDEN_REMOTE_BASE_PATH, + AIDEN_REMOTE_CAPABILITIES, + AIDEN_REMOTE_PROTOCOL_VERSION, + parseAidenRemoteJson, + type AidenRemoteCapability, + type AidenRemoteErrorEnvelope, +} from "./aiden-remote-protocol.js"; +import { + AidenRemoteServiceError, + asAidenRemoteServiceError, +} from "./aiden-remote-errors.js"; +import type { AidenRemotePairingService } from "./aiden-remote-pairing.js"; +import type { + AidenRemoteAuthenticatedDevice, + AidenRemoteConnectionMode, + AidenRemoteStateRegistry, +} from "./aiden-remote-state.js"; +import type { AidenRemoteWorkspaceBrowserService } from "./aiden-remote-workspace-browser.js"; +import type { AidenRemoteWorkspaceService } from "./aiden-remote-workspaces.js"; +import type { AidenRemoteChatService } from "./aiden-remote-chats.js"; +import type { AidenRemoteModelService } from "./aiden-remote-models.js"; +import type { AidenRemoteStreamService } from "./aiden-remote-streams.js"; +import type { AidenRemoteFileService } from "./aiden-remote-files.js"; +import type { AidenRemoteGitService } from "./aiden-remote-git.js"; +import type { AidenRemoteScheduleService } from "./aiden-remote-schedules.js"; +import type { UsageDateRange, UsageSummary } from "./types.js"; +import { MAX_AIDEN_REMOTE_ATTACHMENT_REQUEST_BYTES } from "./aiden-remote-attachments.js"; + +const MAX_REQUEST_BODY_BYTES = 1_048_576; +const MAX_FILE_REQUEST_BODY_BYTES = 6 * 1_048_576; +const MAX_REQUEST_URL_LENGTH = 2_048; +export interface AidenRemoteServerProjection { + protocolVersion: typeof AIDEN_REMOTE_PROTOCOL_VERSION; + instanceId: string; + name: string; + appVersion: string; + capabilities: AidenRemoteCapability[]; + connectionMode: AidenRemoteConnectionMode; + minimumClientVersion?: string; + serverTime: string; +} + +export interface AidenRemoteRouterDependencies { + instanceId: string; + displayName(): string; + appVersion: string; + devices: Pick; + pairing: Pick + & Partial>; + workspaces?: Pick; + workspaceBrowser?: Pick< + AidenRemoteWorkspaceBrowserService, + "listRoots" | "listChildren" | "createSelection" + >; + chats?: Pick< + AidenRemoteChatService, + "list" | "get" | "create" | "rename" | "move" | "remove" | "startTurn" + > & Partial>; + models?: Pick; + streams?: Pick< + AidenRemoteStreamService, + "status" | "pendingApproval" | "cancel" | "respondApproval" | "openEvents" + >; + files?: Pick; + git?: Pick; + schedules?: Pick; + usage?: { summary(range: UsageDateRange): Promise }; + connectionMode(): AidenRemoteConnectionMode; + now(): number; + /** Tailscale Serve strips the public API prefix before loopback proxying. */ + acceptStrippedBasePath?: boolean; + log(entry: { + requestId: string; + route: + | "health" + | "pairingManualBootstrap" + | "pairingExchange" + | "server" + | "workspaces" + | "workspace" + | "workspaceBrowserRoots" + | "workspaceBrowserChildren" + | "workspaceBrowserSelection" + | "workspaceFiles" + | "workspaceFile" + | "workspaceGit" + | "scheduledTasks" + | "usage" + | "chats" + | "chat" + | "chatMove" + | "chatAttachment" + | "turns" + | "models" + | "stream" + | "streamApproval" + | "streamEvents" + | "streamCancel" + | "approvalRespond" + | "unknown"; + status: number; + latencyMs: number; + deviceIdSuffix?: string; + errorCode?: string; + }): void; +} + +function requestId(): string { + return `req_${randomBytes(18).toString("base64url")}`; +} + +function hasAsciiControl(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +function responseHeaders(contentType = "application/json; charset=utf-8") { + return { + "aiden-protocol-version": String(AIDEN_REMOTE_PROTOCOL_VERSION), + "cache-control": "no-store", + "content-type": contentType, + "cross-origin-resource-policy": "same-site", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + }; +} + +function writeJson(response: ServerResponse, status: number, value: unknown): void { + const body = JSON.stringify(value); + response.writeHead(status, { + ...responseHeaders(), + "content-length": String(Buffer.byteLength(body, "utf8")), + }); + response.end(body); +} + +function writeAttachmentContent( + response: ServerResponse, + content: { bytes: Buffer; mimeType: string }, +): void { + response.writeHead(200, { + ...responseHeaders(content.mimeType), + "content-length": String(content.bytes.length), + "content-security-policy": "default-src 'none'; sandbox", + }); + response.end(content.bytes); +} + +function writeError( + response: ServerResponse, + id: string, + error: AidenRemoteServiceError, +): void { + const envelope: AidenRemoteErrorEnvelope = { + error: { + code: error.code, + message: error.message, + requestId: id, + retryable: error.retryable, + ...(error.details ? { details: error.details } : {}), + }, + }; + writeJson(response, error.status, envelope); +} + +async function readJsonBody( + request: IncomingMessage, + maximumBytes = MAX_REQUEST_BODY_BYTES, +): Promise { + const contentType = request.headers["content-type"]; + if ( + typeof contentType !== "string" || + contentType.toLowerCase().split(";", 1)[0]?.trim() !== "application/json" + ) { + throw new AidenRemoteServiceError( + "invalid_request", + "This endpoint requires an application/json request body.", + 415, + ); + } + const declaredLength = request.headers["content-length"]; + if ( + typeof declaredLength === "string" && + (!/^\d+$/u.test(declaredLength) || Number(declaredLength) > maximumBytes) + ) { + throw new AidenRemoteServiceError( + "payload_too_large", + "The request body is too large.", + 413, + ); + } + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.length; + if (bytes > maximumBytes) { + throw new AidenRemoteServiceError( + "payload_too_large", + "The request body is too large.", + 413, + ); + } + chunks.push(buffer); + } + if (bytes === 0) { + throw new AidenRemoteServiceError( + "invalid_request", + "The request body is required.", + 400, + ); + } + let serialized: string; + try { + serialized = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); + } catch { + throw new AidenRemoteServiceError( + "invalid_request", + "The request body must be valid UTF-8 JSON.", + 400, + ); + } + try { + return parseAidenRemoteJson(serialized, "request JSON data"); + } catch { + throw new AidenRemoteServiceError( + "invalid_request", + "The request body must be valid JSON with unique safe fields.", + 400, + ); + } +} + +function bearerCredential(request: IncomingMessage): string | null { + const authorization = request.headers.authorization; + if (typeof authorization !== "string") return null; + const match = /^Bearer ([A-Za-z0-9_-]{43})$/u.exec(authorization); + return match?.[1] ?? null; +} + +async function authenticateCredential( + request: IncomingMessage, + devices: Pick, + capability: AidenRemoteCapability, +): Promise { + if (request.headers["aiden-protocol-version"] !== "1") { + throw new AidenRemoteServiceError( + "invalid_request", + "Aiden-Protocol-Version must be 1.", + 400, + false, + { minimumClientVersion: "1" }, + ); + } + const credential = bearerCredential(request); + if (!credential) { + throw new AidenRemoteServiceError( + "authentication_required", + "Pair this device in Aiden Settings before connecting.", + 401, + ); + } + const device = await devices.authenticate(credential); + if (!device) { + throw new AidenRemoteServiceError( + "authentication_required", + "This device credential is not valid for this Aiden installation.", + 401, + ); + } + if (device.revoked) { + throw new AidenRemoteServiceError( + "credential_revoked", + "This device was revoked in Aiden Settings.", + 403, + ); + } + if (!device.capabilities.has(capability)) { + throw new AidenRemoteServiceError( + "capability_denied", + "This device does not have access to that Aiden capability.", + 403, + ); + } + return device; +} + +function requestTarget( + request: IncomingMessage, + acceptStrippedBasePath: boolean, +): { path: string; query: string } { + const raw = request.url; + if ( + !raw || + raw.length > MAX_REQUEST_URL_LENGTH || + raw.includes("#") || + raw.includes("%") || + raw.includes("\\") || + hasAsciiControl(raw) + ) { + throw new AidenRemoteServiceError( + "invalid_request", + "The request URL is invalid.", + 400, + ); + } + const query = raw.indexOf("?"); + const path = query < 0 ? raw : raw.slice(0, query); + if ( + !path.startsWith("/") || + path.includes("//") || + path.split("/").some((segment) => segment === "." || segment === "..") + ) { + throw new AidenRemoteServiceError( + "invalid_request", + "The request URL is invalid.", + 400, + ); + } + const queryString = query < 0 ? "" : raw.slice(query + 1); + if (path.startsWith(`${AIDEN_REMOTE_BASE_PATH}/`)) { + return { path: path.slice(AIDEN_REMOTE_BASE_PATH.length), query: queryString }; + } + if (acceptStrippedBasePath) { + return { path, query: queryString }; + } + return { path: "", query: queryString }; +} + +function requireNoQuery(query: string): void { + if (query) { + throw new AidenRemoteServiceError( + "invalid_request", + "This endpoint does not accept query parameters.", + 400, + ); + } +} + +function browserQuery(query: string): { location: string; cursor?: string } { + const values = new Map(); + for (const component of query.split("&")) { + const separator = component.indexOf("="); + if (separator <= 0) { + throw new AidenRemoteServiceError("invalid_request", "The folder-browser query is invalid.", 400); + } + const key = component.slice(0, separator); + const value = component.slice(separator + 1); + if (values.has(key) || (key !== "location" && key !== "cursor")) { + throw new AidenRemoteServiceError("invalid_request", "The folder-browser query is invalid.", 400); + } + values.set(key, value); + } + const location = values.get("location"); + const cursor = values.get("cursor"); + if ( + !location || + !/^loc_[A-Za-z0-9_-]{43}$/u.test(location) || + (cursor !== undefined && !/^cur_[A-Za-z0-9_-]{43}$/u.test(cursor)) + ) { + throw new AidenRemoteServiceError("invalid_request", "The folder-browser query is invalid.", 400); + } + return { location, ...(cursor ? { cursor } : {}) }; +} + +function chatsQuery(query: string): { workspaceId?: string } { + if (!query) return {}; + const separator = query.indexOf("="); + if ( + separator <= 0 || + query.slice(0, separator) !== "workspaceId" || + query.indexOf("&") >= 0 || + !/^[A-Za-z0-9._:-]{1,128}$/u.test(query.slice(separator + 1)) + ) { + throw new AidenRemoteServiceError("invalid_request", "The chats query is invalid.", 400); + } + return { workspaceId: query.slice(separator + 1) }; +} + +function usageQuery(query: string): UsageDateRange { + const params = new URLSearchParams(query); + const range = params.get("range") ?? "30d"; + if (params.size !== 1 || !["7d", "30d", "90d", "1y", "all"].includes(range)) { + throw new AidenRemoteServiceError("invalid_request", "The usage range is invalid.", 400); + } + return range as UsageDateRange; +} + +function scheduledScriptsQuery(query: string): { workspaceId?: string } { + if (!query) return {}; + const separator = query.indexOf("="); + if ( + separator <= 0 || + query.slice(0, separator) !== "workspaceId" || + query.indexOf("&") >= 0 || + !/^[A-Za-z0-9_-]{1,128}$/u.test(query.slice(separator + 1)) + ) { + throw new AidenRemoteServiceError("invalid_request", "The scheduled-script query is invalid.", 400); + } + return { workspaceId: query.slice(separator + 1) }; +} + +function streamAfter(request: IncomingMessage, query: string): number { + let after: string | undefined; + if (query) { + const separator = query.indexOf("="); + if ( + separator <= 0 || + query.slice(0, separator) !== "after" || + query.indexOf("&") >= 0 + ) { + throw new AidenRemoteServiceError("invalid_request", "The stream cursor is invalid.", 400); + } + after = query.slice(separator + 1); + } + const lastEventId = request.headers["last-event-id"]; + if (Array.isArray(lastEventId) || (lastEventId !== undefined && typeof lastEventId !== "string")) { + throw new AidenRemoteServiceError("invalid_request", "Last-Event-ID is invalid.", 400); + } + if (after !== undefined && lastEventId !== undefined && after !== lastEventId) { + throw new AidenRemoteServiceError("invalid_request", "Stream cursors disagree.", 400); + } + const value = after ?? lastEventId ?? "0"; + if (!/^(?:0|[1-9]\d{0,15})$/u.test(value)) { + throw new AidenRemoteServiceError("invalid_request", "The stream cursor is invalid.", 400); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new AidenRemoteServiceError("invalid_request", "The stream cursor is invalid.", 400); + } + return parsed; +} + +function approvalDecision(value: unknown): "allow" | "deny" { + const record = value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; + if ( + !record || + Object.keys(record).length !== 1 || + (record.decision !== "allow" && record.decision !== "deny") + ) { + throw new AidenRemoteServiceError("invalid_request", "The approval response is invalid.", 400); + } + return record.decision; +} + +function requiredHeader( + request: IncomingMessage, + name: "if-match" | "idempotency-key", + pattern: RegExp, +): string { + const value = request.headers[name]; + if (typeof value !== "string" || !pattern.test(value)) { + throw new AidenRemoteServiceError( + "invalid_request", + `${name === "if-match" ? "If-Match" : "Idempotency-Key"} is required and invalid.`, + 400, + ); + } + return value; +} + +function selectionLocation(value: unknown): string { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.keys(value).length !== 1 || + typeof (value as { location?: unknown }).location !== "string" || + !/^loc_[A-Za-z0-9_-]{43}$/u.test((value as { location: string }).location) + ) { + throw new AidenRemoteServiceError("invalid_request", "The folder selection request is invalid.", 400); + } + return (value as { location: string }).location; +} + +function sourceIdentity(request: IncomingMessage): string { + const address = request.socket.remoteAddress ?? "unknown"; + return address.length <= 128 ? address : "unknown"; +} + +function requireEmptyObject(value: unknown): void { + if ( + !value + || typeof value !== "object" + || Array.isArray(value) + || Object.keys(value).length !== 0 + ) { + throw new AidenRemoteServiceError( + "invalid_request", + "This request body must be an empty JSON object.", + 400, + ); + } +} + +export function createAidenRemoteRequestHandler( + dependencies: AidenRemoteRouterDependencies, +): (request: IncomingMessage, response: ServerResponse) => void { + return (request, response) => { + const id = requestId(); + const startedAt = dependencies.now(); + let route: Parameters[0]["route"] = "unknown"; + let deviceIdSuffix: string | undefined; + let releaseDeviceAuthorization: (() => void) | undefined; + void (async () => { + if (request.headers.origin !== undefined) { + throw new AidenRemoteServiceError( + "invalid_request", + "Browser-origin requests are not accepted by Aiden Remote.", + 403, + ); + } + const target = requestTarget( + request, + dependencies.acceptStrippedBasePath === true, + ); + const { path, query } = target; + const authenticate = async ( + _request: IncomingMessage, + _devices: Pick, + capability: AidenRemoteCapability, + ): Promise => { + const device = await authenticateCredential(request, dependencies.devices, capability); + // Every authenticated operation crosses the synchronous revocation + // fence. Only mutations participate in the drain; SSE/read lifetimes + // must not postpone durable revocation or cleanup. + releaseDeviceAuthorization = dependencies.devices.acquireDeviceAuthorization( + device.id, + request.method !== "GET", + ); + return device; + }; + if (request.method === "GET" && path === "/health") { + requireNoQuery(query); + route = "health"; + writeJson(response, 200, { + ok: true, + protocolVersion: AIDEN_REMOTE_PROTOCOL_VERSION, + }); + return; + } + if (request.method === "POST" && path === "/pairing/exchange") { + requireNoQuery(query); + route = "pairingExchange"; + const result = await dependencies.pairing.exchange( + await readJsonBody(request), + sourceIdentity(request), + ); + writeJson(response, 200, result); + return; + } + if (request.method === "POST" && path === "/pairing/manual-bootstrap") { + requireNoQuery(query); + route = "pairingManualBootstrap"; + requireEmptyObject(await readJsonBody(request, 64)); + if (!dependencies.pairing.manualBootstrap) { + throw new AidenRemoteServiceError( + "not_found", + "This endpoint is unavailable.", + 404, + ); + } + writeJson(response, 200, dependencies.pairing.manualBootstrap()); + return; + } + if (request.method === "GET" && path === "/server") { + requireNoQuery(query); + route = "server"; + const device = await authenticate(request, dependencies.devices, "server:read"); + deviceIdSuffix = device.id.slice(-8); + const projection: AidenRemoteServerProjection = { + protocolVersion: AIDEN_REMOTE_PROTOCOL_VERSION, + instanceId: dependencies.instanceId, + name: dependencies.displayName(), + appVersion: dependencies.appVersion, + capabilities: [...AIDEN_REMOTE_CAPABILITIES], + connectionMode: dependencies.connectionMode(), + serverTime: new Date(dependencies.now()).toISOString(), + }; + writeJson(response, 200, projection); + return; + } + if (path === "/workspaces" && request.method === "GET") { + requireNoQuery(query); + route = "workspaces"; + const device = await authenticate(request, dependencies.devices, "workspace:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.workspaces) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, await dependencies.workspaces.list()); + return; + } + if (path === "/workspaces" && request.method === "POST") { + requireNoQuery(query); + route = "workspaces"; + const body = await readJsonBody(request); + const device = await authenticate(request, dependencies.devices, "workspace:manage"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.workspaces) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); + writeJson( + response, + 201, + await dependencies.workspaces.create(device.id, key, body), + ); + return; + } + const workspaceMatch = /^\/workspaces\/([A-Za-z0-9_-]{1,128})$/u.exec(path); + if (workspaceMatch && request.method === "GET") { + requireNoQuery(query); + route = "workspace"; + const device = await authenticate(request, dependencies.devices, "workspace:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.workspaces) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, await dependencies.workspaces.get(workspaceMatch[1]!)); + return; + } + if (workspaceMatch && request.method === "PATCH") { + requireNoQuery(query); + route = "workspace"; + const body = await readJsonBody(request); + const device = await authenticate(request, dependencies.devices, "workspace:manage"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.workspaces) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + const revision = requiredHeader(request, "if-match", /^[\x21-\x7e]{1,128}$/u); + writeJson( + response, + 200, + await dependencies.workspaces.update( + workspaceMatch[1]!, + revision, + body, + ), + ); + return; + } + if (workspaceMatch && request.method === "DELETE") { + requireNoQuery(query); + route = "workspace"; + const device = await authenticate(request, dependencies.devices, "workspace:manage"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.workspaces) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + const revision = requiredHeader(request, "if-match", /^[\x21-\x7e]{1,128}$/u); + await dependencies.workspaces.remove(workspaceMatch[1]!, revision); + response.writeHead(204, responseHeaders()); + response.end(); + return; + } + const workspaceFilesMatch = /^\/workspaces\/([A-Za-z0-9_-]{1,128})\/files$/u.exec(path); + if (workspaceFilesMatch && request.method === "GET") { + requireNoQuery(query); + route = "workspaceFiles"; + const device = await authenticate(request, dependencies.devices, "files:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.files) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, await dependencies.files.list(device.id, workspaceFilesMatch[1]!)); + return; + } + const workspaceFileMatch = /^\/workspaces\/([A-Za-z0-9_-]{1,128})\/files\/(file_[A-Za-z0-9_-]{43})$/u.exec(path); + if (workspaceFileMatch && request.method === "GET") { + requireNoQuery(query); + route = "workspaceFile"; + const device = await authenticate(request, dependencies.devices, "files:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.files) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson( + response, + 200, + await dependencies.files.read(device.id, workspaceFileMatch[1]!, workspaceFileMatch[2]!), + ); + return; + } + if (workspaceFileMatch && request.method === "PUT") { + requireNoQuery(query); + route = "workspaceFile"; + const body = await readJsonBody(request, MAX_FILE_REQUEST_BODY_BYTES); + const device = await authenticate(request, dependencies.devices, "files:write"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.files) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson( + response, + 200, + await dependencies.files.write( + device.id, + workspaceFileMatch[1]!, + workspaceFileMatch[2]!, + body, + ), + ); + return; + } + const gitBaseMatch = /^\/workspaces\/([A-Za-z0-9_-]{1,128})\/git\/(review|diff|branches|checkout|commit|push-capability|push|compare|comparison-diff|worktrees)$/u.exec(path); + if (gitBaseMatch) { + requireNoQuery(query); + route = "workspaceGit"; + const workspaceId = gitBaseMatch[1]!; + const action = gitBaseMatch[2]!; + const readRoute = + (action === "review" && request.method === "GET") || + (action === "diff" && request.method === "POST") || + (action === "branches" && request.method === "GET") || + (action === "push-capability" && request.method === "GET") || + (action === "compare" && request.method === "POST") || + (action === "comparison-diff" && request.method === "POST") || + (action === "worktrees" && request.method === "GET"); + const writeRoute = + (action === "branches" && request.method === "POST") || + (action === "checkout" && request.method === "POST") || + (action === "commit" && request.method === "POST") || + (action === "push" && request.method === "POST") || + (action === "worktrees" && request.method === "POST"); + if (readRoute || writeRoute) { + const body = request.method === "POST" ? await readJsonBody(request) : undefined; + const device = await authenticate( + request, + dependencies.devices, + writeRoute ? "git:write" : "git:read", + ); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.git) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + if (action === "review" && request.method === "GET") { + writeJson(response, 200, await dependencies.git.review(device.id, workspaceId)); + return; + } + if (action === "diff" && request.method === "POST") { + writeJson(response, 200, await dependencies.git.diff(device.id, workspaceId, body)); + return; + } + if (action === "branches" && request.method === "GET") { + writeJson(response, 200, await dependencies.git.branches(device.id, workspaceId)); + return; + } + if (action === "branches" && request.method === "POST") { + const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); + writeJson(response, 202, await dependencies.git.createBranch(device.id, workspaceId, key, body)); + return; + } + if (action === "checkout" && request.method === "POST") { + const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); + writeJson(response, 202, await dependencies.git.checkout(device.id, workspaceId, key, body)); + return; + } + if (action === "commit" && request.method === "POST") { + const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); + writeJson(response, 202, await dependencies.git.commit(device.id, workspaceId, key, body)); + return; + } + if (action === "push-capability" && request.method === "GET") { + writeJson(response, 200, await dependencies.git.pushCapability(device.id, workspaceId)); + return; + } + if (action === "push" && request.method === "POST") { + const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); + writeJson(response, 202, await dependencies.git.push(device.id, workspaceId, key, body)); + return; + } + if (action === "compare" && request.method === "POST") { + writeJson(response, 200, await dependencies.git.compare(device.id, workspaceId, body)); + return; + } + if (action === "comparison-diff" && request.method === "POST") { + writeJson(response, 200, await dependencies.git.comparisonDiff(device.id, workspaceId, body)); + return; + } + if (action === "worktrees" && request.method === "GET") { + writeJson(response, 200, await dependencies.git.worktrees(device.id, workspaceId)); + return; + } + if (action === "worktrees" && request.method === "POST") { + const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); + writeJson(response, 202, await dependencies.git.createWorktree(device.id, workspaceId, key, body)); + return; + } + } + } + const managedWorktreeMatch = /^\/workspaces\/([A-Za-z0-9_-]{1,128})\/git\/managed-worktree$/u.exec(path); + if (managedWorktreeMatch && request.method === "DELETE") { + requireNoQuery(query); + route = "workspaceGit"; + const body = await readJsonBody(request); + const device = await authenticate(request, dependencies.devices, "git:write"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.git) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + const revision = requiredHeader(request, "if-match", /^[\x21-\x7e]{1,128}$/u); + const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); + writeJson( + response, + 202, + await dependencies.git.deleteManagedWorktree( + device.id, + managedWorktreeMatch[1]!, + revision, + key, + body, + ), + ); + return; + } + if (path === "/scheduled-tasks" && request.method === "GET") { + requireNoQuery(query); + route = "scheduledTasks"; + const device = await authenticate(request, dependencies.devices, "schedule:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.schedules) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, await dependencies.schedules.list(device.id)); + return; + } + if (path === "/scheduled-tasks" && request.method === "POST") { + requireNoQuery(query); + route = "scheduledTasks"; + const body = await readJsonBody(request); + const device = await authenticate(request, dependencies.devices, "schedule:write"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.schedules) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); + writeJson(response, 201, await dependencies.schedules.create(device.id, key, body)); + return; + } + if (path === "/scheduled-tasks/preview" && request.method === "POST") { + requireNoQuery(query); + route = "scheduledTasks"; + const body = await readJsonBody(request); + const device = await authenticate(request, dependencies.devices, "schedule:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.schedules) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, dependencies.schedules.preview(body)); + return; + } + if (path === "/scheduled-tasks/scripts" && request.method === "GET") { + route = "scheduledTasks"; + const device = await authenticate(request, dependencies.devices, "schedule:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.schedules) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, await dependencies.schedules.scripts(device.id, scheduledScriptsQuery(query).workspaceId)); + return; + } + if (path === "/scheduled-tasks/mcp-servers" && request.method === "GET") { + requireNoQuery(query); + route = "scheduledTasks"; + const device = await authenticate(request, dependencies.devices, "schedule:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.schedules) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, await dependencies.schedules.mcpServers()); + return; + } + if (path === "/scheduled-tasks/settings" && request.method === "GET") { + requireNoQuery(query); + route = "scheduledTasks"; + const device = await authenticate(request, dependencies.devices, "schedule:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.schedules) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, await dependencies.schedules.settings()); + return; + } + if (path === "/scheduled-tasks/settings" && request.method === "PATCH") { + requireNoQuery(query); + route = "scheduledTasks"; + const body = await readJsonBody(request); + const device = await authenticate(request, dependencies.devices, "schedule:write"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.schedules) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + const revision = requiredHeader(request, "if-match", /^[\x21-\x7e]{1,128}$/u); + writeJson(response, 200, await dependencies.schedules.updateSettings(revision, body)); + return; + } + const scheduledRunsMatch = /^\/scheduled-tasks\/([A-Za-z0-9._:-]{1,160})\/runs$/u.exec(path); + if (scheduledRunsMatch && request.method === "GET") { + requireNoQuery(query); + route = "scheduledTasks"; + const device = await authenticate(request, dependencies.devices, "schedule:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.schedules) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, await dependencies.schedules.runs(scheduledRunsMatch[1]!)); + return; + } + const scheduledActionMatch = /^\/scheduled-tasks\/([A-Za-z0-9._:-]{1,160})\/(pause|resume|run)$/u.exec(path); + if (scheduledActionMatch && request.method === "POST") { + requireNoQuery(query); + route = "scheduledTasks"; + const device = await authenticate(request, dependencies.devices, "schedule:write"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.schedules) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + const taskId = scheduledActionMatch[1]!; + const action = scheduledActionMatch[2]!; + const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); + if (action === "run") { + writeJson(response, 202, await dependencies.schedules.run(device.id, taskId, key)); + return; + } + const revision = requiredHeader(request, "if-match", /^[\x21-\x7e]{1,128}$/u); + writeJson(response, 202, action === "pause" + ? await dependencies.schedules.pause(device.id, taskId, revision, key) + : await dependencies.schedules.resume(device.id, taskId, revision, key)); + return; + } + const scheduledTaskMatch = /^\/scheduled-tasks\/([A-Za-z0-9._:-]{1,160})$/u.exec(path); + if (scheduledTaskMatch && request.method === "GET") { + requireNoQuery(query); + route = "scheduledTasks"; + const device = await authenticate(request, dependencies.devices, "schedule:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.schedules) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, await dependencies.schedules.get(device.id, scheduledTaskMatch[1]!)); + return; + } + if (scheduledTaskMatch && request.method === "PATCH") { + requireNoQuery(query); + route = "scheduledTasks"; + const body = await readJsonBody(request); + const device = await authenticate(request, dependencies.devices, "schedule:write"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.schedules) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + const revision = requiredHeader(request, "if-match", /^[\x21-\x7e]{1,128}$/u); + writeJson(response, 200, await dependencies.schedules.update(device.id, scheduledTaskMatch[1]!, revision, body)); + return; + } + if (scheduledTaskMatch && request.method === "DELETE") { + requireNoQuery(query); + route = "scheduledTasks"; + const device = await authenticate(request, dependencies.devices, "schedule:write"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.schedules) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + const revision = requiredHeader(request, "if-match", /^[\x21-\x7e]{1,128}$/u); + await dependencies.schedules.remove(scheduledTaskMatch[1]!, revision); + response.writeHead(204, responseHeaders()); + response.end(); + return; + } + if (path === "/workspace-browser/roots" && request.method === "GET") { + requireNoQuery(query); + route = "workspaceBrowserRoots"; + const device = await authenticate(request, dependencies.devices, "workspace:browse"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.workspaceBrowser) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, await dependencies.workspaceBrowser.listRoots(device.id)); + return; + } + if (path === "/workspace-browser/children" && request.method === "GET") { + route = "workspaceBrowserChildren"; + const device = await authenticate(request, dependencies.devices, "workspace:browse"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.workspaceBrowser) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + const parsed = browserQuery(query); + writeJson( + response, + 200, + await dependencies.workspaceBrowser.listChildren( + device.id, + parsed.location, + parsed.cursor, + ), + ); + return; + } + if (path === "/workspace-browser/selections" && request.method === "POST") { + requireNoQuery(query); + route = "workspaceBrowserSelection"; + const body = await readJsonBody(request); + const device = await authenticate(request, dependencies.devices, "workspace:browse"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.workspaceBrowser) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson( + response, + 201, + await dependencies.workspaceBrowser.createSelection( + device.id, + selectionLocation(body), + ), + ); + return; + } + if (path === "/models" && request.method === "GET") { + requireNoQuery(query); + route = "models"; + const device = await authenticate(request, dependencies.devices, "chat:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.models) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, await dependencies.models.list()); + return; + } + if (path === "/usage" && request.method === "GET") { + route = "usage"; + const device = await authenticate(request, dependencies.devices, "server:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.usage) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, await dependencies.usage.summary(usageQuery(query))); + return; + } + if (path === "/chats" && request.method === "GET") { + route = "chats"; + const device = await authenticate(request, dependencies.devices, "chat:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.chats) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, await dependencies.chats.list(chatsQuery(query).workspaceId)); + return; + } + if (path === "/chats" && request.method === "POST") { + requireNoQuery(query); + route = "chats"; + const body = await readJsonBody(request); + const device = await authenticate(request, dependencies.devices, "chat:write"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.chats) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); + writeJson(response, 201, await dependencies.chats.create(device.id, key, body)); + return; + } + const chatMatch = /^\/chats\/([A-Za-z0-9._:-]{1,128})$/u.exec(path); + if (chatMatch && request.method === "GET") { + requireNoQuery(query); + route = "chat"; + const device = await authenticate(request, dependencies.devices, "chat:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.chats) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, await dependencies.chats.get(chatMatch[1]!)); + return; + } + if (chatMatch && request.method === "PATCH") { + requireNoQuery(query); + route = "chat"; + const body = await readJsonBody(request); + const device = await authenticate(request, dependencies.devices, "chat:write"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.chats) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + const revision = requiredHeader(request, "if-match", /^[\x21-\x7e]{1,128}$/u); + writeJson(response, 200, await dependencies.chats.rename(chatMatch[1]!, revision, body)); + return; + } + if (chatMatch && request.method === "DELETE") { + requireNoQuery(query); + route = "chat"; + const device = await authenticate(request, dependencies.devices, "chat:write"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.chats) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + const revision = requiredHeader(request, "if-match", /^[\x21-\x7e]{1,128}$/u); + await dependencies.chats.remove(chatMatch[1]!, revision); + response.writeHead(204, responseHeaders()); + response.end(); + return; + } + const moveMatch = /^\/chats\/([A-Za-z0-9._:-]{1,128})\/move$/u.exec(path); + if (moveMatch && request.method === "POST") { + requireNoQuery(query); + route = "chatMove"; + const body = await readJsonBody(request); + const device = await authenticate(request, dependencies.devices, "chat:write"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.chats) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + const revision = requiredHeader(request, "if-match", /^[\x21-\x7e]{1,128}$/u); + const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); + writeJson(response, 200, await dependencies.chats.move(device.id, moveMatch[1]!, revision, key, body)); + return; + } + const turnsMatch = /^\/chats\/([A-Za-z0-9._:-]{1,128})\/turns$/u.exec(path); + if (turnsMatch && request.method === "POST") { + requireNoQuery(query); + route = "turns"; + const body = await readJsonBody(request); + const device = await authenticate(request, dependencies.devices, "chat:write"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.chats) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); + writeJson(response, 202, await dependencies.chats.startTurn(device.id, turnsMatch[1]!, key, body)); + return; + } + const attachmentCollectionMatch = /^\/chats\/([A-Za-z0-9._:-]{1,128})\/attachments$/u.exec(path); + if (attachmentCollectionMatch && request.method === "POST") { + requireNoQuery(query); + route = "chatAttachment"; + const body = await readJsonBody(request, MAX_AIDEN_REMOTE_ATTACHMENT_REQUEST_BYTES); + const device = await authenticate(request, dependencies.devices, "chat:write"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.chats?.uploadAttachment) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson( + response, + 201, + await dependencies.chats.uploadAttachment( + device.id, + attachmentCollectionMatch[1]!, + body, + ), + ); + return; + } + const attachmentMatch = /^\/chats\/([A-Za-z0-9._:-]{1,128})\/attachments\/(att_[A-Za-z0-9_-]{43})$/u.exec(path); + if (attachmentMatch && request.method === "DELETE") { + requireNoQuery(query); + route = "chatAttachment"; + const device = await authenticate(request, dependencies.devices, "chat:write"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.chats?.removeAttachment) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + await dependencies.chats.removeAttachment(device.id, attachmentMatch[1]!, attachmentMatch[2]!); + response.writeHead(204, responseHeaders()); + response.end(); + return; + } + const attachmentContentMatch = /^\/chats\/([A-Za-z0-9._:-]{1,128})\/attachments\/([A-Za-z0-9._:-]{1,256})\/content$/u.exec(path); + if (attachmentContentMatch && request.method === "GET") { + requireNoQuery(query); + route = "chatAttachment"; + const device = await authenticate(request, dependencies.devices, "chat:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.chats?.attachmentContent) { + throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + } + writeAttachmentContent( + response, + await dependencies.chats.attachmentContent( + attachmentContentMatch[1]!, + attachmentContentMatch[2]!, + ), + ); + return; + } + const streamMatch = /^\/streams\/([A-Za-z0-9._:-]{1,128})$/u.exec(path); + if (streamMatch && request.method === "GET") { + requireNoQuery(query); + route = "stream"; + const device = await authenticate(request, dependencies.devices, "chat:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.streams) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, dependencies.streams.status(device.id, streamMatch[1]!)); + return; + } + const eventsMatch = /^\/streams\/([A-Za-z0-9._:-]{1,128})\/events$/u.exec(path); + if (eventsMatch && request.method === "GET") { + route = "streamEvents"; + const device = await authenticate(request, dependencies.devices, "chat:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.streams) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + dependencies.streams.openEvents(device.id, eventsMatch[1]!, streamAfter(request, query), response); + return; + } + const streamApprovalMatch = /^\/streams\/([A-Za-z0-9._:-]{1,128})\/approval$/u.exec(path); + if (streamApprovalMatch && request.method === "GET") { + requireNoQuery(query); + route = "streamApproval"; + const device = await authenticate(request, dependencies.devices, "chat:read"); + deviceIdSuffix = device.id.slice(-8); + if (!dependencies.streams) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 200, { approval: dependencies.streams.pendingApproval(device.id, streamApprovalMatch[1]!) }); + return; + } + const cancelMatch = /^\/streams\/([A-Za-z0-9._:-]{1,128})\/cancel$/u.exec(path); + if (cancelMatch && request.method === "POST") { + requireNoQuery(query); + route = "streamCancel"; + const device = await authenticate(request, dependencies.devices, "chat:write"); + deviceIdSuffix = device.id.slice(-8); + const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); + if (!dependencies.streams) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson(response, 202, await dependencies.streams.cancel(device.id, cancelMatch[1]!, key)); + return; + } + const approvalMatch = /^\/approvals\/([A-Za-z0-9._:-]{1,128})\/respond$/u.exec(path); + if (approvalMatch && request.method === "POST") { + requireNoQuery(query); + route = "approvalRespond"; + const body = await readJsonBody(request); + const device = await authenticate(request, dependencies.devices, "approval:respond"); + deviceIdSuffix = device.id.slice(-8); + const key = requiredHeader(request, "idempotency-key", /^[\x21-\x7e]{16,128}$/u); + if (!dependencies.streams) throw new AidenRemoteServiceError("not_found", "This endpoint is unavailable.", 404); + writeJson( + response, + 200, + await dependencies.streams.respondApproval( + device.id, + approvalMatch[1]!, + approvalDecision(body), + key, + ), + ); + return; + } + throw new AidenRemoteServiceError( + "not_found", + "This Aiden Remote endpoint does not exist.", + 404, + ); + })() + .finally(() => releaseDeviceAuthorization?.()) + .then(() => { + dependencies.log({ + requestId: id, + route, + status: response.statusCode, + latencyMs: Math.max(0, dependencies.now() - startedAt), + ...(deviceIdSuffix ? { deviceIdSuffix } : {}), + }); + }) + .catch((error: unknown) => { + const safe = asAidenRemoteServiceError(error); + if (!response.headersSent) writeError(response, id, safe); + else response.destroy(); + dependencies.log({ + requestId: id, + route, + status: safe.status, + latencyMs: Math.max(0, dependencies.now() - startedAt), + ...(deviceIdSuffix ? { deviceIdSuffix } : {}), + errorCode: safe.code, + }); + }); + }; +} diff --git a/main/services/aiden-remote-schedules.test.ts b/main/services/aiden-remote-schedules.test.ts new file mode 100644 index 00000000..149dc8e3 --- /dev/null +++ b/main/services/aiden-remote-schedules.test.ts @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { AidenRemoteScheduleService } from "./aiden-remote-schedules.js"; +import type { ScheduledRun, ScheduledTask, ScheduledTaskInput } from "./types.js"; + +function fixture() { + let clock = 100; + let runStarts = 0; + const tasks = new Map(); + const histories = new Map(); + let settingsValue = { + revision: "rev_settings", + value: { + enabled: true, defaultMode: "llm" as const, defaultPermission: "read-only" as const, + defaultMcpEnabled: false, defaultNotify: true, defaultTimezone: "UTC", + }, + }; + const application = { + list: async () => [...tasks.values()], + get: async (id: string) => { + const task = tasks.get(id); + if (!task) throw new Error(`Scheduled task ${id} not found.`); + return task; + }, + save: async (input: ScheduledTaskInput, options: { expectedRevision?: string } = {}) => { + const existing = input.id ? tasks.get(input.id) : undefined; + if (options.expectedRevision && options.expectedRevision !== `revision:${existing?.updatedAt}`) { + throw new Error("This automation changed."); + } + const updatedAt = ++clock; + const task: ScheduledTask = { + id: existing?.id ?? `task-${tasks.size + 1}`, name: input.name, enabled: true, + mode: input.mode, cron: input.cron, timezone: input.timezone ?? "UTC", + workspaceId: input.workspaceId, providerId: input.providerId, model: input.model, + prompt: input.prompt, script: input.script, permission: input.permission ?? "read-only", + mcpServerIds: input.mcpServerIds, notify: input.notify ?? true, + providerFingerprint: "private-provider-fingerprint", chatId: "private-chat", + mcpServerBindings: [{ id: "mcp-1", fingerprint: "private-mcp-fingerprint" }], + createdAt: existing?.createdAt ?? updatedAt, updatedAt, + }; + tasks.set(task.id, task); + return task; + }, + remove: async (id: string) => { tasks.delete(id); }, + pause: async (id: string) => { + const next = { ...tasks.get(id)!, enabled: false, updatedAt: ++clock }; + tasks.set(id, next); + return next; + }, + resume: async (id: string) => { + const next = { ...tasks.get(id)!, enabled: true, updatedAt: ++clock }; + tasks.set(id, next); + return next; + }, + runNow: async (id: string, runId?: string) => { + runStarts += 1; + const run: ScheduledRun = { + id: runId ?? "run-local", taskId: id, startedAt: 1, finishedAt: 2, + result: "success", output: "read /Users/private/project and token_secretvalue123456", + }; + histories.set(id, [run]); + return undefined; + }, + runs: async (id: string) => histories.get(id) ?? [], + preview: (_cron: string, _timezone: string, count = 3) => Array.from({ length: count }, (_, index) => 1_000 + index), + scripts: async () => ["safe.sh"], + mcpServers: async () => [{ id: "mcp-1", name: "GitHub" }], + settings: async () => settingsValue, + updateSettings: async (_revision: string, patch: Record) => { + settingsValue = { ...settingsValue, revision: "rev_settings_2", value: { ...settingsValue.value, ...patch } } as typeof settingsValue; + return settingsValue; + }, + isRunning: () => false, + }; + const service = new AidenRemoteScheduleService({ + application, + models: { resolve: async () => ({ providerId: "provider-1", modelId: "model-1", thinkingLevels: [] }) }, + }); + return { service, tasks, histories, application, runStarts: () => runStarts }; +} + +const llmMutation = { + name: "Morning review", schedule: "0 8 * * *", timezone: "UTC", mode: "llm", + permission: "read-only", prompt: "Summarize the workspace", confirmedForeground: true, +}; + +test("scheduled-task projections omit runtime authority and script selections are opaque and device bound", async () => { + const value = fixture(); + const created = await value.service.create("device-1", "create-key-123456", llmMutation); + const serialized = JSON.stringify(created); + assert.doesNotMatch(serialized, /fingerprint|private-chat|\/Users\//u); + assert.equal(created.providerId, "provider-1"); + assert.equal(created.prompt, "Summarize the workspace"); + + const inventory = await value.service.scripts("device-1", "workspace-1"); + assert.match(inventory.scripts[0]!.id, /^script_[A-Za-z0-9_-]{43}$/u); + await assert.rejects( + value.service.create("device-2", "script-key-123456", { + name: "Script", schedule: "0 9 * * *", timezone: "UTC", mode: "script", + permission: "full", scriptId: inventory.scripts[0]!.id, workspaceId: "workspace-1", + confirmedForeground: true, + }), + /another device/u, + ); + const script = await value.service.create("device-1", "script-key-654321", { + name: "Script", schedule: "0 9 * * *", timezone: "UTC", mode: "script", + permission: "full", scriptId: inventory.scripts[0]!.id, workspaceId: "workspace-1", + confirmedForeground: true, + }); + assert.ok(script.scriptId); + assert.doesNotMatch(JSON.stringify(script), /safe\.sh|\/Users\//u); + assert.deepEqual(await value.service.mcpServers(), { + servers: [{ id: "mcp-1", name: "GitHub" }], + }); +}); + +test("scheduled task run retries reuse one accepted run and history is bounded and redacted", async () => { + const value = fixture(); + const created = await value.service.create("device-1", "create-key-123456", llmMutation); + const first = await value.service.run("device-1", created.id, "run-key-12345678"); + const replay = await value.service.run("device-1", created.id, "run-key-12345678"); + assert.deepEqual(replay, first); + assert.equal(value.runStarts(), 1); + const history = await value.service.runs(created.id); + assert.equal(history.runs[0]?.id, first.runId); + assert.match(history.runs[0]?.summary ?? "", /\[local path\]/u); + assert.doesNotMatch(history.runs[0]?.summary ?? "", /secretvalue|\/Users\//u); +}); + +test("accepted execution remains scheduler-owned after the remote caller disconnects", async () => { + const value = fixture(); + const created = await value.service.create("device-1", "create-key-123456", llmMutation); + let finish!: () => void; + const completion = new Promise((resolve) => { finish = resolve; }); + value.application.runNow = async (id: string, runId?: string) => { + void completion.then(() => { + value.histories.set(id, [{ + id: runId ?? "run-local", taskId: id, startedAt: 1, finishedAt: 2, + result: "success", output: "completed without the caller", + }]); + }); + return undefined; + }; + + const accepted = await value.service.run("device-1", created.id, "disconnect-key-1234"); + assert.deepEqual((await value.service.runs(created.id)).runs, []); + finish(); + await completion; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal((await value.service.runs(created.id)).runs[0]?.id, accepted.runId); +}); + +test("scheduled task mutations require foreground confirmation and stale edits fail explicitly", async () => { + const value = fixture(); + await assert.rejects( + value.service.create("device-1", "create-key-123456", { ...llmMutation, confirmedForeground: false }), + /foreground review/u, + ); + const created = await value.service.create("device-1", "create-key-654321", llmMutation); + await assert.rejects( + value.service.update("device-1", created.id, "rev_stale", llmMutation), + /changed/u, + ); + await assert.rejects( + value.service.updateSettings("rev_settings", { + confirmedForeground: true, + defaultMode: "garbage", + }), + /settings are invalid/u, + ); +}); diff --git a/main/services/aiden-remote-schedules.ts b/main/services/aiden-remote-schedules.ts new file mode 100644 index 00000000..94ebabee --- /dev/null +++ b/main/services/aiden-remote-schedules.ts @@ -0,0 +1,422 @@ +import { createHash, randomBytes } from "node:crypto"; +import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; +import type { AidenRemoteModelService } from "./aiden-remote-models.js"; +import { + AidenIdempotencyLedger, + type AidenIdempotencySnapshot, +} from "./aiden-remote-operation-contract.js"; +import type { ScheduledTaskApplicationService } from "./scheduled-task-application-service.js"; +import { scheduledTaskRevision } from "./scheduled-task-application-service.js"; +import type { + ScheduledRun, + ScheduledTask, + ScheduledTaskInput, + ScheduledTaskMode, + ScheduledTaskPermission, +} from "./types.js"; + +const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7e]{16,128}$/u; +const TASK_ID_PATTERN = /^[A-Za-z0-9._:-]{1,160}$/u; +const SCRIPT_ID_PATTERN = /^script_[A-Za-z0-9_-]{43}$/u; +const SCRIPT_CLAIM_TTL_MS = 10 * 60_000; +const MAX_SCRIPT_CLAIMS = 4_096; + +interface ScriptClaim { + deviceId: string; + workspaceId?: string; + name: string; + expiresAt: number; +} + +export interface AidenRemoteScheduledTaskProjection { + id: string; + revision: string; + name: string; + enabled: boolean; + schedule: string; + timezone: string; + mode: ScheduledTaskMode; + permission: ScheduledTaskPermission; + workspaceId?: string; + providerId?: string; + modelId?: string; + mcpServerIds?: string[]; + scriptId?: string; + prompt?: string; + notify: boolean; + running: boolean; + nextRunAt?: string; + lastRunAt?: string; + lastResult?: string; + createdAt: string; + updatedAt: string; +} + +function ownRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function exactKeys( + record: Record, + required: readonly string[], + optional: readonly string[] = [], +): boolean { + const allowed = new Set([...required, ...optional]); + return required.every((key) => Object.prototype.hasOwnProperty.call(record, key)) + && Object.keys(record).every((key) => allowed.has(key)); +} + +function boundedString(value: unknown, maximum: number): value is string { + return typeof value === "string" && value.trim().length > 0 && [...value].length <= maximum; +} + +function boundedIds(value: unknown): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.length > 64) return undefined; + const ids = value.map((item) => boundedString(item, 256) ? item : ""); + if (ids.some((id) => !id) || new Set(ids).size !== ids.length) return undefined; + return ids; +} + +function safeTaskId(value: string): string { + if (!TASK_ID_PATTERN.test(value)) { + throw new AidenRemoteServiceError("invalid_request", "The scheduled-task identifier is invalid.", 400); + } + return value; +} + +function timestamp(value: number | undefined): string | undefined { + return value === undefined ? undefined : new Date(value).toISOString(); +} + +function redactSummary(value: string): string { + return [...value + .replace(/\/(?:Users|home)\/[^\s"'`]+/gu, "[local path]") + .replace(/\b(?:sk|key|token|secret|bearer)[-_][A-Za-z0-9._-]{12,}\b/giu, "[redacted]")] + .slice(0, 20_000) + .join(""); +} + +function mapRun(run: ScheduledRun) { + const failed = run.result === "error" || run.result === "blocked"; + const summary = redactSummary(run.error ?? run.output); + return { + id: run.id, + taskId: run.taskId, + status: failed ? "failed" as const : "succeeded" as const, + startedAt: new Date(run.startedAt).toISOString(), + finishedAt: new Date(run.finishedAt).toISOString(), + ...(summary ? { summary } : {}), + ...(failed ? { errorCode: run.result === "blocked" ? "blocked" : "execution_failed" } : {}), + }; +} + +function mapError(error: unknown): never { + if (error instanceof AidenRemoteServiceError) throw error; + const message = error instanceof Error ? error.message : ""; + if (/not found/iu.test(message)) { + throw new AidenRemoteServiceError("not_found", "This scheduled task no longer exists.", 404); + } + if (/changed|revision/iu.test(message)) { + throw new AidenRemoteServiceError("revision_conflict", "This scheduled task changed. Refresh it before trying again.", 409); + } + if (/already running/iu.test(message)) { + throw new AidenRemoteServiceError("operation_in_progress", "This scheduled task is already running.", 409, true); + } + if (/workspace|script|provider|model|MCP|cron|schedule|timezone|permission|prompt/iu.test(message)) { + throw new AidenRemoteServiceError("invalid_request", message.slice(0, 500), 400); + } + throw new AidenRemoteServiceError("internal_error", "Aiden could not complete this scheduled-task request.", 500); +} + +export class AidenRemoteScheduleService { + private readonly idempotency: AidenIdempotencyLedger; + private readonly scriptClaims = new Map(); + + constructor( + private readonly options: { + application: Pick; + models: Pick; + idempotency?: AidenIdempotencyLedger; + persistIdempotency?: (snapshot: AidenIdempotencySnapshot) => Promise; + now?: () => number; + }, + ) { + this.idempotency = options.idempotency ?? new AidenIdempotencyLedger(); + } + + private now(): number { + return this.options.now?.() ?? Date.now(); + } + + private pruneScripts(): void { + const now = this.now(); + for (const [digest, claim] of this.scriptClaims) { + if (claim.expiresAt <= now) this.scriptClaims.delete(digest); + } + } + + private issueScript(deviceId: string, workspaceId: string | undefined, name: string): string { + this.pruneScripts(); + if (this.scriptClaims.size >= MAX_SCRIPT_CLAIMS) { + const oldest = this.scriptClaims.keys().next().value as string | undefined; + if (oldest) this.scriptClaims.delete(oldest); + } + const token = `script_${randomBytes(32).toString("base64url")}`; + this.scriptClaims.set(createHash("sha256").update(token).digest("base64url"), { + deviceId, + ...(workspaceId ? { workspaceId } : {}), + name, + expiresAt: this.now() + SCRIPT_CLAIM_TTL_MS, + }); + return token; + } + + private consumeScript(deviceId: string, workspaceId: string | undefined, token: string): string { + this.pruneScripts(); + if (!SCRIPT_ID_PATTERN.test(token)) { + throw new AidenRemoteServiceError("handle_invalid", "This script selection is invalid.", 400); + } + const claim = this.scriptClaims.get(createHash("sha256").update(token).digest("base64url")); + if (!claim) throw new AidenRemoteServiceError("handle_expired", "This script selection expired. Refresh the inventory.", 410); + if (claim.deviceId !== deviceId) throw new AidenRemoteServiceError("handle_wrong_device", "This script selection belongs to another device.", 403); + if (claim.workspaceId !== workspaceId) throw new AidenRemoteServiceError("operation_stale", "This script selection belongs to another workspace.", 409); + return claim.name; + } + + private project(deviceId: string, task: ScheduledTask): AidenRemoteScheduledTaskProjection { + return { + id: task.id, + revision: scheduledTaskRevision(task), + name: [...task.name].slice(0, 120).join(""), + enabled: task.enabled, + schedule: task.cron, + timezone: task.timezone, + mode: task.mode, + permission: task.permission, + ...(task.workspaceId ? { workspaceId: task.workspaceId } : {}), + ...(task.providerId ? { providerId: task.providerId } : {}), + ...(task.model ? { modelId: task.model } : {}), + ...(task.mcpServerIds ? { mcpServerIds: [...task.mcpServerIds] } : {}), + ...(task.script ? { scriptId: this.issueScript(deviceId, task.workspaceId, task.script) } : {}), + ...(task.prompt ? { prompt: task.prompt } : {}), + notify: task.notify, + running: this.options.application.isRunning(task.id), + ...(timestamp(task.nextRunAt) ? { nextRunAt: timestamp(task.nextRunAt)! } : {}), + ...(timestamp(task.lastRunAt) ? { lastRunAt: timestamp(task.lastRunAt)! } : {}), + ...(task.lastResult ? { lastResult: task.lastResult } : {}), + createdAt: new Date(task.createdAt).toISOString(), + updatedAt: new Date(task.updatedAt).toISOString(), + }; + } + + private async mutation(deviceId: string, value: unknown): Promise { + const record = ownRecord(value); + if (!record || !exactKeys(record, + ["name", "schedule", "timezone", "mode", "permission", "confirmedForeground"], + ["workspaceId", "providerId", "modelId", "mcpServerIds", "scriptId", "prompt", "notify"], + ) || record.confirmedForeground !== true || !boundedString(record.name, 120) + || !boundedString(record.schedule, 500) || !boundedString(record.timezone, 120) + || (record.mode !== "llm" && record.mode !== "script") + || (record.permission !== "full" && record.permission !== "read-only") + || (record.workspaceId !== undefined && !boundedString(record.workspaceId, 128)) + || (record.notify !== undefined && typeof record.notify !== "boolean")) { + throw new AidenRemoteServiceError("permission_confirmation_required", "Creating or editing unattended work requires final foreground review.", 409); + } + const workspaceId = typeof record.workspaceId === "string" ? record.workspaceId : undefined; + const ids = boundedIds(record.mcpServerIds); + if (record.mcpServerIds !== undefined && ids === undefined) { + throw new AidenRemoteServiceError("invalid_request", "The selected MCP scope is invalid.", 400); + } + if (record.mode === "llm") { + if (!boundedString(record.prompt, 32 * 1_024)) { + throw new AidenRemoteServiceError("invalid_request", "Ask Aiden tasks require a bounded prompt.", 400); + } + const resolved = await this.options.models.resolve( + typeof record.providerId === "string" ? record.providerId : undefined, + typeof record.modelId === "string" ? record.modelId : undefined, + ); + return { + name: record.name.trim(), mode: "llm", cron: record.schedule.trim(), timezone: record.timezone.trim(), + permission: record.permission, prompt: record.prompt, providerId: resolved.providerId, + model: resolved.modelId, ...(workspaceId ? { workspaceId } : {}), + ...(ids ? { mcpServerIds: ids } : {}), ...(typeof record.notify === "boolean" ? { notify: record.notify } : {}), + }; + } + if (record.permission !== "full" || typeof record.scriptId !== "string") { + throw new AidenRemoteServiceError("invalid_request", "Script tasks require Full permission and a current script selection.", 400); + } + return { + name: record.name.trim(), mode: "script", cron: record.schedule.trim(), timezone: record.timezone.trim(), + permission: "full", script: this.consumeScript(deviceId, workspaceId, record.scriptId), + ...(workspaceId ? { workspaceId } : {}), ...(typeof record.notify === "boolean" ? { notify: record.notify } : {}), + }; + } + + private async executeIdempotent( + scope: { deviceId: string; route: string; resourceId: string; key: string }, + input: unknown, + action: () => Promise, + ): Promise { + if (!IDEMPOTENCY_KEY_PATTERN.test(scope.key)) { + throw new AidenRemoteServiceError("invalid_request", "Idempotency-Key is invalid.", 400); + } + if (!this.options.persistIdempotency) return this.idempotency.execute(scope, input, action); + let release!: () => void; + let reject!: (error: unknown) => void; + const admission = new Promise((resolve, rejectPromise) => { release = resolve; reject = rejectPromise; }); + const pending = this.idempotency.execute(scope, input, async () => { await admission; return action(); }); + try { + await this.options.persistIdempotency(this.idempotency.snapshot()); + release(); + } catch (error) { + reject(error); + await pending.catch(() => undefined); + throw new AidenRemoteServiceError("internal_error", "Aiden could not durably prepare this scheduled-task request.", 500); + } + let result: T | undefined; + let failure: unknown; + try { result = await pending; } catch (error) { failure = error; } + try { + await this.options.persistIdempotency(this.idempotency.snapshot()); + } catch { + throw new AidenRemoteServiceError("idempotency_in_flight", "The scheduled-task change may have completed, but Aiden could not record its outcome.", 409); + } + if (failure) throw failure; + return result!; + } + + async list(deviceId: string) { + return { tasks: (await this.options.application.list()).map((task) => this.project(deviceId, task)) }; + } + + async get(deviceId: string, taskId: string) { + try { return this.project(deviceId, await this.options.application.get(safeTaskId(taskId))); } + catch (error) { mapError(error); } + } + + async create(deviceId: string, key: string, value: unknown) { + const input = await this.mutation(deviceId, value); + try { + return await this.executeIdempotent( + { deviceId, route: "POST /scheduled-tasks", resourceId: "scheduled-tasks", key }, + value, + async () => this.project(deviceId, await this.options.application.save(input)), + ); + } catch (error) { mapError(error); } + } + + async update(deviceId: string, taskId: string, revision: string, value: unknown) { + const id = safeTaskId(taskId); + const input = { ...(await this.mutation(deviceId, value)), id }; + try { return this.project(deviceId, await this.options.application.save(input, { expectedRevision: revision })); } + catch (error) { mapError(error); } + } + + async remove(taskId: string, revision: string): Promise { + try { await this.options.application.remove(safeTaskId(taskId), revision); } + catch (error) { mapError(error); } + } + + async pause(deviceId: string, taskId: string, revision: string, key: string) { + const id = safeTaskId(taskId); + try { + return await this.executeIdempotent( + { deviceId, route: `POST /scheduled-tasks/${id}/pause`, resourceId: id, key }, + { revision }, + async () => this.project(deviceId, await this.options.application.pause(id, revision)), + ); + } catch (error) { mapError(error); } + } + + async resume(deviceId: string, taskId: string, revision: string, key: string) { + const id = safeTaskId(taskId); + try { + return await this.executeIdempotent( + { deviceId, route: `POST /scheduled-tasks/${id}/resume`, resourceId: id, key }, + { revision }, + async () => this.project(deviceId, await this.options.application.resume(id, revision)), + ); + } catch (error) { mapError(error); } + } + + async run(deviceId: string, taskId: string, key: string) { + const id = safeTaskId(taskId); + const runId = `run_${randomBytes(24).toString("base64url")}`; + try { + return await this.executeIdempotent( + { deviceId, route: `POST /scheduled-tasks/${id}/run`, resourceId: id, key }, + {}, + async () => { + await this.options.application.runNow(id, runId); + return { taskId: id, runId, status: "accepted" as const, acceptedAt: new Date(this.now()).toISOString() }; + }, + ); + } catch (error) { mapError(error); } + } + + async runs(taskId: string) { + try { return { runs: (await this.options.application.runs(safeTaskId(taskId))).map(mapRun) }; } + catch (error) { mapError(error); } + } + + preview(value: unknown) { + const record = ownRecord(value); + if (!record || !exactKeys(record, ["cron", "timezone"], ["count"]) + || !boundedString(record.cron, 500) || !boundedString(record.timezone, 120) + || (record.count !== undefined && (!Number.isInteger(record.count) || (record.count as number) < 1 || (record.count as number) > 20))) { + throw new AidenRemoteServiceError("invalid_request", "The schedule preview request is invalid.", 400); + } + try { + return { dates: this.options.application.preview(record.cron, record.timezone, typeof record.count === "number" ? record.count : 3).map((date) => new Date(date).toISOString()) }; + } catch (error) { mapError(error); } + } + + async scripts(deviceId: string, workspaceId?: string) { + try { + return { scripts: (await this.options.application.scripts(workspaceId)).map((name) => ({ id: this.issueScript(deviceId, workspaceId, name), name })) }; + } catch (error) { mapError(error); } + } + + async mcpServers() { + try { + const servers = await this.options.application.mcpServers(); + return { + servers: servers.slice(0, 4_000).map((server) => { + if (!boundedString(server.id, 256) || !boundedString(server.name, 256)) { + throw new Error("MCP inventory contains an invalid entry."); + } + return { id: server.id, name: server.name }; + }), + }; + } catch (error) { mapError(error); } + } + + async settings() { + const current = await this.options.application.settings(); + return { revision: current.revision, ...current.value }; + } + + async updateSettings(revision: string, value: unknown) { + const record = ownRecord(value); + if (!record || record.confirmedForeground !== true || !exactKeys(record, ["confirmedForeground"], ["enabled", "defaultMode", "defaultPermission", "defaultMcpEnabled", "defaultNotify", "defaultTimezone"]) || Object.keys(record).length < 2) { + throw new AidenRemoteServiceError("permission_confirmation_required", "Scheduled-task settings require explicit foreground confirmation.", 409); + } + if ((record.enabled !== undefined && typeof record.enabled !== "boolean") + || (record.defaultMode !== undefined && record.defaultMode !== "llm" && record.defaultMode !== "script") + || (record.defaultPermission !== undefined && record.defaultPermission !== "read-only" && record.defaultPermission !== "full") + || (record.defaultMcpEnabled !== undefined && typeof record.defaultMcpEnabled !== "boolean") + || (record.defaultNotify !== undefined && typeof record.defaultNotify !== "boolean") + || (record.defaultTimezone !== undefined && !boundedString(record.defaultTimezone, 120))) { + throw new AidenRemoteServiceError("invalid_request", "One or more scheduled-task settings are invalid.", 400); + } + const { confirmedForeground: _confirmed, ...patch } = record; + try { + const saved = await this.options.application.updateSettings(revision, patch); + return { revision: saved.revision, ...saved.value }; + } catch (error) { mapError(error); } + } +} diff --git a/main/services/aiden-remote-service-main.ts b/main/services/aiden-remote-service-main.ts new file mode 100644 index 00000000..a5b8d83b --- /dev/null +++ b/main/services/aiden-remote-service-main.ts @@ -0,0 +1,407 @@ +import os from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { app, ipcMain, logger } from "../platform.js"; +import { AidenRemoteApprovedRootService } from "./aiden-remote-approved-roots.js"; +import { DataStore } from "./data-store.js"; +import { + AidenRemoteService, + DnsSdAidenRemoteBonjourPublisher, + type AidenRemoteServiceLogEntry, +} from "./aiden-remote-service.js"; +import { + AidenRemoteStateRegistry, + createDefaultAidenRemoteState, + defaultAidenRemoteDisplayName, + parseAidenRemoteStateDocument, + type AidenRemoteStateDocument, +} from "./aiden-remote-state.js"; +import { + AidenRemoteTailscaleController, + createSystemTailscaleCommandRunner, +} from "./aiden-remote-tailscale.js"; +import { loadOrCreateAidenRemoteTlsIdentity } from "./aiden-remote-tls-identity.js"; +import { AidenRemoteWorkspaceBrowserService } from "./aiden-remote-workspace-browser.js"; +import { AidenRemoteWorkspaceService } from "./aiden-remote-workspaces.js"; +import { workspaceApplicationService } from "./workspace-application-service-main.js"; +import { + AidenIdempotencyLedger, + MAX_DURABLE_LEDGER_SNAPSHOT_BYTES, + type AidenIdempotencySnapshot, +} from "./aiden-remote-operation-contract.js"; +import { AidenRemoteChatService } from "./aiden-remote-chats.js"; +import { AidenRemoteModelService } from "./aiden-remote-models.js"; +import { + AidenRemoteStreamService, + MAX_AIDEN_REMOTE_STREAM_SNAPSHOT_BYTES, + normalizeAidenRemoteStreamSnapshot, + removeRevokedDeviceStreams, + type AidenRemotePendingApproval, + type AidenRemoteStreamSnapshot, +} from "./aiden-remote-streams.js"; +import { revokeAidenRemoteRuntimeDevice } from "./aiden-remote-revocation.js"; +import { chatApplicationService } from "./chat-application-service-main.js"; +import { startGenerationAndMaybeTitle } from "./chat-generation-start.js"; +import { chatStore } from "./chat-store.js"; +import { chatTitleService } from "./chat-title.js"; +import { configStore } from "./config-store.js"; +import { llmClient } from "./llm-client.js"; +import { listConfiguredProviders } from "./provider-list-main.js"; +import { AidenRemoteFileService } from "./aiden-remote-files.js"; +import { AidenRemoteWorkspaceOwnerRegistry } from "./aiden-remote-workspace-owners.js"; +import { workspaceEnvironmentApplicationService } from "./workspace-environment-application-service-main.js"; +import { workspaceWorktreeApplicationService } from "./workspace-worktree-application-service-main.js"; +import { AidenRemoteGitService } from "./aiden-remote-git.js"; +import { + gitBranches, + gitCheckout, + gitCommit, + gitCompare, + gitComparisonDiff, + gitCreateBranch, + gitDiff, + gitPush, + gitPushCapability, + gitReview, + gitWorktrees, +} from "./git.js"; +import { AidenRemoteScheduleService } from "./aiden-remote-schedules.js"; +import { usageStore } from "./usage-store.js"; +import { scheduledTaskApplicationService } from "./scheduled-task-application-service-main.js"; + +const STATE_FILE = "aiden-remote-v1.json"; +const OPERATIONS_FILE = "aiden-remote-operations-v1.json"; +const STREAMS_FILE = "aiden-remote-streams-v1.json"; +const MAX_STATE_BYTES = 512 * 1_024; +const execFileAsync = promisify(execFile); + +async function macComputerName(): Promise { + try { + const { stdout } = await execFileAsync( + "/usr/sbin/scutil", + ["--get", "ComputerName"], + { timeout: 1_000, maxBuffer: 4_096, encoding: "utf8" }, + ); + return stdout; + } catch { + return os.hostname(); + } +} + +function normalizeIdempotency(value: unknown): AidenIdempotencySnapshot { + return new AidenIdempotencyLedger(value as AidenIdempotencySnapshot).snapshot(); +} + +function safeIdempotency(value: unknown): boolean { + try { + normalizeIdempotency(value); + return true; + } catch { + return false; + } +} + +function writeRemoteLog(entry: AidenRemoteServiceLogEntry): void { + const details = entry.details ?? {}; + if (entry.level === "error") logger.error("aiden-remote", entry.event, details); + else if (entry.level === "warn") logger.warn("aiden-remote", entry.event, details); + else logger.info("aiden-remote", entry.event, details); +} + +export interface AidenRemoteRuntime { + service: AidenRemoteService; + state: AidenRemoteStateRegistry; + approvedRoots: AidenRemoteApprovedRootService; + revokeDevice(deviceId: string): Promise; + pendingApprovalForChat(chatId: string): AidenRemotePendingApproval | null; + respondApprovalFromHost( + chatId: string, + approvalId: string, + decision: "allow" | "deny", + ): boolean; +} + +let runtimePromise: Promise | null = null; + +async function createRuntime(): Promise { + const userData = app.getPath("userData"); + const hostname = os.hostname(); + const defaultDisplayName = defaultAidenRemoteDisplayName(await macComputerName()); + const store = new DataStore( + STATE_FILE, + createDefaultAidenRemoteState(undefined, defaultDisplayName), + () => userData, + { + maxBytes: MAX_STATE_BYTES, + fileMode: 0o600, + normalize: (value) => parseAidenRemoteStateDocument(value, defaultDisplayName), + isSafe: (value) => { + try { + parseAidenRemoteStateDocument(value, defaultDisplayName); + return true; + } catch { + return false; + } + }, + rejectCorruptWrite: true, + rejectUnsafeWrite: true, + }, + ); + const state = new AidenRemoteStateRegistry({ + load: () => store.load(), + needsSaveAfterLoad: async () => { + const contents = await store.loadedDiskContents(); + if (contents === null) return true; + try { + const raw = JSON.parse(contents.toString("utf8")) as unknown; + return !raw || typeof raw !== "object" || Array.isArray(raw) + || !("displayName" in raw) || !("lanPortCommitted" in raw); + } catch { + return false; + } + }, + save: async (document) => { + await store.save(document); + ipcMain.broadcast("remote:changed", {}); + }, + }); + const operationStore = new DataStore( + OPERATIONS_FILE, + new AidenIdempotencyLedger().snapshot(), + () => userData, + { + maxBytes: MAX_DURABLE_LEDGER_SNAPSHOT_BYTES, + fileMode: 0o600, + normalize: normalizeIdempotency, + isSafe: safeIdempotency, + rejectCorruptWrite: true, + rejectUnsafeWrite: true, + }, + ); + const streamStore = new DataStore( + STREAMS_FILE, + { version: 1, streams: [] }, + () => userData, + { + maxBytes: MAX_AIDEN_REMOTE_STREAM_SNAPSHOT_BYTES, + fileMode: 0o600, + normalize: normalizeAidenRemoteStreamSnapshot, + isSafe: (value) => { + try { + normalizeAidenRemoteStreamSnapshot(value); + return true; + } catch { + return false; + } + }, + rejectCorruptWrite: true, + rejectUnsafeWrite: true, + }, + ); + const tailscale = new AidenRemoteTailscaleController( + await createSystemTailscaleCommandRunner(), + { + outcomeStore: { + begin: (outcome) => state.beginTailscalePendingOutcome(outcome), + snapshot: async () => (await state.snapshot()).tailscalePendingOutcome, + commit: (ownership) => state.commitTailscaleOutcome(ownership), + clear: () => state.clearTailscalePendingOutcome(), + }, + }, + ); + let workspaceApi: + | Promise<{ + instanceId: string; + workspaces: AidenRemoteWorkspaceService; + workspaceBrowser: AidenRemoteWorkspaceBrowserService; + chats: AidenRemoteChatService; + models: AidenRemoteModelService; + streams: AidenRemoteStreamService; + files: AidenRemoteFileService; + git: AidenRemoteGitService; + schedules: AidenRemoteScheduleService; + usage: typeof usageStore; + }> + | undefined; + let workspaceApiInstanceId: string | undefined; + let activeStreams: AidenRemoteStreamService | undefined; + let activeChats: AidenRemoteChatService | undefined; + const workspaceOwners = new AidenRemoteWorkspaceOwnerRegistry(); + const service = new AidenRemoteService({ + state, + appVersion: app.getVersion(), + hostname, + tailscale, + bonjour: new DnsSdAidenRemoteBonjourPublisher(writeRemoteLog), + notifyPairingChanged: () => ipcMain.broadcast("remote:changed", {}), + workspaceApi: async (instanceId) => { + if (!workspaceApi || workspaceApiInstanceId !== instanceId) { + workspaceApiInstanceId = instanceId; + workspaceApi = (async () => { + const idempotency = new AidenIdempotencyLedger( + await operationStore.load(), + ); + await operationStore.save(idempotency.snapshot()); + const workspaceBrowser = new AidenRemoteWorkspaceBrowserService({ + instanceId, + state, + }); + const models = new AidenRemoteModelService({ + listProviders: listConfiguredProviders, + getSettings: () => configStore.getSettings(), + }); + const loadedStreamSnapshot = normalizeAidenRemoteStreamSnapshot(await streamStore.load()); + const revokedDeviceIds = new Set( + (await state.snapshot()).devices + .filter(({ revokedAt }) => revokedAt !== undefined) + .map(({ id }) => id), + ); + const streamSnapshot = removeRevokedDeviceStreams( + loadedStreamSnapshot, + revokedDeviceIds, + ); + if (streamSnapshot.streams.length !== loadedStreamSnapshot.streams.length) { + await streamStore.save(streamSnapshot); + } + const streams = new AidenRemoteStreamService({ + now: Date.now, + cancel: (streamId, ownerDocumentId) => + llmClient.cancel(streamId, "user_stop", ownerDocumentId), + approve: (approvalId, decision, ownerDocumentId) => + llmClient.approve(approvalId, decision, ownerDocumentId), + notifyChatChanged: () => ipcMain.broadcast("chats:changed", {}), + notifyApprovalChanged: (chatId) => + ipcMain.broadcast("remote:approval-changed", { chatId }), + snapshot: streamSnapshot, + persist: (snapshot) => streamStore.save(snapshot), + idempotency, + persistIdempotency: (snapshot) => operationStore.save(snapshot), + onPersistenceError: (error) => + logger.error("aiden-remote", "Could not persist the remote stream journal.", error), + }); + activeStreams = streams; + const chats = new AidenRemoteChatService({ + application: chatApplicationService, + chatStore, + generation: { + beginChatTurn: (chatId, turnId, ownerId) => + llmClient.beginChatTurn(chatId, turnId, ownerId), + start: (streamId, params, owner, options) => + startGenerationAndMaybeTitle( + { + start: (id, input) => llmClient.start(id, input, owner, options), + startTitle: (input) => chatTitleService.startForFirstTurn(input), + }, + streamId, + params, + ), + }, + streams, + models, + idempotency, + persistIdempotency: (snapshot) => operationStore.save(snapshot), + notifyChanged: () => ipcMain.broadcast("chats:changed", {}), + isTitlePending: (chatId) => chatTitleService.isFirstTurnPending(chatId), + }); + activeChats = chats; + const files = new AidenRemoteFileService({ + instanceId, + application: workspaceEnvironmentApplicationService, + owners: workspaceOwners, + }); + const git = new AidenRemoteGitService({ + application: workspaceEnvironmentApplicationService, + owners: workspaceOwners, + git: { + review: gitReview, + diff: gitDiff, + branches: gitBranches, + checkout: gitCheckout, + createBranch: gitCreateBranch, + commit: gitCommit, + pushCapability: gitPushCapability, + push: gitPush, + compare: gitCompare, + comparisonDiff: gitComparisonDiff, + worktrees: gitWorktrees, + }, + worktrees: workspaceWorktreeApplicationService, + listWorkspaces: () => configStore.listWorkspaces(), + idempotency, + persistIdempotency: (snapshot) => operationStore.save(snapshot), + }); + const schedules = new AidenRemoteScheduleService({ + application: scheduledTaskApplicationService, + models, + idempotency, + persistIdempotency: (snapshot) => operationStore.save(snapshot), + }); + return { + instanceId, + workspaceBrowser, + chats, + models, + streams, + files, + git, + schedules, + usage: usageStore, + settle: () => streams.settlePersistence(), + workspaces: new AidenRemoteWorkspaceService({ + application: workspaceApplicationService, + browser: workspaceBrowser, + idempotency, + persistIdempotency: (snapshot) => operationStore.save(snapshot), + notifyChanged: () => ipcMain.broadcast("workspaces:changed", {}), + }), + }; + })(); + } + return workspaceApi; + }, + loadTlsIdentity: () => loadOrCreateAidenRemoteTlsIdentity({ + directory: path.join(userData, "aiden-remote-identity"), + hostnames: [hostname], + }), + log: writeRemoteLog, + }); + return { + service, + state, + approvedRoots: new AidenRemoteApprovedRootService(state), + revokeDevice: (deviceId) => revokeAidenRemoteRuntimeDevice({ + state, + streams: activeStreams, + chats: activeChats, + workspaceOwners, + }, deviceId), + pendingApprovalForChat: (chatId) => activeStreams?.pendingApprovalForChat(chatId) ?? null, + respondApprovalFromHost: (chatId, approvalId, decision) => + activeStreams?.respondApprovalFromHost(chatId, approvalId, decision) ?? false, + }; +} + +export function getAidenRemoteService(): Promise { + runtimePromise ??= createRuntime(); + return runtimePromise.then((runtime) => runtime.service); +} + +export function getAidenRemoteRuntime(): Promise { + runtimePromise ??= createRuntime(); + return runtimePromise; +} + +export async function initializeAidenRemoteService(): Promise { + const service = await getAidenRemoteService(); + await service.initialize(); +} + +export function stopAidenRemoteService(): void { + void runtimePromise?.then((runtime) => runtime.service.stop()); +} + +export async function stopAidenRemoteServiceAndSettle(): Promise { + const runtime = await runtimePromise; + await runtime?.service.stopAndSettle(); +} diff --git a/main/services/aiden-remote-service.test.ts b/main/services/aiden-remote-service.test.ts new file mode 100644 index 00000000..661aff2b --- /dev/null +++ b/main/services/aiden-remote-service.test.ts @@ -0,0 +1,1328 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createConnection, createServer, type Socket } from "node:net"; +import * as fs from "node:fs/promises"; +import http from "node:http"; +import https from "node:https"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + AidenRemotePortInUseError, + AidenRemoteService, + 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 type { AidenTailscaleStatus } from "./aiden-remote-tailscale-route.js"; +import { revokeAidenRemoteRuntimeDevice } from "./aiden-remote-revocation.js"; + +async function canBind(port: number): Promise { + const server = createServer(); + return new Promise((resolve) => { + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); +} + +async function availablePortPair(): Promise { + for (let port = 51_000; port < 55_000; port += 2) { + if (await canBind(port) && await canBind(port + 1)) return port; + } + throw new Error("No test port pair was available."); +} + +async function availablePortPairs(count: number): Promise { + const ports: number[] = []; + for (let port = 51_000; port < 55_000 && ports.length < count; port += 2) { + if (await canBind(port) && await canBind(port + 1)) ports.push(port); + } + if (ports.length !== count) throw new Error(`Only ${ports.length} test port pairs were available.`); + return ports; +} + +async function availableLegacyOddPort(): Promise { + for (let port = 53_001; port < 54_999; port += 2) { + if (await canBind(port) && await canBind(port + 1)) return port; + } + throw new Error("No legacy odd test port pair was available."); +} + +async function reservePort( + port: number, + host: "::" | "127.0.0.1" = "127.0.0.1", +): Promise> { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, resolve); + }); + return server; +} + +async function closeReservedPort(server: ReturnType): Promise { + await new Promise((resolve) => server.close(() => resolve())); +} + +async function heldConnection(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection({ host: "127.0.0.1", port }); + socket.once("connect", () => resolve(socket)); + socket.once("error", reject); + }); +} + +function socketClosed(socket: Socket): Promise { + return new Promise((resolve) => { + socket.on("error", () => undefined); + socket.once("close", () => resolve()); + }); +} + +async function within(promise: Promise, milliseconds = 1_000): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("operation timed out")), milliseconds); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +interface FixtureOptions { + mode?: "lan" | "tailscale" | "both"; + lanPort?: number; + initial?: (state: AidenRemoteStateDocument) => void; + portCandidates?: readonly number[]; + failSaveWhen?: (document: AidenRemoteStateDocument) => boolean; + failBonjourStart?: boolean; + tailscaleServeStatus?: AidenTailscaleStatus; + enableTailscaleTakeover?: boolean; + tailscaleAssessment?: { + state: "available" | "owned" | "other_aiden_live" | "other_aiden_stale" | "unrelated_conflict" | "funnel_conflict" | "unavailable"; + errorCode?: "not_installed" | "not_connected" | "https_unavailable" | "status_unavailable"; + }; + afterListenerBound?: (input: { + transport: "lan" | "tailscale"; + port: number; + }) => Promise; +} + +async function fixture( + modeOrOptions: "lan" | "tailscale" | "both" | FixtureOptions = "lan", +) { + const options = typeof modeOrOptions === "string" + ? { mode: modeOrOptions } + : modeOrOptions; + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-remote-service-")); + const initial = createDefaultAidenRemoteState(); + initial.connectionMode = options.mode ?? "lan"; + initial.lanPort = options.lanPort ?? await availablePortPair(); + options.initial?.(initial); + let persisted = structuredClone(initial); + const state = new AidenRemoteStateRegistry({ + load: async () => structuredClone(persisted), + save: async (document) => { + if (options.failSaveWhen?.(document)) throw new Error("disk unavailable"); + persisted = structuredClone(document); + }, + }); + const bonjour: { + starts: number; + stops: number; + inputs: Array<{ instanceId: string; displayName: string; port: number }>; + failure?: (error: Error) => void; + start( + input: { instanceId: string; displayName: string; port: number }, + onUnexpectedFailure: (error: Error) => void, + ): Promise; + stop(): void; + } = { + starts: 0, + stops: 0, + inputs: [], + start: async (input, onUnexpectedFailure) => { + bonjour.starts += 1; + bonjour.inputs.push(input); + if (options.failBonjourStart) throw new Error("dns-sd unavailable"); + bonjour.failure = onUnexpectedFailure; + }, + stop: () => { + bonjour.stops += 1; + bonjour.failure = undefined; + }, + }; + const tailscale = { + connects: 0, + disconnects: 0, + reconciles: 0, + targets: [] as string[], + disconnectTargets: [] as string[], + status: async () => ({ + installed: true, + dnsName: "aiden.tailnet.ts.net", + ...(options.tailscaleServeStatus + ? { serveStatus: options.tailscaleServeStatus } + : {}), + }), + connect: async ( + target: string, + _ownership?: { path: "/api/aiden/v1"; target: string }, + persistOwnership?: (ownership: { path: "/api/aiden/v1"; target: string }) => Promise, + ) => { + tailscale.connects += 1; + tailscale.targets.push(target); + const ownership = { path: "/api/aiden/v1" as const, target }; + await persistOwnership?.(ownership); + return ownership; + }, + disconnect: async ( + target: string, + _ownership?: { path: "/api/aiden/v1"; target: string }, + clearOwnership?: () => Promise, + ) => { + tailscale.disconnects += 1; + tailscale.disconnectTargets.push(target); + await clearOwnership?.(); + }, + reconcilePendingOutcome: async () => { + tailscale.reconciles += 1; + const pending = (await state.snapshot()).tailscalePendingOutcome; + if (!pending) throw new Error("tailscale_reconciliation_unavailable"); + await state.commitTailscaleOutcome( + pending.operation === "disconnect" + ? undefined + : { path: "/api/aiden/v1", target: pending.target }, + ); + return pending.operation === "disconnect" ? "disconnected" as const : "connected" as const; + }, + ...(options.enableTailscaleTakeover + ? { + assessRoute: async () => ({ state: "other_aiden_stale" as const }), + reviewTakeover: async () => ({ token: "A".repeat(32), expiresAt: Date.now() + 30_000 }), + takeOver: async ( + target: string, + token: string, + persistOwnership: (ownership: { path: "/api/aiden/v1"; target: string }) => Promise, + ) => { + assert.equal(token, "A".repeat(32)); + const ownership = { path: "/api/aiden/v1" as const, target }; + await persistOwnership(ownership); + return ownership; + }, + } + : {}), + ...(options.tailscaleAssessment + ? { assessRoute: async () => options.tailscaleAssessment! } + : {}), + }; + let identityLoads = 0; + const service = new AidenRemoteService({ + state, + appVersion: "0.30.0", + hostname: "Aiden-Test", + bonjour, + tailscale, + resolveTlsEndpointPin: async () => `sha256/${Buffer.alloc(32, 9).toString("base64")}`, + loadTlsIdentity: async () => { + identityLoads += 1; + return loadOrCreateAidenRemoteTlsIdentity({ + directory: path.join(directory, "identity"), + hostnames: ["aiden-test", "aiden-test.local"], + }); + }, + ...(options.portCandidates === undefined + ? {} + : { portCandidates: () => options.portCandidates ?? [] }), + ...(options.afterListenerBound === undefined + ? {} + : { afterListenerBound: options.afterListenerBound }), + }); + return { + service, + state, + bonjour, + tailscale, + identityLoads: () => identityLoads, + persisted: () => structuredClone(persisted), + directory, + cleanup: async () => { + await service.stopAndSettle(); + await fs.rm(directory, { recursive: true, force: true }); + }, + }; +} + +async function insecureHealth(port: number): Promise<{ status: number; body: unknown }> { + return new Promise((resolve, reject) => { + const request = https.get( + `https://127.0.0.1:${port}/api/aiden/v1/health`, + { rejectUnauthorized: false, timeout: 3_000 }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + response.on("end", () => { + try { + resolve({ + status: response.statusCode ?? 0, + body: JSON.parse(Buffer.concat(chunks).toString("utf8")), + }); + } catch (error) { + reject(error); + } + }); + }, + ); + request.once("error", reject); + }); +} + +async function plainHealth(port: number): Promise { + return new Promise((resolve, reject) => { + const request = http.get( + `http://127.0.0.1:${port}/api/aiden/v1/health`, + { timeout: 1_000 }, + (response) => { + response.resume(); + response.once("end", () => resolve(response.statusCode ?? 0)); + }, + ); + request.once("error", reject); + }); +} + +async function healthWithAgent( + port: number, + transport: "lan" | "tailscale", + agent: http.Agent | https.Agent, +): Promise { + await new Promise((resolve, reject) => { + const request = (transport === "lan" ? https : http).get({ + host: "127.0.0.1", + port, + path: "/api/aiden/v1/health", + agent, + ...(transport === "lan" ? { rejectUnauthorized: false } : {}), + }, (response) => { + response.resume(); + response.once("end", resolve); + }); + request.once("error", reject); + }); +} + +async function insecureJson( + port: number, + requestPath: string, + options: { + method?: string; + headers?: Record; + body?: unknown; + } = {}, +): Promise<{ status: number; body: T }> { + const encodedBody = options.body === undefined + ? undefined + : Buffer.from(JSON.stringify(options.body)); + return new Promise((resolve, reject) => { + const request = https.request({ + host: "127.0.0.1", + port, + path: requestPath, + method: options.method ?? "GET", + rejectUnauthorized: false, + timeout: 3_000, + headers: { + ...options.headers, + ...(encodedBody === undefined ? {} : { + "content-type": "application/json", + "content-length": String(encodedBody.length), + }), + }, + }, (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + response.on("end", () => { + try { + resolve({ + status: response.statusCode ?? 0, + body: JSON.parse(Buffer.concat(chunks).toString("utf8")) as T, + }); + } catch (error) { + reject(error); + } + }); + }); + request.once("error", reject); + if (encodedBody !== undefined) request.write(encodedBody); + request.end(); + }); +} + +test("fresh endpoint candidates are bounded, unique, and reserve a loopback companion", () => { + const candidates = aidenRemotePortCandidates(52_000); + assert.equal(candidates[0], 52_000); + assert.equal(candidates.length, 64); + assert.equal(new Set(candidates).size, candidates.length); + assert.equal(candidates.every((port) => port > 0 && port < 65_535), true); + assert.equal(candidates.every((port) => port + 1 <= 65_535), true); + assert.equal(candidates.every((port) => port % 2 === 0), true); + assert.notEqual(aidenRemotePortCandidates(52_001)[0], 52_001); +}); + +test("a fresh profile moves to the next complete port pair and advertises only the committed port", async () => { + const [blockedPort, fallbackPort] = await availablePortPairs(2); + const blocker = await reservePort(blockedPort, "::"); + const app = await fixture({ + mode: "both", + lanPort: blockedPort, + portCandidates: [blockedPort, fallbackPort], + }); + try { + await app.service.setEnabled(true); + assert.equal(app.persisted().lanPort, fallbackPort); + assert.equal(app.persisted().lanPortCommitted, true); + assert.deepEqual(app.bonjour.inputs.map((input) => input.port), [fallbackPort]); + assert.deepEqual(await insecureHealth(fallbackPort), { + status: 200, + body: { ok: true, protocolVersion: 1 }, + }); + } finally { + await app.cleanup(); + await closeReservedPort(blocker); + } +}); + +test("a failed loopback companion bind rolls back the partial LAN listener before retrying", async () => { + const [partialPort, fallbackPort] = await availablePortPairs(2); + const blocker = await reservePort(partialPort + 1); + let heldSocket: Socket | undefined; + let heldSocketClosed: Promise | undefined; + const app = await fixture({ + mode: "both", + lanPort: partialPort, + portCandidates: [partialPort, fallbackPort], + afterListenerBound: async ({ transport, port }) => { + if (transport !== "lan" || port !== partialPort) return; + heldSocket = await heldConnection(port); + heldSocketClosed = socketClosed(heldSocket); + }, + }); + try { + await within(app.service.setEnabled(true)); + if (heldSocketClosed) await within(heldSocketClosed); + assert.equal(heldSocket?.destroyed, true); + assert.equal(app.persisted().lanPort, fallbackPort); + assert.equal(await canBind(partialPort), true, "the first candidate LAN listener was released"); + assert.deepEqual(app.bonjour.inputs.map((input) => input.port), [fallbackPort]); + } finally { + await app.cleanup(); + await closeReservedPort(blocker); + } +}); + +test("single-transport profiles lease the inactive half and retain the pair when enabling both", async () => { + const [lanPort, lanFallback, tailscalePort, tailscaleFallback] = await availablePortPairs(4); + const loopbackBlocker = await reservePort(lanPort + 1); + const lanBlocker = await reservePort(tailscalePort, "::"); + const lan = await fixture({ + mode: "lan", + lanPort, + portCandidates: [lanPort, lanFallback], + }); + const tailscale = await fixture({ + mode: "tailscale", + lanPort: tailscalePort, + portCandidates: [tailscalePort, tailscaleFallback], + }); + try { + await lan.service.setEnabled(true); + await tailscale.service.setEnabled(true); + assert.equal(lan.persisted().lanPort, lanFallback); + assert.equal(tailscale.persisted().lanPort, tailscaleFallback); + + await closeReservedPort(loopbackBlocker); + await closeReservedPort(lanBlocker); + await lan.service.setConnectionMode("both"); + await tailscale.service.setConnectionMode("both"); + assert.equal(lan.persisted().lanPort, lanFallback); + assert.equal(tailscale.persisted().lanPort, tailscaleFallback); + assert.equal(lan.identityLoads(), 1, "mode changes retain the original listener pair"); + assert.equal(tailscale.identityLoads(), 1, "mode changes retain the original listener pair"); + } finally { + await Promise.all([lan.cleanup(), tailscale.cleanup()]); + if (loopbackBlocker.listening) await closeReservedPort(loopbackBlocker); + if (lanBlocker.listening) await closeReservedPort(lanBlocker); + } +}); + +test("inactive transports retain their lease while rejecting connections at the socket boundary", async () => { + const lan = await fixture("lan"); + const tailscale = await fixture("tailscale"); + try { + await lan.service.setEnabled(true); + await tailscale.service.setEnabled(true); + await assert.rejects(plainHealth(lan.persisted().lanPort + 1)); + await assert.rejects(insecureHealth(tailscale.persisted().lanPort)); + assert.equal((await lan.service.status()).running, true); + assert.equal((await tailscale.service.status()).running, true); + } finally { + await Promise.all([lan.cleanup(), tailscale.cleanup()]); + } +}); + +test("mode transitions terminate keep-alive sockets accepted by the newly inactive transport", async () => { + const app = await fixture("both"); + const lanAgent = new https.Agent({ keepAlive: true, maxSockets: 1, rejectUnauthorized: false }); + const tailscaleAgent = new http.Agent({ keepAlive: true, maxSockets: 1 }); + try { + await app.service.setEnabled(true); + await healthWithAgent(app.persisted().lanPort, "lan", lanAgent); + await healthWithAgent(app.persisted().lanPort + 1, "tailscale", tailscaleAgent); + const lanSocket = Object.values(lanAgent.freeSockets).flat()[0]; + const tailscaleSocket = Object.values(tailscaleAgent.freeSockets).flat()[0]; + assert.ok(lanSocket && !lanSocket.destroyed); + assert.ok(tailscaleSocket && !tailscaleSocket.destroyed); + + const lanClosed = once(lanSocket, "close"); + await app.service.setConnectionMode("tailscale"); + await lanClosed; + assert.equal(lanSocket.destroyed, true); + assert.equal(tailscaleSocket.destroyed, false); + + const tailscaleClosed = once(tailscaleSocket, "close"); + await app.service.setConnectionMode("lan"); + await tailscaleClosed; + assert.equal(tailscaleSocket.destroyed, true); + } finally { + lanAgent.destroy(); + tailscaleAgent.destroy(); + await app.cleanup(); + } +}); + +test("LAN-only and Tailscale-only profiles racing for one pair cannot split its ownership", async () => { + const [firstPort, secondPort] = await availablePortPairs(2); + const lan = await fixture({ + mode: "lan", + lanPort: firstPort, + portCandidates: [firstPort, secondPort], + }); + const tailscale = await fixture({ + mode: "tailscale", + lanPort: firstPort, + portCandidates: [firstPort, secondPort], + }); + try { + await Promise.all([lan.service.setEnabled(true), tailscale.service.setEnabled(true)]); + assert.deepEqual( + new Set([lan.persisted().lanPort, tailscale.persisted().lanPort]), + new Set([firstPort, secondPort]), + ); + } finally { + await Promise.all([lan.cleanup(), tailscale.cleanup()]); + } +}); + +test("an enabled mode transition never releases its pair to a competing fresh profile", async () => { + const [ownedPort, fallbackPort] = await availablePortPairs(2); + const incumbent = await fixture({ + mode: "lan", + lanPort: ownedPort, + portCandidates: [ownedPort, fallbackPort], + }); + const competitor = await fixture({ + mode: "tailscale", + lanPort: ownedPort, + portCandidates: [ownedPort, fallbackPort], + }); + try { + await incumbent.service.setEnabled(true); + await incumbent.service.setConnectionMode("both"); + await competitor.service.setEnabled(true); + assert.equal(incumbent.persisted().lanPort, ownedPort); + assert.equal(competitor.persisted().lanPort, fallbackPort); + assert.equal(incumbent.identityLoads(), 1); + } finally { + await Promise.all([incumbent.cleanup(), competitor.cleanup()]); + } +}); + +test("a canonical Serve target owned elsewhere is excluded from fresh allocation", async () => { + const [reservedPort, fallbackPort] = await availablePortPairs(2); + const app = await fixture({ + mode: "both", + lanPort: reservedPort, + portCandidates: [reservedPort, fallbackPort], + tailscaleServeStatus: { + TCP: { "443": { HTTPS: true } }, + Web: { + "other-mac.tailnet.ts.net:443": { + Handlers: { + "/api/aiden/v1": { + Proxy: `http://127.0.0.1:${reservedPort + 1}/api/aiden/v1`, + }, + }, + }, + }, + }, + }); + try { + await app.service.setEnabled(true); + assert.equal(app.persisted().lanPort, fallbackPort); + assert.deepEqual(app.bonjour.inputs.map((input) => input.port), [fallbackPort]); + } finally { + await app.cleanup(); + } +}); + +test("all exact Aiden Serve targets are reserved even with ambiguous authorities and either pair half", async () => { + const [firstPort, secondPort, fallbackPort] = await availablePortPairs(3); + const app = await fixture({ + mode: "both", + lanPort: firstPort, + portCandidates: [firstPort, secondPort, fallbackPort], + tailscaleServeStatus: { + Web: { + "malformed authority": { + Handlers: { + "/api/aiden/v1": { + Proxy: `http://localhost:${firstPort + 1}/api/aiden/v1`, + }, + }, + }, + "second.tailnet.ts.net:443": { + Handlers: { + "/api/aiden/v1": { Proxy: `http://127.0.0.1:${secondPort}` }, + }, + }, + }, + }, + }); + try { + await app.service.setEnabled(true); + assert.equal(app.persisted().lanPort, fallbackPort); + } finally { + await app.cleanup(); + } +}); + +test("two fresh profiles racing for the same candidates commit distinct endpoints", async () => { + const [firstPort, secondPort] = await availablePortPairs(2); + const first = await fixture({ + mode: "both", + lanPort: firstPort, + portCandidates: [firstPort, secondPort], + }); + const second = await fixture({ + mode: "both", + lanPort: firstPort, + portCandidates: [firstPort, secondPort], + }); + try { + await Promise.all([ + first.service.setEnabled(true), + second.service.setEnabled(true), + ]); + assert.deepEqual( + new Set([first.persisted().lanPort, second.persisted().lanPort]), + new Set([firstPort, secondPort]), + ); + assert.equal(first.persisted().lanPortCommitted, true); + assert.equal(second.persisted().lanPortCommitted, true); + } finally { + await Promise.all([first.cleanup(), second.cleanup()]); + } +}); + +test("a committed endpoint remains stable across restart even when alternatives are offered", async () => { + const [committedPort, alternatePort] = await availablePortPairs(2); + const first = await fixture({ + mode: "both", + lanPort: committedPort, + portCandidates: [committedPort, alternatePort], + }); + try { + await first.service.setEnabled(true); + const committed = first.persisted(); + assert.equal(committed.lanPort, committedPort); + assert.equal(committed.lanPortCommitted, true); + await first.service.setEnabled(false); + + const restarted = await fixture({ + mode: "both", + lanPort: alternatePort, + portCandidates: [alternatePort], + initial: (state) => Object.assign(state, committed, { enabled: false }), + }); + try { + await restarted.service.setEnabled(true); + assert.equal(restarted.persisted().lanPort, committedPort); + assert.deepEqual(restarted.bonjour.inputs.map((input) => input.port), [committedPort]); + } finally { + await restarted.cleanup(); + } + } finally { + await first.cleanup(); + } +}); + +test("committed legacy port 65535 remains restart-compatible in every connection mode", async (context) => { + if (!await canBind(65_535) || !await canBind(49_221)) { + context.skip("legacy endpoint ports are occupied on this host"); + return; + } + for (const mode of ["lan", "tailscale", "both"] as const) { + const app = await fixture({ + mode, + lanPort: 65_535, + initial: (state) => { + state.lanPortCommitted = true; + }, + }); + try { + await app.service.setEnabled(true); + assert.equal(app.persisted().lanPort, 65_535); + assert.equal((await app.service.status()).running, true); + } finally { + await app.cleanup(); + } + } +}); + +test("an exact live legacy Serve owner restarts and reaches canonical migration", async () => { + const port = await availablePortPair(); + const legacyTarget = `http://127.0.0.1:${port + 1}`; + const app = await fixture({ + mode: "both", + lanPort: port, + initial: (state) => { + state.lanPortCommitted = true; + state.tailscaleOwnership = { path: "/api/aiden/v1", target: legacyTarget }; + }, + tailscaleServeStatus: { + TCP: { "443": { HTTPS: true } }, + Web: { + "aiden.tailnet.ts.net:443": { + Handlers: { "/api/aiden/v1": { Proxy: legacyTarget } }, + }, + }, + }, + }); + try { + await app.service.setEnabled(true); + assert.equal(app.persisted().lanPort, port); + await app.service.connectTailscale(); + assert.deepEqual(app.tailscale.disconnectTargets, [legacyTarget]); + assert.deepEqual(app.tailscale.targets, [ + `http://127.0.0.1:${port + 1}/api/aiden/v1`, + ]); + } finally { + await app.cleanup(); + } +}); + +test("ordinary odd committed legacy ports remain restart-compatible", async () => { + const port = await availableLegacyOddPort(); + const app = await fixture({ + mode: "both", + lanPort: port, + initial: (state) => { + state.lanPortCommitted = true; + }, + }); + try { + await app.service.setEnabled(true); + assert.equal(app.persisted().lanPort, port); + assert.deepEqual(await insecureHealth(port), { + status: 200, + body: { ok: true, protocolVersion: 1 }, + }); + } finally { + await app.cleanup(); + } +}); + +test("a paired profile fails closed with typed remediation instead of moving its endpoint", async () => { + const [pairedPort, alternatePort] = await availablePortPairs(2); + const blocker = await reservePort(pairedPort, "::"); + const app = await fixture({ + mode: "both", + lanPort: pairedPort, + portCandidates: [pairedPort, alternatePort], + }); + try { + await app.state.issueDevice({ name: "Previous iPhone", type: "iphone", clientVersion: "1" }); + await assert.rejects( + app.service.setEnabled(true), + (error: unknown) => error instanceof AidenRemotePortInUseError + && error.code === "remote_port_in_use" + && error.lanPort === pairedPort, + ); + const status = await app.service.status(); + assert.equal(status.errorCode, "remote_port_in_use"); + assert.match(status.error ?? "", /Stop the other Aiden profile/u); + assert.equal(status.error?.includes("EADDRINUSE"), false); + assert.equal(app.persisted().lanPort, pairedPort); + assert.equal(app.persisted().lanPortCommitted, false); + assert.equal(app.bonjour.starts, 0); + assert.equal(await canBind(alternatePort), true); + } finally { + await app.cleanup(); + await closeReservedPort(blocker); + } +}); + +test("candidate exhaustion leaves a fresh profile uncommitted and publishes nothing", async () => { + const [firstPort, secondPort] = await availablePortPairs(2); + const firstBlocker = await reservePort(firstPort, "::"); + const secondBlocker = await reservePort(secondPort, "::"); + const app = await fixture({ + mode: "both", + lanPort: firstPort, + portCandidates: [firstPort, secondPort], + }); + try { + await assert.rejects(app.service.setEnabled(true), AidenRemotePortInUseError); + assert.equal(app.persisted().lanPort, firstPort); + assert.equal(app.persisted().lanPortCommitted, false); + assert.equal(app.persisted().enabled, false); + assert.equal(app.bonjour.starts, 0); + } finally { + await app.cleanup(); + await Promise.all([ + closeReservedPort(firstBlocker), + closeReservedPort(secondBlocker), + ]); + } +}); + +test("a failed endpoint commit releases every listener and never advertises", async () => { + const port = await availablePortPair(); + let heldSocket: Socket | undefined; + let heldSocketClosed: Promise | undefined; + const app = await fixture({ + mode: "both", + lanPort: port, + portCandidates: [port], + failSaveWhen: (document) => document.lanPortCommitted, + afterListenerBound: async ({ transport }) => { + if (transport !== "lan") return; + heldSocket = await heldConnection(port); + heldSocketClosed = socketClosed(heldSocket); + }, + }); + try { + await within(assert.rejects(app.service.setEnabled(true), /disk unavailable/u)); + if (heldSocketClosed) await within(heldSocketClosed); + assert.equal(heldSocket?.destroyed, true); + assert.equal(await canBind(port), true); + assert.equal(await canBind(port + 1), true); + assert.equal(app.persisted().lanPortCommitted, false); + assert.equal(app.bonjour.starts, 0); + } finally { + await app.cleanup(); + } +}); + +test("Bonjour launch rejection rolls back listeners and never reports a running service", async () => { + const port = await availablePortPair(); + const app = await fixture({ + mode: "both", + lanPort: port, + portCandidates: [port], + failBonjourStart: true, + }); + try { + await assert.rejects(app.service.setEnabled(true), /dns-sd unavailable/u); + const status = await app.service.status(); + assert.equal(status.running, false); + assert.equal(status.error, "Local network discovery could not start. Restart Remote Access to try again."); + assert.equal(status.error?.includes("dns-sd unavailable"), false); + assert.equal(await canBind(port), true); + assert.equal(await canBind(port + 1), true); + assert.equal(app.persisted().enabled, false); + } finally { + await app.cleanup(); + } +}); + +test("unexpected Bonjour termination stops the transport and exposes safe recovery", async () => { + const app = await fixture({ mode: "both" }); + try { + await app.service.setEnabled(true); + app.bonjour.failure?.(new Error("private dns-sd detail")); + const status = await app.service.status(); + assert.equal(status.running, false); + assert.equal(status.error, "Local network discovery stopped unexpectedly. Restart Remote Access to try again."); + assert.equal(status.error?.includes("private dns-sd detail"), false); + } finally { + await app.cleanup(); + } +}); + +test("disabled startup creates no identity, listener, or Bonjour advertisement", async () => { + const app = await fixture(); + try { + await app.service.initialize(); + assert.deepEqual(await app.service.status(), { + enabled: false, + running: false, + connectionMode: "lan", + lanPort: app.persisted().lanPort, + tailscaleConnected: false, + tailscaleInstalled: false, + tailscaleRouteState: "unavailable", + pairedDeviceCount: 0, + approvedRootCount: 0, + }); + assert.equal(app.identityLoads(), 0); + assert.equal(app.bonjour.starts, 0); + } finally { + await app.cleanup(); + } +}); + +test("Bonjour labels disambiguate duplicate Mac names with a stable bounded instance suffix", () => { + const first = aidenRemoteBonjourServiceName("Studio Mac", "instance_aaaaaa"); + const second = aidenRemoteBonjourServiceName("Studio Mac", "instance_bbbbbb"); + assert.match(first, /^Studio Mac \[[a-f0-9]{6}\]$/u); + assert.match(second, /^Studio Mac \[[a-f0-9]{6}\]$/u); + assert.notEqual(first, second); + assert.ok(Buffer.byteLength( + aidenRemoteBonjourServiceName("🖥️".repeat(80), "instance_cccccc"), + "utf8", + ) <= 63); +}); + +test("explicit enable serves authenticated API shell over LAN HTTPS and stops cleanly", async () => { + const app = await fixture(); + try { + await app.service.initialize(); + await app.service.setEnabled(true); + const health = await insecureHealth(app.persisted().lanPort); + assert.deepEqual(health, { status: 200, body: { ok: true, protocolVersion: 1 } }); + assert.equal(app.bonjour.starts, 1); + assert.equal((await app.service.status()).running, true); + await app.service.setEnabled(false); + assert.equal((await app.service.status()).running, false); + assert.equal(app.persisted().enabled, false); + } finally { + await app.cleanup(); + } +}); + +test("renaming the Mac updates authenticated projection and Bonjour without rotating identity", async () => { + const app = await fixture(); + try { + await app.service.setEnabled(true); + const { bootstrap } = await app.service.beginPairing("lan"); + const paired = await insecureJson<{ deviceId: string; credential: string }>( + app.persisted().lanPort, + "/api/aiden/v1/pairing/exchange", + { + method: "POST", + body: { + secret: bootstrap.secret, + deviceName: "Phone", + deviceType: "iphone", + clientVersion: "1", + }, + }, + ); + const identity = app.persisted().instanceId; + const deviceId = paired.body.deviceId; + await app.service.setDisplayName("Studio Mac"); + const projection = await insecureJson<{ instanceId: string; name: string }>( + app.persisted().lanPort, + "/api/aiden/v1/server", + { + headers: { + authorization: `Bearer ${paired.body.credential}`, + "aiden-protocol-version": "1", + }, + }, + ); + assert.equal(projection.status, 200); + assert.equal(projection.body.instanceId, identity); + assert.equal(projection.body.name, "Studio Mac"); + assert.equal(app.persisted().instanceId, identity); + assert.equal((await app.state.listDevices())[0]?.id, deviceId); + assert.equal(app.bonjour.inputs[app.bonjour.inputs.length - 1]?.displayName, "Studio Mac"); + } finally { + await app.cleanup(); + } +}); + +test("Tailscale connect ownership persists only after connect and explicit disable clears only it", async () => { + const app = await fixture("both"); + try { + await app.service.initialize(); + await app.service.setEnabled(true); + await app.service.connectTailscale(); + assert.equal(app.tailscale.connects, 1); + assert.deepEqual(app.tailscale.targets, [ + `http://127.0.0.1:${app.persisted().lanPort + 1}/api/aiden/v1`, + ]); + assert.equal(app.persisted().tailscaleOwnership?.path, "/api/aiden/v1"); + await app.service.setEnabled(false); + assert.equal(app.tailscale.disconnects, 1); + assert.equal(app.persisted().tailscaleOwnership, undefined); + assert.equal(app.persisted().enabled, false); + } 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}`; + try { + await app.state.setTailscaleOwnership({ path: "/api/aiden/v1", target: legacyTarget }); + let legacyOwnershipWrites = 0; + const legacySetter = app.state.setTailscaleOwnership.bind(app.state); + app.state.setTailscaleOwnership = async (ownership) => { + legacyOwnershipWrites += 1; + return legacySetter(ownership); + }; + await app.service.initialize(); + await app.service.setEnabled(true); + await app.service.connectTailscale(); + assert.deepEqual(app.tailscale.disconnectTargets, [legacyTarget]); + assert.deepEqual(app.tailscale.targets, [ + `${legacyTarget}/api/aiden/v1`, + ]); + assert.equal(app.persisted().tailscaleOwnership?.target, `${legacyTarget}/api/aiden/v1`); + assert.equal(legacyOwnershipWrites, 0); + } finally { + await app.cleanup(); + } +}); + +test("Tailscale takeover review stays main-owned and persists only after confirmed takeover", async () => { + const app = await fixture({ mode: "both", enableTailscaleTakeover: true }); + try { + await app.service.setEnabled(true); + assert.equal((await app.service.status()).tailscaleRouteState, "other_aiden_stale"); + const review = await app.service.reviewTailscaleTakeover(); + assert.equal(app.persisted().tailscaleOwnership, undefined); + await app.service.takeOverTailscale(review.token); + assert.equal(app.persisted().tailscaleOwnership?.path, "/api/aiden/v1"); + assert.equal( + app.persisted().tailscaleOwnership?.target, + `http://127.0.0.1:${app.persisted().lanPort + 1}/api/aiden/v1`, + ); + } finally { + await app.cleanup(); + } +}); + +test("Funnel and unavailable owned routes are not presented or paired as connected", async () => { + for (const assessment of [ + { state: "funnel_conflict" as const }, + { state: "owned" as const, errorCode: "not_connected" as const }, + { state: "owned" as const, errorCode: "https_unavailable" as const }, + ]) { + const app = await fixture({ + mode: "both", + tailscaleAssessment: assessment, + 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); + const status = await app.service.status(); + assert.equal(status.tailscaleRouteState, assessment.state); + assert.equal(status.tailscaleConnected, false); + await assert.rejects( + app.service.beginPairing("tailscale"), + /not privately connected/u, + ); + } finally { + await app.cleanup(); + } + } +}); + +test("a persisted Tailscale handler without TCP 443 HTTPS is neither connected nor pairable", async () => { + let target = ""; + const app = await fixture({ + mode: "both", + initial: (state) => { + target = `http://127.0.0.1:${state.lanPort + 1}/api/aiden/v1`; + state.tailscaleOwnership = { path: "/api/aiden/v1", target }; + }, + tailscaleServeStatus: { + Web: { + "aiden.tailnet.ts.net:443": { + Handlers: { "/api/aiden/v1": { Proxy: target } }, + }, + }, + }, + }); + try { + await app.service.setEnabled(true); + const status = await app.service.status(); + assert.equal(status.tailscaleConnected, false); + assert.equal(status.tailscaleRouteState, "unavailable"); + await assert.rejects( + app.service.beginPairing("tailscale"), + /not privately connected/u, + ); + } finally { + await app.cleanup(); + } +}); + +test("a durable unknown Tailscale outcome blocks pairing until explicit reconciliation", async () => { + let target = ""; + const app = await fixture({ + mode: "both", + initial: (state) => { + target = `http://127.0.0.1:${state.lanPort + 1}/api/aiden/v1`; + state.tailscalePendingOutcome = { + operation: "connect", + target, + beforeFingerprint: "a".repeat(64), + preservedFingerprint: "b".repeat(64), + normalizeListenerScaffolding: false, + createdAt: 1_000, + }; + }, + }); + try { + await app.service.setEnabled(true); + assert.equal((await app.service.status()).tailscaleRouteState, "reconciliation_required"); + await assert.rejects(app.service.beginPairing("tailscale"), /Verify the previous Tailscale route update/u); + await app.service.reconcileTailscale(); + assert.equal(app.tailscale.reconciles, 1); + assert.deepEqual(app.persisted().tailscaleOwnership, { path: "/api/aiden/v1", target }); + assert.equal(app.persisted().tailscalePendingOutcome, undefined); + } finally { + await app.cleanup(); + } +}); + +test("a pending disconnect blocks every direct Tailscale operation and pairing until reconciliation", async () => { + let target = ""; + const app = await fixture({ + mode: "both", + initial: (state) => { + target = `http://127.0.0.1:${state.lanPort + 1}/api/aiden/v1`; + state.tailscaleOwnership = { path: "/api/aiden/v1", target }; + state.tailscalePendingOutcome = { + operation: "disconnect", + target, + previousTarget: target, + beforeFingerprint: "a".repeat(64), + preservedFingerprint: "b".repeat(64), + normalizeListenerScaffolding: false, + createdAt: 1_000, + }; + }, + }); + try { + await app.service.setEnabled(true); + for (const action of [ + () => app.service.connectTailscale(), + () => app.service.disconnectTailscale(), + () => app.service.reviewTailscaleTakeover(), + () => app.service.takeOverTailscale("A".repeat(32)), + ]) { + await assert.rejects(action(), /tailscale_reconciliation_required/u); + } + await assert.rejects( + app.service.beginPairing("tailscale"), + /Verify the previous Tailscale route update/u, + ); + await app.service.reconcileTailscale(); + assert.equal(app.persisted().tailscaleOwnership, undefined); + assert.equal(app.persisted().tailscalePendingOutcome, undefined); + } finally { + await app.cleanup(); + } +}); + +test("pairing windows expose no secret until a local desktop action begins one", async () => { + const app = await fixture(); + try { + await app.service.setEnabled(true); + const pairing = await app.service.beginPairing("lan"); + const { bootstrap } = pairing; + assert.equal(bootstrap.endpoint, `https://aiden-test.local:${app.persisted().lanPort}/api/aiden/v1`); + assert.match(bootstrap.secret, /^[A-Za-z0-9_-]{43}$/u); + assert.match(bootstrap.serverSpkiSha256, /^sha256\/[A-Za-z0-9+/]{43}=$/u); + const qr = JSON.parse(app.service.pairingQrPayload(bootstrap, "lan")); + assert.equal(qr.kind, "aiden-pairing-v1"); + assert.equal(qr.bootstrap.secret, bootstrap.secret); + assert.equal(qr.trust.mode, "private-ca"); + assert.ok(Buffer.from(qr.trust.caCertificateDerBase64, "base64").length > 100); + assert.equal(JSON.stringify(await app.service.status()).includes(bootstrap.secret), false); + } finally { + await app.cleanup(); + } +}); + +test("revoked devices can re-pair through a fresh local window without reviving the old credential", async () => { + const app = await fixture(); + try { + await app.service.setEnabled(true); + const { bootstrap: firstBootstrap } = await app.service.beginPairing("lan"); + const first = await insecureJson<{ deviceId: string; credential: string }>( + app.persisted().lanPort, + "/api/aiden/v1/pairing/exchange", + { + method: "POST", + body: { + secret: firstBootstrap.secret, + deviceName: "Physical iPhone", + deviceType: "iphone", + clientVersion: "1.0", + }, + }, + ); + assert.equal(first.status, 200); + assert.match(first.body.credential, /^[A-Za-z0-9_-]{43}$/u); + assert.equal(await app.state.revokeDevice(first.body.deviceId), true); + + const { bootstrap: repairBootstrap } = await app.service.beginPairing("lan"); + assert.notEqual(repairBootstrap.secret, firstBootstrap.secret); + const repaired = await insecureJson<{ deviceId: string; credential: string }>( + app.persisted().lanPort, + "/api/aiden/v1/pairing/exchange", + { + method: "POST", + body: { + secret: repairBootstrap.secret, + deviceName: "Physical iPhone", + deviceType: "iphone", + clientVersion: "1.0", + }, + }, + ); + assert.equal(repaired.status, 200); + assert.notEqual(repaired.body.deviceId, first.body.deviceId); + assert.notEqual(repaired.body.credential, first.body.credential); + + const headers = (credential: string) => ({ + authorization: `Bearer ${credential}`, + "aiden-protocol-version": "1", + }); + const oldCredential = await insecureJson<{ error: { code: string } }>( + app.persisted().lanPort, + "/api/aiden/v1/server", + { headers: headers(first.body.credential) }, + ); + assert.equal(oldCredential.status, 403); + assert.equal(oldCredential.body.error.code, "credential_revoked"); + const replacementCredential = await insecureJson<{ instanceId: string }>( + app.persisted().lanPort, + "/api/aiden/v1/server", + { headers: headers(repaired.body.credential) }, + ); + assert.equal(replacementCredential.status, 200); + assert.equal(replacementCredential.body.instanceId, app.persisted().instanceId); + + const serialized = JSON.stringify(app.persisted()); + assert.equal(serialized.includes(first.body.credential), false); + assert.equal(serialized.includes(repaired.body.credential), false); + } finally { + await app.cleanup(); + } +}); + +test("two paired devices authenticate independently and revoking one leaves the other active", async () => { + const app = await fixture(); + try { + await app.service.setEnabled(true); + const pairDevice = async (deviceName: string) => { + const { bootstrap } = await app.service.beginPairing("lan"); + const exchange = await insecureJson<{ deviceId: string; credential: string }>( + app.persisted().lanPort, + "/api/aiden/v1/pairing/exchange", + { + method: "POST", + body: { + secret: bootstrap.secret, + deviceName, + deviceType: "iphone", + clientVersion: "1.0", + }, + }, + ); + assert.equal(exchange.status, 200); + return exchange.body; + }; + const first = await pairDevice("Personal iPhone"); + const second = await pairDevice("Travel iPhone"); + assert.notEqual(first.deviceId, second.deviceId); + assert.notEqual(first.credential, second.credential); + + const authenticate = (credential: string) => insecureJson<{ instanceId: string }>( + app.persisted().lanPort, + "/api/aiden/v1/server", + { + headers: { + authorization: `Bearer ${credential}`, + "aiden-protocol-version": "1", + }, + }, + ); + const [firstServer, secondServer] = await Promise.all([ + authenticate(first.credential), + authenticate(second.credential), + ]); + assert.equal(firstServer.status, 200); + assert.equal(secondServer.status, 200); + assert.equal(firstServer.body.instanceId, app.persisted().instanceId); + assert.equal(secondServer.body.instanceId, app.persisted().instanceId); + + assert.equal(await revokeAidenRemoteRuntimeDevice({ + state: app.state, + workspaceOwners: { revokeDevice: () => undefined }, + }, first.deviceId), true); + const revoked = await insecureJson<{ error: { code: string } }>( + app.persisted().lanPort, + "/api/aiden/v1/server", + { + headers: { + authorization: `Bearer ${first.credential}`, + "aiden-protocol-version": "1", + }, + }, + ); + const unaffected = await authenticate(second.credential); + assert.equal(revoked.status, 403); + assert.equal(revoked.body.error.code, "credential_revoked"); + assert.equal(unaffected.status, 200); + assert.equal(unaffected.body.instanceId, app.persisted().instanceId); + + const devices = (await app.state.snapshot()).devices; + const firstDevice = devices.find(({ id }) => id === first.deviceId); + const secondDevice = devices.find(({ id }) => id === second.deviceId); + assert.ok(firstDevice?.revokedAt); + assert.ok((firstDevice?.lastSeenAt ?? 0) > 0); + assert.equal(secondDevice?.revokedAt, undefined); + assert.ok((secondDevice?.lastSeenAt ?? 0) > 0); + + const serialized = JSON.stringify(app.persisted()); + assert.equal(serialized.includes(first.credential), false); + assert.equal(serialized.includes(second.credential), false); + } finally { + await app.cleanup(); + } +}); diff --git a/main/services/aiden-remote-service.ts b/main/services/aiden-remote-service.ts new file mode 100644 index 00000000..49c42c31 --- /dev/null +++ b/main/services/aiden-remote-service.ts @@ -0,0 +1,952 @@ +import { spawn, type ChildProcess } from "node:child_process"; +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"; +import type { Server as NetServer } from "node:net"; +import os from "node:os"; +import type { Duplex } from "node:stream"; +import { AIDEN_REMOTE_BASE_PATH } from "./aiden-remote-protocol.js"; +import { + AidenRemotePairingService, + type AidenRemoteDesktopPairing, + type AidenRemotePairingBootstrap, + type AidenRemotePairingWindowStatus, +} from "./aiden-remote-pairing.js"; +import { + createAidenRemoteRequestHandler, + type AidenRemoteRouterDependencies, +} from "./aiden-remote-router.js"; +import type { + AidenRemoteConnectionMode, + AidenRemoteStateDocument, + AidenRemoteStateRegistry, +} from "./aiden-remote-state.js"; +import type { AidenRemoteTlsIdentity } from "./aiden-remote-tls-identity.js"; +import { fetchTlsServerSpkiSha256 } from "./aiden-remote-tls-identity.js"; +import type { + AidenRemoteTailscaleController, + AidenTailscaleConnectionStatus, + AidenTailscaleRouteState, + AidenTailscaleTakeoverReview, +} from "./aiden-remote-tailscale.js"; +import { + aidenTailscaleCanonicalLoopbackTargets, + planAidenTailscaleConnect, +} from "./aiden-remote-tailscale-route.js"; +import type { AidenRemoteWorkspaceBrowserService } from "./aiden-remote-workspace-browser.js"; +import type { AidenRemoteWorkspaceService } from "./aiden-remote-workspaces.js"; +import type { AidenRemoteChatService } from "./aiden-remote-chats.js"; +import type { AidenRemoteModelService } from "./aiden-remote-models.js"; +import type { AidenRemoteStreamService } from "./aiden-remote-streams.js"; +import type { AidenRemoteFileService } from "./aiden-remote-files.js"; +import type { AidenRemoteGitService } from "./aiden-remote-git.js"; +import type { AidenRemoteScheduleService } from "./aiden-remote-schedules.js"; +import type { UsageDateRange, UsageSummary } from "./types.js"; + +const MAX_CONNECTIONS = 64; +const REQUEST_TIMEOUT_MS = 30_000; +const PORT_PAIR_CANDIDATE_COUNT = 64; +const FIRST_DYNAMIC_LAN_PORT = 49_220; + +export class AidenRemotePortInUseError extends Error { + readonly code = "remote_port_in_use" as const; + + constructor(readonly lanPort: number) { + super( + `Aiden Remote cannot use private port ${lanPort}. Stop the other Aiden profile using it, then try again.`, + ); + this.name = "AidenRemotePortInUseError"; + } +} + +export function aidenRemotePortCandidates(preferredPort: number): number[] { + const values: number[] = []; + const add = (port: number) => { + if ( + Number.isInteger(port) + && port > 0 + && port < 65_535 + && port % 2 === 0 + && !values.includes(port) + ) { + values.push(port); + } + }; + add(preferredPort); + for (let index = 0; index < PORT_PAIR_CANDIDATE_COUNT; index += 1) { + add(FIRST_DYNAMIC_LAN_PORT + index * 2); + } + return values.slice(0, PORT_PAIR_CANDIDATE_COUNT); +} + +export interface AidenRemoteBonjourPublisher { + start( + input: { instanceId: string; displayName: string; port: number }, + onUnexpectedFailure: (error: Error) => void, + ): Promise; + stop(): void; +} + +export interface AidenRemoteServiceLogEntry { + level: "info" | "warn" | "error"; + event: string; + details?: Record; +} + +export function aidenRemoteBonjourServiceName( + displayName: string, + instanceId: string, +): string { + const publicSuffix = createHash("sha256") + .update(instanceId, "utf8") + .digest("hex") + .slice(0, 6); + const suffix = ` [${publicSuffix}]`; + let label = displayName; + while (Buffer.byteLength(`${label}${suffix}`, "utf8") > 63) { + label = [...label].slice(0, -1).join(""); + } + return `${label || "Aiden"}${suffix}`; +} + +export interface AidenRemoteServiceOptions { + state: AidenRemoteStateRegistry; + appVersion: string; + hostname?: string; + loadTlsIdentity(): Promise; + resolveTlsEndpointPin?: (hostname: string, port?: number) => Promise; + tailscale: Pick + & Partial>; + bonjour: AidenRemoteBonjourPublisher; + notifyPairingChanged?: () => void; + workspaceApi?: ( + instanceId: string, + ) => + | { + workspaces: Pick; + workspaceBrowser: Pick< + AidenRemoteWorkspaceBrowserService, + "listRoots" | "listChildren" | "createSelection" + >; + chats?: Pick; + models?: Pick; + streams?: Pick; + files?: Pick; + git?: Pick; + schedules?: Pick; + usage?: { summary(range: UsageDateRange): Promise }; + settle?: () => Promise; + } + | Promise<{ + workspaces: Pick; + workspaceBrowser: Pick< + AidenRemoteWorkspaceBrowserService, + "listRoots" | "listChildren" | "createSelection" + >; + chats?: Pick; + models?: Pick; + streams?: Pick; + files?: Pick; + git?: Pick; + schedules?: Pick; + usage?: { summary(range: UsageDateRange): Promise }; + settle?: () => Promise; + }>; + now?: () => number; + portCandidates?: (preferredPort: number) => readonly number[]; + afterListenerBound?: (input: { + transport: "lan" | "tailscale"; + port: number; + }) => Promise; + log?: (entry: AidenRemoteServiceLogEntry) => void; +} + +export interface AidenRemoteServiceStatus { + enabled: boolean; + running: boolean; + connectionMode: AidenRemoteConnectionMode; + lanPort: number; + lanEndpoint?: string; + tailscaleEndpoint?: string; + tailscaleRoutePreview?: string; + tailscaleConnected: boolean; + tailscaleInstalled: boolean; + tailscaleRouteState: AidenTailscaleRouteState; + tailscaleErrorCode?: AidenTailscaleConnectionStatus["errorCode"]; + pairedDeviceCount: number; + approvedRootCount: number; + errorCode?: "remote_port_in_use"; + error?: string; +} + +function normalizedHostname(raw: string): string { + const value = raw.trim().replace(/\.$/u, "").toLowerCase(); + if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(value)) return "localhost"; + return value; +} + +function localDnsName(hostname: string): string { + return hostname.includes(".") ? hostname : `${hostname}.local`; +} + +function tailscaleLoopbackPort(lanPort: number): number { + return lanPort === 65_535 ? 49_221 : lanPort + 1; +} + +function isAddressInUse(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === "EADDRINUSE"; +} + +function configureServer(server: HttpServer | HttpsServer): void { + server.maxConnections = MAX_CONNECTIONS; + server.headersTimeout = REQUEST_TIMEOUT_MS; + server.requestTimeout = REQUEST_TIMEOUT_MS; + server.keepAliveTimeout = 5_000; +} + +async function listen( + server: NetServer, + port: number, + host: string, +): Promise { + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(port, host); + }); +} + +async function closeServer(server: NetServer | null): Promise { + if (!server) return; + await new Promise((resolve) => { + server.close(() => resolve()); + // Stop admitting sockets before forcing existing HTTP(S) connections + // closed. Reversing this order leaves a race where a new connection can + // arrive after closeAllConnections() and keep server.close() pending. + if ("closeIdleConnections" in server) { + (server as HttpServer).closeIdleConnections?.(); + (server as HttpServer).closeAllConnections?.(); + } + }); +} + +export class DnsSdAidenRemoteBonjourPublisher implements AidenRemoteBonjourPublisher { + private child: ChildProcess | null = null; + + 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 serviceName = aidenRemoteBonjourServiceName( + input.displayName, + input.instanceId, + ); + const child = spawn( + "/usr/bin/dns-sd", + [ + "-R", serviceName, "_aiden-agent._tcp", "local.", + String(input.port), "v=1", `instance=${input.instanceId}`, + ], + { stdio: "ignore", windowsHide: true }, + ); + this.child = child; + await new Promise((resolve, reject) => { + let ready = false; + let failed = false; + let readinessTimer: NodeJS.Timeout | undefined; + const fail = (error: Error) => { + if (failed) return; + const isCurrent = this.child === child; + if (ready && !isCurrent) return; + failed = true; + if (readinessTimer) clearTimeout(readinessTimer); + if (isCurrent) this.child = null; + this.log({ level: "warn", event: "bonjour_failed", details: { message: error.message } }); + if (!ready) reject(error); + else if (isCurrent) onUnexpectedFailure(error); + }; + child.once("error", fail); + child.once("exit", (code, signal) => { + fail(new Error(`Local discovery exited (${code ?? signal ?? "unknown"}).`)); + }); + child.once("spawn", () => { + // dns-sd has no registration acknowledgement. Remaining alive through + // a bounded launch window catches missing binaries and immediate + // registration failures before listeners are declared ready. + readinessTimer = setTimeout(() => { + ready = true; + resolve(); + }, 150); + }); + }); + } + + stop(): void { + const child = this.child; + this.child = null; + if (child && child.exitCode === null && !child.killed) child.kill("SIGTERM"); + } +} + +export class AidenRemoteService { + private lanServer: HttpsServer | null = null; + private tailscaleServer: HttpServer | null = null; + private readonly lanConnections = new Set(); + private readonly tailscaleConnections = new Set(); + private pairing: AidenRemotePairingService | null = null; + private tlsIdentity: AidenRemoteTlsIdentity | null = null; + private activeState: AidenRemoteStateDocument | null = null; + private lastError: string | undefined; + private lastErrorCode: "remote_port_in_use" | undefined; + private operationTail: Promise = Promise.resolve(); + private settleRemoteApi: (() => Promise) | undefined; + private readonly now: () => number; + private readonly hostname: string; + + constructor(private readonly options: AidenRemoteServiceOptions) { + this.now = options.now ?? Date.now; + this.hostname = normalizedHostname(options.hostname ?? os.hostname()); + } + + private serialized(operation: () => Promise): Promise { + const result = this.operationTail.then(operation, operation); + this.operationTail = result.then(() => undefined, () => undefined); + return result; + } + + async initialize(): Promise { + await this.serialized(async () => { + const state = await this.options.state.initialize(); + if (state.enabled) await this.startConfigured(state); + }); + } + + private async startConfigured(state: AidenRemoteStateDocument): Promise { + await this.stopListeners(); + this.lastError = undefined; + this.lastErrorCode = undefined; + try { + const tlsIdentity = await this.options.loadTlsIdentity(); + const pairing = new AidenRemotePairingService( + state.instanceId, + this.options.state, + undefined, + this.options.notifyPairingChanged, + () => this.activeState?.displayName ?? state.displayName, + ); + const workspaceApi = await this.options.workspaceApi?.(state.instanceId); + this.settleRemoteApi = workspaceApi?.settle; + const routerDependencies: AidenRemoteRouterDependencies = { + instanceId: state.instanceId, + displayName: () => this.activeState?.displayName ?? state.displayName, + appVersion: this.options.appVersion, + devices: this.options.state, + pairing, + ...(workspaceApi ?? {}), + connectionMode: () => this.activeState?.connectionMode ?? state.connectionMode, + now: this.now, + log: (entry) => { + this.options.log?.({ + level: entry.status >= 500 ? "error" : entry.status >= 400 ? "warn" : "info", + event: "request", + details: { + requestId: entry.requestId, + route: entry.route, + status: entry.status, + latencyMs: entry.latencyMs, + deviceIdSuffix: entry.deviceIdSuffix, + errorCode: entry.errorCode, + }, + }); + }, + }; + + const mayMoveFreshProfile = !state.lanPortCommitted + && state.devices.length === 0 + && state.tailscaleOwnership === undefined; + const externallyReservedPorts = new Set(); + try { + const tailscaleStatus = await this.options.tailscale.status(); + for (const configured of tailscaleStatus.serveStatus + ? aidenTailscaleCanonicalLoopbackTargets(tailscaleStatus.serveStatus) + : []) { + if (state.tailscaleOwnership?.target !== configured.target) { + externallyReservedPorts.add(configured.port); + } + } + } catch { + // A missing/unavailable local Tailscale CLI must not prevent LAN-only + // startup. Exact route conflicts are still handled before mutation. + } + const candidates = mayMoveFreshProfile + ? [...(this.options.portCandidates?.(state.lanPort) + ?? aidenRemotePortCandidates(state.lanPort))] + : [state.lanPort]; + let selectedPort: number | undefined; + let selectedLanServer: HttpsServer | null = null; + let selectedTailscaleServer: HttpServer | null = null; + for (const candidate of candidates) { + const validFreshPair = Number.isInteger(candidate) + && candidate >= 1 + && candidate < 65_535 + && candidate % 2 === 0; + const validCommittedLegacy = !mayMoveFreshProfile + && Number.isInteger(candidate) + && candidate >= 1 + && candidate <= 65_535; + if (!validFreshPair && !validCommittedLegacy) continue; + if ( + externallyReservedPorts.has(candidate) + || externallyReservedPorts.has(tailscaleLoopbackPort(candidate)) + ) continue; + let lanServer: HttpsServer | null = null; + let tailscaleServer: HttpServer | null = null; + try { + const lanHandler = createAidenRemoteRequestHandler(routerDependencies); + lanServer = createHttpsServer( + { key: tlsIdentity.privateKey, cert: tlsIdentity.certificateChain }, + (request, response) => { + const mode = this.activeState?.connectionMode ?? state.connectionMode; + if (mode === "tailscale") { + response.writeHead(404).end(); + return; + } + lanHandler(request, response); + }, + ); + lanServer.prependListener("connection", (socket) => { + const mode = this.activeState?.connectionMode ?? state.connectionMode; + if (mode === "tailscale") { + socket.destroy(); + return; + } + this.lanConnections.add(socket); + socket.once("close", () => this.lanConnections.delete(socket)); + }); + configureServer(lanServer); + // macOS Bonjour commonly resolves both A and AAAA records. The IPv6 + // wildcard is dual-stack by default, so one listener serves both. + await listen(lanServer, candidate, "::"); + await this.options.afterListenerBound?.({ transport: "lan", port: candidate }); + + const tailscaleHandler = createAidenRemoteRequestHandler({ + ...routerDependencies, + acceptStrippedBasePath: true, + }); + tailscaleServer = createHttpServer((request, response) => { + const mode = this.activeState?.connectionMode ?? state.connectionMode; + if (mode === "lan") { + response.writeHead(404).end(); + return; + } + tailscaleHandler(request, response); + }); + tailscaleServer.prependListener("connection", (socket) => { + const mode = this.activeState?.connectionMode ?? state.connectionMode; + if (mode === "lan") { + socket.destroy(); + return; + } + this.tailscaleConnections.add(socket); + socket.once("close", () => this.tailscaleConnections.delete(socket)); + }); + configureServer(tailscaleServer); + await listen(tailscaleServer, tailscaleLoopbackPort(candidate), "127.0.0.1"); + await this.options.afterListenerBound?.({ + transport: "tailscale", + port: tailscaleLoopbackPort(candidate), + }); + selectedPort = candidate; + selectedLanServer = lanServer; + selectedTailscaleServer = tailscaleServer; + break; + } catch (error) { + this.destroyConnections(this.lanConnections); + this.destroyConnections(this.tailscaleConnections); + await Promise.all([ + closeServer(lanServer), + closeServer(tailscaleServer), + ]); + if (isAddressInUse(error) && mayMoveFreshProfile) continue; + if (isAddressInUse(error)) throw new AidenRemotePortInUseError(state.lanPort); + throw error; + } + } + if (selectedPort === undefined) { + throw new AidenRemotePortInUseError(state.lanPort); + } + if (!state.lanPortCommitted || state.lanPort !== selectedPort) { + try { + await this.options.state.commitLanPort(selectedPort); + } catch (error) { + this.destroyConnections(this.lanConnections); + this.destroyConnections(this.tailscaleConnections); + await Promise.all([ + closeServer(selectedLanServer), + closeServer(selectedTailscaleServer), + ]); + throw error; + } + } + const committedState = { + ...state, + lanPort: selectedPort, + lanPortCommitted: true, + }; + this.lanServer = selectedLanServer; + this.tailscaleServer = selectedTailscaleServer; + this.tlsIdentity = tlsIdentity; + this.pairing = pairing; + this.activeState = structuredClone(committedState); + if (state.connectionMode === "lan" || state.connectionMode === "both") { + await this.publishBonjour({ + instanceId: committedState.instanceId, + displayName: committedState.displayName, + port: committedState.lanPort, + }); + } + this.options.log?.({ + level: "info", + event: "started", + details: { mode: committedState.connectionMode, lanPort: committedState.lanPort }, + }); + } catch (error) { + if (error instanceof AidenRemotePortInUseError) { + this.lastErrorCode = error.code; + this.lastError = error.message; + } else if (!this.lastError) { + this.lastError = error instanceof Error ? error.message : "Aiden Remote failed to start."; + } + await this.stopListeners(); + throw error; + } + } + + private async publishBonjour( + input: { instanceId: string; displayName: string; port: number }, + ): Promise { + try { + await this.options.bonjour.start(input, (error) => { + void this.serialized(async () => { + if ( + this.activeState?.instanceId !== input.instanceId + || this.activeState.lanPort !== input.port + || !this.lanServer + ) { + return; + } + this.lastError = "Local network discovery stopped unexpectedly. Restart Remote Access to try again."; + this.options.log?.({ + level: "warn", + event: "bonjour_stopped", + details: { message: error.message }, + }); + await this.stopListeners(); + }); + }); + } catch (error) { + this.lastError = "Local network discovery could not start. Restart Remote Access to try again."; + throw error; + } + } + + private async stopListeners(): Promise { + this.pairing?.close(); + this.pairing = null; + this.options.bonjour.stop(); + const lan = this.lanServer; + const tailscale = this.tailscaleServer; + this.lanServer = null; + this.tailscaleServer = null; + this.destroyConnections(this.lanConnections); + this.destroyConnections(this.tailscaleConnections); + this.activeState = null; + const settleRemoteApi = this.settleRemoteApi; + this.settleRemoteApi = undefined; + await Promise.all([ + closeServer(lan), + closeServer(tailscale), + settleRemoteApi?.() ?? Promise.resolve(), + ]); + } + + async stopAndSettle(): Promise { + await this.serialized(() => this.stopListeners()); + } + + stop(): void { + this.pairing?.close(); + this.options.bonjour.stop(); + this.destroyConnections(this.lanConnections); + this.destroyConnections(this.tailscaleConnections); + this.lanServer?.close(); + this.tailscaleServer?.close(); + } + + async setEnabled(enabled: boolean): Promise { + await this.serialized(async () => { + const current = await this.options.state.snapshot(); + if (enabled) { + if (!current.enabled || !this.activeState) { + await this.startConfigured({ ...current, enabled: true }); + try { + await this.options.state.setEnabled(true); + } catch (error) { + await this.stopListeners(); + throw error; + } + } + return; + } + let disconnectError: unknown; + if (current.tailscaleOwnership) { + try { + await this.disconnectTailscaleInternal(current); + } catch (error) { + disconnectError = error; + } + } + await this.stopListeners(); + await this.options.state.setEnabled(false); + if (disconnectError) throw disconnectError; + }); + } + + async setConnectionMode(connectionMode: AidenRemoteConnectionMode): Promise { + await this.serialized(async () => { + const current = await this.options.state.snapshot(); + if (current.tailscaleOwnership && connectionMode === "lan") { + await this.disconnectTailscaleInternal(current); + } + await this.options.state.setConnectionMode(connectionMode); + if (current.enabled) { + if (!this.activeState || !this.lanServer || !this.tailscaleServer) { + await this.startConfigured({ ...current, connectionMode }); + return; + } + const previouslyAdvertised = current.connectionMode === "lan" + || current.connectionMode === "both"; + const shouldAdvertise = connectionMode === "lan" || connectionMode === "both"; + this.activeState.connectionMode = connectionMode; + if (connectionMode === "tailscale") this.destroyConnections(this.lanConnections); + if (connectionMode === "lan") this.destroyConnections(this.tailscaleConnections); + if (previouslyAdvertised && !shouldAdvertise) { + this.options.bonjour.stop(); + } else if (!previouslyAdvertised && shouldAdvertise) { + try { + await this.publishBonjour({ + instanceId: this.activeState.instanceId, + displayName: this.activeState.displayName, + port: this.activeState.lanPort, + }); + } catch (error) { + await this.stopListeners(); + throw error; + } + } + } + }); + } + + private destroyConnections(connections: Set): void { + for (const socket of connections) socket.destroy(); + connections.clear(); + } + + async setDisplayName(displayName: string): Promise { + await this.serialized(async () => { + await this.options.state.setDisplayName(displayName); + const state = await this.options.state.snapshot(); + if (this.activeState) this.activeState.displayName = state.displayName; + if ( + this.lanServer + && (state.connectionMode === "lan" || state.connectionMode === "both") + ) { + try { + await this.publishBonjour({ + instanceId: state.instanceId, + displayName: state.displayName, + port: state.lanPort, + }); + } catch (error) { + await this.stopListeners(); + throw error; + } + } + }); + } + + private loopbackTarget(state: AidenRemoteStateDocument): string { + return `http://127.0.0.1:${tailscaleLoopbackPort(state.lanPort)}${AIDEN_REMOTE_BASE_PATH}`; + } + + async connectTailscale(): Promise { + await this.serialized(async () => { + const state = await this.options.state.snapshot(); + if (state.tailscalePendingOutcome) throw new Error("tailscale_reconciliation_required"); + if (!state.enabled || (state.connectionMode !== "tailscale" && state.connectionMode !== "both")) { + throw new Error("Enable Aiden Remote with Tailscale access before connecting Serve."); + } + if (!this.tailscaleServer) throw new Error("Aiden Remote loopback service is not running."); + const target = this.loopbackTarget(state); + let ownership = state.tailscaleOwnership; + if (ownership && ownership.target !== target) { + // Pre-acceptance builds persisted an origin-only target that cannot + // route the canonical API after Tailscale strips --set-path. Remove + // only that exact owned route before creating the corrected one. + await this.options.tailscale.disconnect( + ownership.target, + ownership, + () => this.options.state.commitTailscaleOutcome(undefined), + ); + ownership = undefined; + } + await this.options.tailscale.connect( + target, + ownership, + (nextOwnership) => this.options.state.commitTailscaleOutcome(nextOwnership), + ); + }); + } + + async reviewTailscaleTakeover(): Promise { + return this.serialized(async () => { + const state = await this.options.state.snapshot(); + if (state.tailscalePendingOutcome) throw new Error("tailscale_reconciliation_required"); + if (!state.enabled || (state.connectionMode !== "tailscale" && state.connectionMode !== "both")) { + throw new Error("tailscale_takeover_unavailable"); + } + if (!this.tailscaleServer || !this.options.tailscale.reviewTakeover) { + throw new Error("tailscale_takeover_unavailable"); + } + return this.options.tailscale.reviewTakeover( + this.loopbackTarget(state), + state.tailscaleOwnership, + ); + }); + } + + async takeOverTailscale(token: string): Promise { + await this.serialized(async () => { + const state = await this.options.state.snapshot(); + if (state.tailscalePendingOutcome) throw new Error("tailscale_reconciliation_required"); + if (!state.enabled || (state.connectionMode !== "tailscale" && state.connectionMode !== "both")) { + throw new Error("tailscale_takeover_unavailable"); + } + if (!this.tailscaleServer || !this.options.tailscale.takeOver) { + throw new Error("tailscale_takeover_unavailable"); + } + await this.options.tailscale.takeOver( + this.loopbackTarget(state), + token, + (ownership) => this.options.state.commitTailscaleOutcome(ownership), + ); + }); + } + + private async disconnectTailscaleInternal(state: AidenRemoteStateDocument): Promise { + if (state.tailscalePendingOutcome) throw new Error("tailscale_reconciliation_required"); + if (!state.tailscaleOwnership) return; + await this.options.tailscale.disconnect( + state.tailscaleOwnership.target, + state.tailscaleOwnership, + () => this.options.state.commitTailscaleOutcome(undefined), + ); + } + + async disconnectTailscale(): Promise { + await this.serialized(async () => { + await this.disconnectTailscaleInternal(await this.options.state.snapshot()); + }); + } + + async reconcileTailscale(): Promise { + await this.serialized(async () => { + const state = await this.options.state.snapshot(); + if (!state.tailscalePendingOutcome) { + throw new Error("tailscale_reconciliation_unavailable"); + } + if (!state.enabled || !this.tailscaleServer || !this.options.tailscale.reconcilePendingOutcome) { + throw new Error("tailscale_reconciliation_unavailable"); + } + await this.options.tailscale.reconcilePendingOutcome(); + }); + } + + async beginPairing(transport: "lan" | "tailscale"): Promise { + return this.serialized(async () => { + const state = await this.options.state.snapshot(); + if (!state.enabled || !this.pairing || !this.tlsIdentity) { + throw new Error("Enable Aiden Remote before pairing a device."); + } + let endpoint: string; + let serverSpkiSha256: string; + if (transport === "lan") { + if ( + !this.lanServer + || (state.connectionMode !== "lan" && state.connectionMode !== "both") + ) throw new Error("Local-network access is not enabled."); + endpoint = `https://${localDnsName(this.hostname)}:${state.lanPort}${AIDEN_REMOTE_BASE_PATH}`; + serverSpkiSha256 = this.tlsIdentity.serverSpkiSha256; + } else { + if (state.tailscalePendingOutcome) { + throw new Error("Verify the previous Tailscale route update before pairing."); + } + if ( + !state.tailscaleOwnership + || !this.tailscaleServer + || (state.connectionMode !== "tailscale" && state.connectionMode !== "both") + ) { + throw new Error("Connect the Aiden Tailscale Serve route before pairing."); + } + const status = await this.options.tailscale.status(); + if (this.options.tailscale.assessRoute) { + const assessment = await this.options.tailscale.assessRoute( + this.loopbackTarget(state), + state.tailscaleOwnership, + ); + if (assessment.state !== "owned" || assessment.errorCode) { + throw new Error("The Tailscale route is not privately connected to this Aiden profile."); + } + } else { + let connected = false; + try { + connected = status.serveStatus !== undefined + && planAidenTailscaleConnect( + status.serveStatus, + this.loopbackTarget(state), + state.tailscaleOwnership, + status.httpsAvailable, + ).action === "noop"; + } catch { + connected = false; + } + if (!connected) { + throw new Error("The Tailscale route is not privately connected to this Aiden profile."); + } + } + 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); + } + const pairing = this.pairing.begin(endpoint, serverSpkiSha256); + try { + const qrPayload = this.pairingQrPayload(pairing.bootstrap, transport); + this.pairing.sealManualPayload(pairing.sessionId, qrPayload); + return { ...pairing, qrPayload }; + } catch (error) { + this.pairing.close(pairing.sessionId); + throw error; + } + }); + } + + async closePairing(sessionId: string): Promise { + return this.serialized(async () => { + return this.pairing?.close(sessionId) ?? false; + }); + } + + pairingStatus(): AidenRemotePairingWindowStatus | undefined { + return this.pairing?.status(); + } + + pairingQrPayload( + bootstrap: AidenRemotePairingBootstrap, + transport: "lan" | "tailscale", + ): string { + if (!this.tlsIdentity) throw new Error("Aiden Remote transport identity is unavailable."); + const trust = transport === "lan" + ? { + mode: "private-ca" as const, + caCertificateDerBase64: new X509Certificate( + this.tlsIdentity.caCertificate, + ).raw.toString("base64"), + } + : { mode: "system" as const }; + const payload = JSON.stringify({ + kind: "aiden-pairing-v1", + bootstrap, + trust, + }); + if (Buffer.byteLength(payload, "utf8") > 4_096) { + throw new Error("Aiden Remote pairing payload is too large."); + } + return payload; + } + + async status(): Promise { + await this.operationTail; + const state = await this.options.state.snapshot(); + let tailscaleStatus: AidenTailscaleConnectionStatus = { installed: false }; + if (state.connectionMode !== "lan" || state.tailscaleOwnership) { + tailscaleStatus = await this.options.tailscale.status(); + } + const lanEndpoint = this.lanServer + && (state.connectionMode === "lan" || state.connectionMode === "both") + ? `https://${localDnsName(this.hostname)}:${state.lanPort}${AIDEN_REMOTE_BASE_PATH}` + : undefined; + const tailscaleEndpoint = tailscaleStatus.dnsName + ? `https://${tailscaleStatus.dnsName}${AIDEN_REMOTE_BASE_PATH}` + : undefined; + const target = this.loopbackTarget(state); + let tailscaleConnected = false; + let tailscaleRouteState: AidenTailscaleRouteState = "unavailable"; + let tailscaleErrorCode = tailscaleStatus.errorCode; + if (state.tailscalePendingOutcome) { + tailscaleRouteState = "reconciliation_required"; + } else if (this.options.tailscale.assessRoute && (state.connectionMode === "tailscale" || state.connectionMode === "both")) { + const assessment = await this.options.tailscale.assessRoute(target, state.tailscaleOwnership); + tailscaleRouteState = assessment.state; + tailscaleErrorCode = assessment.errorCode; + tailscaleConnected = assessment.state === "owned" && assessment.errorCode === undefined; + } else if (state.tailscaleOwnership && tailscaleStatus.serveStatus) { + try { + tailscaleConnected = planAidenTailscaleConnect( + tailscaleStatus.serveStatus, + target, + state.tailscaleOwnership, + ).action === "noop"; + tailscaleRouteState = tailscaleConnected ? "owned" : "unavailable"; + } catch { + tailscaleConnected = false; + } + } else if (tailscaleStatus.installed) { + tailscaleRouteState = "available"; + } + return { + enabled: state.enabled, + running: this.lanServer !== null || this.tailscaleServer !== null, + connectionMode: state.connectionMode, + lanPort: state.lanPort, + ...(lanEndpoint ? { lanEndpoint } : {}), + ...(tailscaleEndpoint ? { tailscaleEndpoint } : {}), + ...(state.connectionMode === "tailscale" || state.connectionMode === "both" + ? { tailscaleRoutePreview: `tailscale serve --yes --bg --https=443 --set-path=${AIDEN_REMOTE_BASE_PATH} ${target}` } + : {}), + tailscaleConnected, + tailscaleInstalled: tailscaleStatus.installed, + tailscaleRouteState, + ...(tailscaleErrorCode ? { tailscaleErrorCode } : {}), + pairedDeviceCount: state.devices.length, + approvedRootCount: state.approvedRoots.length, + ...(this.lastErrorCode ? { errorCode: this.lastErrorCode } : {}), + ...(this.lastError ? { error: this.lastError } : {}), + }; + } +} diff --git a/main/services/aiden-remote-state.test.ts b/main/services/aiden-remote-state.test.ts new file mode 100644 index 00000000..9820105d --- /dev/null +++ b/main/services/aiden-remote-state.test.ts @@ -0,0 +1,433 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { DataStore } from "./data-store.js"; +import { + AidenRemoteStateRegistry, + createDefaultAidenRemoteState, + defaultAidenRemoteDisplayName, + parseAidenRemoteStateDocument, + type AidenRemoteStateDocument, + type AidenRemoteStateStorage, +} from "./aiden-remote-state.js"; + +function fixture(initial?: unknown) { + let stored = initial ?? createDefaultAidenRemoteState(() => Buffer.alloc(24, 7)); + const writes: AidenRemoteStateDocument[] = []; + let failNextSave = false; + const storage: AidenRemoteStateStorage = { + load: async () => structuredClone(stored), + save: async (document) => { + if (failNextSave) { + failNextSave = false; + throw new Error("disk unavailable"); + } + stored = structuredClone(document); + writes.push(structuredClone(document)); + }, + }; + let randomCounter = 0; + let now = 1_000; + const registry = new AidenRemoteStateRegistry(storage, { + now: () => now, + randomBytes: (size) => Buffer.alloc(size, ++randomCounter), + deriveCredentialDigest: async (credential, salt) => + createHash("sha256").update(credential).update(salt).digest(), + }); + return { + registry, + writes, + stored: () => structuredClone(stored), + setNow: (value: number) => { + now = value; + }, + failNextSave: () => { + failNextSave = true; + }, + }; +} + +test("remote device credentials persist only digests and authenticate with capability state", async () => { + const state = fixture(); + await state.registry.initialize(); + const issued = await state.registry.issueDevice({ + name: "Sambit’s iPhone", + type: "iphone", + clientVersion: "1.0", + }); + assert.match(issued.credential, /^[A-Za-z0-9_-]{43}$/u); + const serialized = JSON.stringify(state.stored()); + assert.equal(serialized.includes(issued.credential), false); + assert.equal(serialized.includes("credentialDigest"), true); + assert.equal(serialized.includes("lookupDigest"), true); + assert.equal(issued.device.lastSeenAt, 0); + + const authenticated = await state.registry.authenticate(issued.credential); + assert.equal(authenticated?.id, issued.device.id); + assert.equal(authenticated?.revoked, false); + assert.equal(authenticated?.capabilities.has("workspace:manage"), true); + assert.equal((await state.registry.listDevices())[0]?.lastSeenAt, 1_000); + assert.equal(await state.registry.authenticate("x".repeat(43)), null); +}); + +test("device issuance checks pairing authorization inside the durable mutation", async () => { + const state = fixture(); + await assert.rejects( + state.registry.issueDevice({ + name: "Cancelled iPhone", + type: "iphone", + clientVersion: "1", + authorizeCommit: () => false, + }), + (error: unknown) => (error as { code?: string }).code === "pairing_closed", + ); + assert.deepEqual(await state.registry.listDevices(), []); + assert.equal(state.writes.length, 0); +}); + +test("revocation is durable and preserves the revoked classification", async () => { + const state = fixture(); + const issued = await state.registry.issueDevice({ + name: "iPad", + type: "ipad", + clientVersion: "1", + }); + assert.equal(await state.registry.revokeDevice(issued.device.id), true); + assert.equal(await state.registry.revokeDevice(issued.device.id), false); + assert.equal((await state.registry.authenticate(issued.credential))?.revoked, true); + assert.equal((await state.registry.listDevices())[0]?.revokedAt, 1_000); +}); + +test("revocation fences new device work and waits for already-admitted work without blocking another device", async () => { + const state = fixture(); + const first = await state.registry.issueDevice({ + name: "First iPhone", + type: "iphone", + clientVersion: "1", + }); + const second = await state.registry.issueDevice({ + name: "Second iPhone", + type: "iphone", + clientVersion: "1", + }); + const releaseFirst = state.registry.acquireDeviceAuthorization(first.device.id); + let revocationSettled = false; + const revocation = state.registry.revokeDevice(first.device.id).finally(() => { + revocationSettled = true; + }); + await Promise.resolve(); + assert.equal(revocationSettled, false); + assert.ok((await state.registry.snapshot()).devices.find(({ id }) => id === first.device.id)?.revokedAt); + assert.throws( + () => state.registry.acquireDeviceAuthorization(first.device.id), + (error: unknown) => (error as { code?: string }).code === "credential_revoked", + ); + const releaseSecond = state.registry.acquireDeviceAuthorization(second.device.id); + releaseSecond(); + + releaseFirst(); + assert.equal(await revocation, true); + assert.equal((await state.registry.authenticate(first.credential))?.revoked, true); + assert.equal((await state.registry.authenticate(second.credential))?.revoked, false); +}); + +test("failed revocation persistence reopens authorization for the still-valid device", async () => { + const state = fixture(); + const issued = await state.registry.issueDevice({ + name: "iPhone", + type: "iphone", + clientVersion: "1", + }); + state.failNextSave(); + await assert.rejects(state.registry.revokeDevice(issued.device.id), /disk unavailable/u); + const release = state.registry.acquireDeviceAuthorization(issued.device.id); + release(); + assert.equal((await state.registry.authenticate(issued.credential))?.revoked, false); +}); + +test("state restore rejects excess keys, duplicate identities, and raw credentials", () => { + const base = createDefaultAidenRemoteState(() => Buffer.alloc(24, 1)); + assert.throws( + () => parseAidenRemoteStateDocument({ ...base, extra: true }), + /state is invalid/u, + ); + assert.throws( + () => + parseAidenRemoteStateDocument({ + ...base, + devices: [ + { + id: "device", + name: "Phone", + type: "iphone", + clientVersion: "1", + lookupDigest: "a".repeat(43), + credentialSalt: "b".repeat(43), + credentialDigest: "c".repeat(43), + capabilities: ["server:read"], + createdAt: 1, + lastSeenAt: 1, + credential: "must-not-be-retained", + }, + ], + }), + /invalid device/u, + ); +}); + +test("remote access is off by default and persists an explicit listen mode", async () => { + const state = fixture(); + const initial = await state.registry.initialize(); + assert.equal(initial.enabled, false); + assert.equal(initial.lanPortCommitted, false); + assert.equal((await state.registry.snapshot()).connectionMode, "lan"); + await state.registry.setConnectionMode("both"); + assert.equal((await state.registry.snapshot()).connectionMode, "both"); +}); + +test("pending Tailscale outcomes are bounded and commit atomically with ownership", async () => { + const state = fixture(); + const target = "http://127.0.0.1:49221/api/aiden/v1"; + const pending = { + operation: "connect" as const, + target, + beforeFingerprint: "a".repeat(64), + preservedFingerprint: "b".repeat(64), + normalizeListenerScaffolding: true, + createdAt: 1_000, + }; + await state.registry.beginTailscalePendingOutcome(pending); + assert.deepEqual((state.stored() as AidenRemoteStateDocument).tailscalePendingOutcome, pending); + await assert.rejects( + state.registry.beginTailscalePendingOutcome({ ...pending, operation: "disconnect" }), + /tailscale_reconciliation_required/u, + ); + await state.registry.commitTailscaleOutcome({ path: "/api/aiden/v1", target }); + assert.deepEqual((state.stored() as AidenRemoteStateDocument).tailscaleOwnership, { path: "/api/aiden/v1", target }); + assert.equal((state.stored() as AidenRemoteStateDocument).tailscalePendingOutcome, undefined); + assert.throws( + () => parseAidenRemoteStateDocument({ + ...state.stored(), + tailscalePendingOutcome: { ...pending, beforeFingerprint: "not-a-digest" }, + }), + /invalid pending Tailscale outcome/u, + ); +}); + +test("legacy endpoint commitment is inferred conservatively and persisted", async () => { + const freshLegacy = createDefaultAidenRemoteState(() => Buffer.alloc(24, 2)); + delete (freshLegacy as Partial).lanPortCommitted; + assert.equal(parseAidenRemoteStateDocument(freshLegacy).lanPortCommitted, false); + + const enabledLegacy = { ...freshLegacy, enabled: true }; + assert.equal(parseAidenRemoteStateDocument(enabledLegacy).lanPortCommitted, true); + + const pairedLegacy = createDefaultAidenRemoteState(() => Buffer.alloc(24, 3)); + delete (pairedLegacy as Partial).lanPortCommitted; + const paired = fixture(pairedLegacy); + await paired.registry.issueDevice({ name: "Phone", type: "iphone", clientVersion: "1" }); + const pairedDocument = paired.stored(); + delete (pairedDocument as Partial).lanPortCommitted; + assert.equal(parseAidenRemoteStateDocument(pairedDocument).lanPortCommitted, true); + + const migrated = fixture(freshLegacy); + const initialized = await migrated.registry.initialize(); + assert.equal(initialized.lanPortCommitted, false); + assert.equal((migrated.stored() as AidenRemoteStateDocument).lanPortCommitted, false); + assert.equal(migrated.writes.length, 1); +}); + +test("committing an endpoint is durable, idempotent, and validates complete port pairs", async () => { + const state = fixture(); + await state.registry.initialize(); + await state.registry.commitLanPort(50_200); + assert.equal((state.stored() as AidenRemoteStateDocument).lanPort, 50_200); + assert.equal((state.stored() as AidenRemoteStateDocument).lanPortCommitted, true); + const writesAfterCommit = state.writes.length; + await state.registry.commitLanPort(50_200); + assert.equal(state.writes.length, writesAfterCommit); + await assert.rejects(state.registry.commitLanPort(65_535), /listener port/u); + await assert.rejects(state.registry.commitLanPort(50_201), /listener port/u); +}); + +test("legacy state gains a bounded computer label without changing stable identity", async () => { + const legacy = createDefaultAidenRemoteState(() => Buffer.alloc(24, 4)); + const instanceId = legacy.instanceId; + delete (legacy as Partial).displayName; + const migrated = parseAidenRemoteStateDocument(legacy, "Sambit’s Mac Studio.local"); + assert.equal(migrated.instanceId, instanceId); + assert.equal(migrated.displayName, "Sambit’s Mac Studio"); + assert.equal(defaultAidenRemoteDisplayName(" "), "Aiden Agent"); + + const persisted = fixture(legacy); + const initialized = await persisted.registry.initialize(); + assert.equal(initialized.instanceId, instanceId); + assert.equal(initialized.displayName, "Aiden Agent"); + assert.equal((persisted.stored() as AidenRemoteStateDocument).displayName, "Aiden Agent"); + assert.equal(persisted.writes.length, 1, "initialization durably writes the normalized legacy document"); +}); + +test("current state initialization is read-only when no migration is needed", async () => { + const current = createDefaultAidenRemoteState(() => Buffer.alloc(24, 5), "Current Mac"); + const state = fixture(current); + const initialized = await state.registry.initialize(); + assert.equal(initialized.displayName, "Current Mac"); + assert.equal(state.writes.length, 0); +}); + +test("production-shaped storage durably seeds missing files and migrates legacy labels", async () => { + const root = await mkdtemp(join(tmpdir(), "aiden-remote-state-storage-")); + const filename = "aiden-remote-state.json"; + const makeRegistry = () => { + const store = new DataStore( + filename, + createDefaultAidenRemoteState(() => Buffer.alloc(24, 9), "Studio Mac"), + () => root, + { normalize: (value) => parseAidenRemoteStateDocument(value, "Studio Mac") }, + ); + return new AidenRemoteStateRegistry({ + load: () => store.load(), + needsSaveAfterLoad: async () => { + const contents = await store.loadedDiskContents(); + if (contents === null) return true; + const raw = JSON.parse(contents.toString("utf8")) as Record; + return !("displayName" in raw) || !("lanPortCommitted" in raw); + }, + save: (document) => store.save(document), + }); + }; + + try { + const seeded = await makeRegistry().initialize(); + const seededDisk = JSON.parse(await readFile(join(root, filename), "utf8")) as AidenRemoteStateDocument; + assert.equal(seededDisk.instanceId, seeded.instanceId); + assert.equal(seededDisk.displayName, "Studio Mac"); + assert.equal(seededDisk.lanPortCommitted, false); + + const legacy = createDefaultAidenRemoteState(() => Buffer.alloc(24, 3), "Old Name"); + delete (legacy as Partial).displayName; + delete (legacy as Partial).lanPortCommitted; + await writeFile(join(root, filename), JSON.stringify(legacy), { mode: 0o600 }); + const migrated = await makeRegistry().initialize(); + const migratedDisk = JSON.parse(await readFile(join(root, filename), "utf8")) as AidenRemoteStateDocument; + assert.equal(migrated.instanceId, legacy.instanceId); + assert.equal(migratedDisk.displayName, "Studio Mac"); + assert.equal(migratedDisk.lanPortCommitted, false); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("renaming a Mac label is durable and never rotates instance or device identity", async () => { + const state = fixture(); + const before = await state.registry.initialize(); + const issued = await state.registry.issueDevice({ + name: "Phone", + type: "iphone", + clientVersion: "1", + }); + await state.registry.setDisplayName(" Studio Mac "); + const after = await state.registry.snapshot(); + assert.equal(after.displayName, "Studio Mac"); + assert.equal(after.instanceId, before.instanceId); + assert.equal(after.devices[0]?.id, issued.device.id); + await assert.rejects(state.registry.setDisplayName("\n"), /display name/u); + await assert.rejects(state.registry.setDisplayName("x".repeat(81)), /display name/u); +}); + +test("first authentication is durable, later last-seen writes are throttled, and revocation wins", async () => { + const state = fixture(); + const issued = await state.registry.issueDevice({ + name: "Phone", + type: "iphone", + clientVersion: "1", + }); + const writesAfterIssue = state.writes.length; + state.setNow(1_000 + 60_000); + await state.registry.authenticate(issued.credential); + assert.equal(state.writes.length, writesAfterIssue + 1); + await state.registry.authenticate(issued.credential); + assert.equal(state.writes.length, writesAfterIssue + 1); + state.setNow(1_000 + 7 * 60_000); + await state.registry.authenticate(issued.credential); + assert.equal(state.writes.length, writesAfterIssue + 2); + await state.registry.revokeDevice(issued.device.id); + state.setNow(1_000 + 12 * 60_000); + assert.equal((await state.registry.authenticate(issued.credential))?.revoked, true); +}); + +test("concurrent first authenticated requests produce one durable connection transition", async () => { + const state = fixture(); + const issued = await state.registry.issueDevice({ + name: "Phone", + type: "iphone", + clientVersion: "1", + }); + const writesAfterIssue = state.writes.length; + state.setNow(2_000); + + const authenticated = await Promise.all( + Array.from({ length: 8 }, () => state.registry.authenticate(issued.credential)), + ); + + assert.equal(authenticated.every((device) => device?.id === issued.device.id), true); + assert.equal(state.writes.length, writesAfterIssue + 1); + assert.equal((await state.registry.listDevices())[0]?.lastSeenAt, 2_000); +}); + +test("a failed first-contact save remains pending and retries durably", async () => { + const state = fixture(); + const issued = await state.registry.issueDevice({ + name: "Phone", + type: "iphone", + clientVersion: "1", + }); + state.setNow(2_000); + state.failNextSave(); + + await assert.rejects(state.registry.authenticate(issued.credential), /disk unavailable/u); + assert.equal((await state.registry.listDevices())[0]?.lastSeenAt, 0); + assert.equal((await state.registry.authenticate(issued.credential))?.revoked, false); + assert.equal((await state.registry.listDevices())[0]?.lastSeenAt, 2_000); +}); + +test("revocation completed during credential verification wins before authentication returns", async () => { + let stored = createDefaultAidenRemoteState(() => Buffer.alloc(24, 5)); + let blockDigest = false; + let releaseDigest: (() => void) | undefined; + const digestGate = new Promise((resolve) => { + releaseDigest = resolve; + }); + const registry = new AidenRemoteStateRegistry( + { + load: async () => structuredClone(stored), + save: async (document) => { + stored = structuredClone(document); + }, + }, + { + now: () => 3_000, + randomBytes: (size) => Buffer.alloc(size, 9), + deriveCredentialDigest: async (credential, salt) => { + if (blockDigest) await digestGate; + return createHash("sha256").update(credential).update(salt).digest(); + }, + }, + ); + const issued = await registry.issueDevice({ + name: "Phone", + type: "iphone", + clientVersion: "1", + }); + blockDigest = true; + const authentication = registry.authenticate(issued.credential); + await Promise.resolve(); + assert.equal(await registry.revokeDevice(issued.device.id), true); + releaseDigest?.(); + + assert.equal((await authentication)?.revoked, true); + assert.equal((await registry.listDevices())[0]?.lastSeenAt, 0); +}); diff --git a/main/services/aiden-remote-state.ts b/main/services/aiden-remote-state.ts new file mode 100644 index 00000000..9bcd0d3e --- /dev/null +++ b/main/services/aiden-remote-state.ts @@ -0,0 +1,788 @@ +import { + createHash, + randomBytes, + scrypt, + timingSafeEqual, +} from "node:crypto"; +import type { AidenRemoteCapability } from "./aiden-remote-protocol.js"; +import { AIDEN_REMOTE_CAPABILITIES } from "./aiden-remote-protocol.js"; +import type { AidenTailscaleOwnership } from "./aiden-remote-tailscale-route.js"; +import type { AidenTailscalePendingRouteOutcome } from "./aiden-remote-tailscale.js"; +import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; + +const STATE_VERSION = 1 as const; +const DEFAULT_LAN_PORT = 49_220; +const MAX_DEVICES = 32; +const MAX_RETAINED_DEVICES = 128; +const MAX_APPROVED_ROOTS = 32; +const LAST_SEEN_WRITE_INTERVAL_MS = 5 * 60_000; +export const MAX_AIDEN_REMOTE_DISPLAY_NAME_CHARACTERS = 80; +const FALLBACK_AIDEN_REMOTE_DISPLAY_NAME = "Aiden Agent"; + +export type AidenRemoteDeviceType = "iphone" | "ipad"; +export type AidenRemoteConnectionMode = "lan" | "tailscale" | "both"; + +interface StoredAidenRemoteDevice { + id: string; + name: string; + type: AidenRemoteDeviceType; + clientVersion: string; + lookupDigest: string; + credentialSalt: string; + credentialDigest: string; + capabilities: AidenRemoteCapability[]; + createdAt: number; + lastSeenAt: number; + revokedAt?: number; +} + +export interface AidenRemoteApprovedRoot { + id: string; + label: string; + folderPath: string; + device: string; + inode: string; + policyRevision: string; + createdAt: number; +} + +export interface AidenRemoteStateDocument { + version: typeof STATE_VERSION; + instanceId: string; + displayName: string; + enabled: boolean; + connectionMode: AidenRemoteConnectionMode; + lanPort: number; + lanPortCommitted: boolean; + devices: StoredAidenRemoteDevice[]; + approvedRoots: AidenRemoteApprovedRoot[]; + tailscaleOwnership?: AidenTailscaleOwnership; + tailscalePendingOutcome?: AidenTailscalePendingRouteOutcome; +} + +export interface AidenRemoteStateStorage { + load(): Promise; + save(document: AidenRemoteStateDocument): Promise; + needsSaveAfterLoad?(): Promise; +} + +export interface AidenRemoteDeviceProjection { + id: string; + name: string; + type: AidenRemoteDeviceType; + clientVersion: string; + capabilities: AidenRemoteCapability[]; + createdAt: number; + lastSeenAt: number; + revokedAt?: number; +} + +export interface AidenRemoteAuthenticatedDevice { + id: string; + capabilities: ReadonlySet; + revoked: boolean; +} + +export interface AidenRemoteIssuedCredential { + credential: string; + device: AidenRemoteDeviceProjection; +} + +export interface AidenRemoteStateDependencies { + now(): number; + randomBytes(size: number): Buffer; + deriveCredentialDigest(credential: string, salt: Buffer): Promise; +} + +function ownRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function exactKeys( + record: Record, + required: readonly string[], + optional: readonly string[] = [], +): boolean { + const allowed = new Set([...required, ...optional]); + return ( + required.every((key) => Object.prototype.hasOwnProperty.call(record, key)) && + Object.keys(record).every((key) => allowed.has(key)) + ); +} + +function boundedString(value: unknown, maximum: number): value is string { + if (typeof value !== "string" || value.length === 0) return false; + let characters = 0; + for (const _character of value) characters += 1; + return characters <= maximum; +} + +export function normalizeAidenRemoteDisplayName(value: unknown): string { + if (typeof value !== "string") { + throw new Error("Aiden Remote display name must be text."); + } + const normalized = value.trim().replace(/\s+/gu, " "); + if ( + !boundedString(normalized, MAX_AIDEN_REMOTE_DISPLAY_NAME_CHARACTERS) || + /[\p{Cc}\p{Cf}]/u.test(normalized) + ) { + throw new Error( + `Aiden Remote display name must be 1–${MAX_AIDEN_REMOTE_DISPLAY_NAME_CHARACTERS} visible characters.`, + ); + } + return normalized; +} + +export function defaultAidenRemoteDisplayName(computerName: string): string { + const withoutLocalSuffix = computerName.trim().replace(/\.local\.?$/iu, ""); + try { + return normalizeAidenRemoteDisplayName(withoutLocalSuffix); + } catch { + return FALLBACK_AIDEN_REMOTE_DISPLAY_NAME; + } +} + +function digestString(value: unknown): value is string { + return typeof value === "string" && /^[A-Za-z0-9_-]{43}$/u.test(value); +} + +function safeTimestamp(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function parseCapabilities(value: unknown): AidenRemoteCapability[] | null { + if (!Array.isArray(value) || value.length > AIDEN_REMOTE_CAPABILITIES.length) { + return null; + } + const capabilities = new Set(); + for (const capability of value) { + if ( + typeof capability !== "string" || + !(AIDEN_REMOTE_CAPABILITIES as readonly string[]).includes(capability) || + capabilities.has(capability as AidenRemoteCapability) + ) { + return null; + } + capabilities.add(capability as AidenRemoteCapability); + } + return [...capabilities]; +} + +function parseDevice(value: unknown): StoredAidenRemoteDevice | null { + const record = ownRecord(value); + const required = [ + "id", + "name", + "type", + "clientVersion", + "lookupDigest", + "credentialSalt", + "credentialDigest", + "capabilities", + "createdAt", + "lastSeenAt", + ] as const; + if (!record || !exactKeys(record, required, ["revokedAt"])) return null; + const capabilities = parseCapabilities(record.capabilities); + if ( + !boundedString(record.id, 128) || + !boundedString(record.name, 80) || + (record.type !== "iphone" && record.type !== "ipad") || + !boundedString(record.clientVersion, 40) || + !digestString(record.lookupDigest) || + !digestString(record.credentialSalt) || + !digestString(record.credentialDigest) || + !capabilities || + !safeTimestamp(record.createdAt) || + !safeTimestamp(record.lastSeenAt) || + (record.revokedAt !== undefined && !safeTimestamp(record.revokedAt)) + ) { + return null; + } + return { + id: record.id, + name: record.name, + type: record.type, + clientVersion: record.clientVersion, + lookupDigest: record.lookupDigest, + credentialSalt: record.credentialSalt, + credentialDigest: record.credentialDigest, + capabilities, + createdAt: record.createdAt, + lastSeenAt: record.lastSeenAt, + ...(record.revokedAt === undefined ? {} : { revokedAt: record.revokedAt }), + }; +} + +function parseApprovedRoot(value: unknown): AidenRemoteApprovedRoot | null { + const record = ownRecord(value); + if ( + !record || + !exactKeys(record, [ + "id", + "label", + "folderPath", + "device", + "inode", + "policyRevision", + "createdAt", + ]) || + !boundedString(record.id, 128) || + !boundedString(record.label, 160) || + !boundedString(record.folderPath, 4_096) || + !boundedString(record.device, 64) || + !boundedString(record.inode, 64) || + !boundedString(record.policyRevision, 128) || + !safeTimestamp(record.createdAt) + ) { + return null; + } + return record as unknown as AidenRemoteApprovedRoot; +} + +function parseOwnership(value: unknown): AidenTailscaleOwnership | undefined { + if (value === undefined) return undefined; + const record = ownRecord(value); + if ( + !record || + !exactKeys(record, ["path", "target"]) || + record.path !== "/api/aiden/v1" || + !boundedString(record.target, 2_048) + ) { + throw new Error("Aiden Remote state has an invalid Tailscale ownership record."); + } + return { path: record.path, target: record.target }; +} + +function parsePendingOutcome(value: unknown): AidenTailscalePendingRouteOutcome | undefined { + if (value === undefined) return undefined; + const record = ownRecord(value); + if ( + !record + || !exactKeys( + record, + [ + "operation", + "target", + "beforeFingerprint", + "preservedFingerprint", + "normalizeListenerScaffolding", + "createdAt", + ], + ["previousTarget"], + ) + || !["connect", "takeover", "disconnect"].includes(String(record.operation)) + || !boundedString(record.target, 2_048) + || (record.previousTarget !== undefined && !boundedString(record.previousTarget, 2_048)) + || typeof record.beforeFingerprint !== "string" + || !/^[a-f0-9]{64}$/u.test(record.beforeFingerprint) + || typeof record.preservedFingerprint !== "string" + || !/^[a-f0-9]{64}$/u.test(record.preservedFingerprint) + || typeof record.normalizeListenerScaffolding !== "boolean" + || !safeTimestamp(record.createdAt) + ) { + throw new Error("Aiden Remote state has an invalid pending Tailscale outcome."); + } + return record as unknown as AidenTailscalePendingRouteOutcome; +} + +export function createDefaultAidenRemoteState( + random: (size: number) => Buffer = randomBytes, + displayName = FALLBACK_AIDEN_REMOTE_DISPLAY_NAME, +): AidenRemoteStateDocument { + return { + version: STATE_VERSION, + instanceId: `instance_${random(24).toString("base64url")}`, + displayName: normalizeAidenRemoteDisplayName(displayName), + enabled: false, + connectionMode: "lan", + lanPort: DEFAULT_LAN_PORT, + lanPortCommitted: false, + devices: [], + approvedRoots: [], + }; +} + +export function parseAidenRemoteStateDocument( + value: unknown, + legacyDisplayName = FALLBACK_AIDEN_REMOTE_DISPLAY_NAME, +): AidenRemoteStateDocument { + const record = ownRecord(value); + if ( + !record || + !exactKeys( + record, + ["version", "instanceId", "enabled", "connectionMode", "lanPort", "devices", "approvedRoots"], + ["displayName", "lanPortCommitted", "tailscaleOwnership", "tailscalePendingOutcome"], + ) || + record.version !== STATE_VERSION || + !boundedString(record.instanceId, 128) || + typeof record.enabled !== "boolean" || + !["lan", "tailscale", "both"].includes(String(record.connectionMode)) || + !Number.isInteger(record.lanPort) || + Number(record.lanPort) < 1 || + Number(record.lanPort) > 65_535 || + (record.lanPortCommitted !== undefined && typeof record.lanPortCommitted !== "boolean") || + !Array.isArray(record.devices) || + record.devices.length > MAX_RETAINED_DEVICES || + !Array.isArray(record.approvedRoots) || + record.approvedRoots.length > MAX_APPROVED_ROOTS + ) { + throw new Error("Aiden Remote state is invalid."); + } + const devices = record.devices.map(parseDevice); + const approvedRoots = record.approvedRoots.map(parseApprovedRoot); + if (devices.some((device) => device === null)) { + throw new Error("Aiden Remote state contains an invalid device."); + } + if (approvedRoots.some((root) => root === null)) { + throw new Error("Aiden Remote state contains an invalid approved root."); + } + const deviceIds = new Set(devices.map((device) => device!.id)); + const lookupDigests = new Set(devices.map((device) => device!.lookupDigest)); + const rootIds = new Set(approvedRoots.map((root) => root!.id)); + if (deviceIds.size !== devices.length || lookupDigests.size !== devices.length) { + throw new Error("Aiden Remote state contains duplicate device identity."); + } + if (rootIds.size !== approvedRoots.length) { + throw new Error("Aiden Remote state contains duplicate approved-root identity."); + } + const tailscaleOwnership = parseOwnership(record.tailscaleOwnership); + const tailscalePendingOutcome = parsePendingOutcome(record.tailscalePendingOutcome); + return { + version: STATE_VERSION, + instanceId: record.instanceId, + displayName: record.displayName === undefined + ? defaultAidenRemoteDisplayName(legacyDisplayName) + : normalizeAidenRemoteDisplayName(record.displayName), + enabled: record.enabled, + connectionMode: record.connectionMode as AidenRemoteConnectionMode, + lanPort: Number(record.lanPort), + lanPortCommitted: record.lanPortCommitted === undefined + ? Boolean(record.enabled || devices.length > 0 || tailscaleOwnership) + : record.lanPortCommitted, + devices: devices as StoredAidenRemoteDevice[], + approvedRoots: approvedRoots as AidenRemoteApprovedRoot[], + ...(tailscaleOwnership ? { tailscaleOwnership } : {}), + ...(tailscalePendingOutcome ? { tailscalePendingOutcome } : {}), + }; +} + +function fastLookupDigest(credential: string): string { + return createHash("sha256").update(credential).digest("base64url"); +} + +function decodeDigest(value: string): Buffer | null { + try { + const decoded = Buffer.from(value, "base64url"); + return decoded.length === 32 ? decoded : null; + } catch { + return null; + } +} + +function safeEqual(left: Buffer | null, right: Buffer): boolean { + return Boolean(left && left.length === right.length && timingSafeEqual(left, right)); +} + +function projectDevice(device: StoredAidenRemoteDevice): AidenRemoteDeviceProjection { + return { + id: device.id, + name: device.name, + type: device.type, + clientVersion: device.clientVersion, + capabilities: [...device.capabilities], + createdAt: device.createdAt, + lastSeenAt: device.lastSeenAt, + ...(device.revokedAt === undefined ? {} : { revokedAt: device.revokedAt }), + }; +} + +export function defaultAidenRemoteStateDependencies(): AidenRemoteStateDependencies { + return { + now: Date.now, + randomBytes, + deriveCredentialDigest: (credential, salt) => + new Promise((resolve, reject) => { + scrypt( + credential, + salt, + 32, + { N: 32_768, r: 8, p: 1, maxmem: 64 * 1_024 * 1_024 }, + (error, derived) => { + if (error) reject(error); + else resolve(derived as Buffer); + }, + ); + }), + }; +} + +export class AidenRemoteStateRegistry { + private document: AidenRemoteStateDocument | null = null; + private mutationTail: Promise = Promise.resolve(); + private readonly blockedDeviceAuthorizations = new Set(); + private readonly activeDeviceAuthorizations = new Map(); + private readonly authorizationWaiters = new Map void>>(); + private readonly pendingRevocations = new Map>(); + + constructor( + private readonly storage: AidenRemoteStateStorage, + private readonly dependencies: AidenRemoteStateDependencies = + defaultAidenRemoteStateDependencies(), + ) {} + + private serialized(operation: () => Promise): Promise { + const result = this.mutationTail.then(operation, operation); + this.mutationTail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async initialize(): Promise { + return this.serialized(async () => { + if (this.document) return structuredClone(this.document); + const raw = await this.storage.load(); + const loaded = parseAidenRemoteStateDocument(raw); + const rawRecord = ownRecord(raw); + const needsSave = this.storage.needsSaveAfterLoad + ? await this.storage.needsSaveAfterLoad() + : rawRecord?.displayName === undefined || rawRecord?.lanPortCommitted === undefined; + if (needsSave) { + await this.storage.save(loaded); + } + this.document = loaded; + return structuredClone(loaded); + }); + } + + private async current(): Promise { + if (!this.document) await this.initialize(); + return this.document!; + } + + private async mutate( + operation: (draft: AidenRemoteStateDocument) => Promise | T, + ): Promise { + // Initialize before joining the mutation queue. Calling initialize from + // inside a queued mutation would enqueue behind itself and deadlock. + if (!this.document) await this.initialize(); + return this.serialized(async () => { + const draft = structuredClone(await this.current()); + const result = await operation(draft); + const validated = parseAidenRemoteStateDocument(draft); + await this.storage.save(validated); + this.document = validated; + return result; + }); + } + + private async mutateIfChanged( + operation: ( + draft: AidenRemoteStateDocument, + ) => Promise<{ changed: boolean; value: T }> | { changed: boolean; value: T }, + ): Promise { + if (!this.document) await this.initialize(); + return this.serialized(async () => { + const draft = structuredClone(await this.current()); + const result = await operation(draft); + if (!result.changed) return result.value; + const validated = parseAidenRemoteStateDocument(draft); + await this.storage.save(validated); + this.document = validated; + return result.value; + }); + } + + async snapshot(): Promise { + await this.mutationTail; + return structuredClone(await this.current()); + } + + async setEnabled(enabled: boolean): Promise { + await this.mutate((draft) => { + draft.enabled = enabled; + }); + } + + async setConnectionMode(connectionMode: AidenRemoteConnectionMode): Promise { + if (!["lan", "tailscale", "both"].includes(connectionMode)) { + throw new Error("Invalid Aiden Remote connection mode."); + } + await this.mutate((draft) => { + draft.connectionMode = connectionMode; + }); + } + + async setDisplayName(displayName: string): Promise { + const normalized = normalizeAidenRemoteDisplayName(displayName); + await this.mutateIfChanged((draft) => { + if (draft.displayName === normalized) { + return { changed: false, value: undefined }; + } + draft.displayName = normalized; + return { changed: true, value: undefined }; + }); + } + + async commitLanPort(lanPort: number): Promise { + if (!Number.isInteger(lanPort) || lanPort < 1 || lanPort >= 65_535 || lanPort % 2 !== 0) { + throw new Error("Invalid Aiden Remote listener port."); + } + await this.mutateIfChanged((draft) => { + if (draft.lanPort === lanPort && draft.lanPortCommitted) { + return { changed: false, value: undefined }; + } + draft.lanPort = lanPort; + draft.lanPortCommitted = true; + return { changed: true, value: undefined }; + }); + } + + async listDevices(): Promise { + const document = await this.snapshot(); + return document.devices.map(projectDevice); + } + + async issueDevice(input: { + name: string; + type: AidenRemoteDeviceType; + clientVersion: string; + capabilities?: readonly AidenRemoteCapability[]; + authorizeCommit?: () => boolean; + }): Promise { + if ( + !boundedString(input.name, 80) || + (input.type !== "iphone" && input.type !== "ipad") || + !boundedString(input.clientVersion, 40) + ) { + throw new Error("Invalid pairing device metadata."); + } + const capabilities = parseCapabilities( + input.capabilities ?? AIDEN_REMOTE_CAPABILITIES, + ); + if (!capabilities) throw new Error("Invalid device capabilities."); + const credential = this.dependencies.randomBytes(32).toString("base64url"); + const salt = this.dependencies.randomBytes(32); + const credentialDigest = await this.dependencies.deriveCredentialDigest( + credential, + salt, + ); + if (credentialDigest.length !== 32) { + throw new Error("Aiden Remote credential derivation failed."); + } + const now = this.dependencies.now(); + const device: StoredAidenRemoteDevice = { + id: `device_${this.dependencies.randomBytes(24).toString("base64url")}`, + name: input.name, + type: input.type, + clientVersion: input.clientVersion, + lookupDigest: fastLookupDigest(credential), + credentialSalt: salt.toString("base64url"), + credentialDigest: credentialDigest.toString("base64url"), + capabilities, + createdAt: now, + // Credential issuance is not proof that the client persisted the + // credential and successfully authenticated back to this Mac. + lastSeenAt: 0, + }; + await this.mutate((draft) => { + if (input.authorizeCommit && !input.authorizeCommit()) { + throw new AidenRemoteServiceError( + "pairing_closed", + "This pairing window was closed before the device was created.", + 403, + ); + } + if (draft.devices.filter((entry) => entry.revokedAt === undefined).length >= MAX_DEVICES) { + throw new Error("Aiden Remote device capacity reached."); + } + if (draft.devices.length >= MAX_RETAINED_DEVICES) { + const revokedIndex = draft.devices.findIndex( + (entry) => entry.revokedAt !== undefined, + ); + if (revokedIndex < 0) throw new Error("Aiden Remote device capacity reached."); + draft.devices.splice(revokedIndex, 1); + } + draft.devices.push(device); + }); + return { credential, device: projectDevice(device) }; + } + + async authenticate(credential: string): Promise { + if (!digestString(credential)) return null; + await this.mutationTail; + const document = await this.current(); + const lookupDigest = fastLookupDigest(credential); + const device = document.devices.find( + (candidate) => candidate.lookupDigest === lookupDigest, + ); + if (!device) return null; + const salt = decodeDigest(device.credentialSalt); + const stored = decodeDigest(device.credentialDigest); + if (!salt || !stored) return null; + const actual = await this.dependencies.deriveCredentialDigest(credential, salt); + if (!safeEqual(stored, actual)) return null; + + const now = this.dependencies.now(); + return this.mutateIfChanged((draft) => { + // Re-resolve inside the mutation queue. Revocation or retention may have + // 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 authenticated: AidenRemoteAuthenticatedDevice = { + id: current.id, + capabilities: new Set(current.capabilities), + revoked: current.revokedAt !== undefined, + }; + const shouldPersistLastSeen = + !authenticated.revoked && + (current.lastSeenAt === 0 || now - current.lastSeenAt >= LAST_SEEN_WRITE_INTERVAL_MS); + if (shouldPersistLastSeen) current.lastSeenAt = now; + return { changed: shouldPersistLastSeen, value: authenticated }; + }); + } + + acquireDeviceAuthorization(deviceId: string, tracksMutationDrain = true): () => void { + if (this.blockedDeviceAuthorizations.has(deviceId)) { + throw new AidenRemoteServiceError( + "credential_revoked", + "This device was revoked in Aiden Settings.", + 403, + ); + } + if (tracksMutationDrain) { + this.activeDeviceAuthorizations.set( + deviceId, + (this.activeDeviceAuthorizations.get(deviceId) ?? 0) + 1, + ); + } + let released = false; + return () => { + if (released) return; + released = true; + if (!tracksMutationDrain) return; + const remaining = (this.activeDeviceAuthorizations.get(deviceId) ?? 1) - 1; + if (remaining > 0) { + this.activeDeviceAuthorizations.set(deviceId, remaining); + return; + } + this.activeDeviceAuthorizations.delete(deviceId); + const waiters = this.authorizationWaiters.get(deviceId); + this.authorizationWaiters.delete(deviceId); + for (const resolve of waiters ?? []) resolve(); + }; + } + + private waitForDeviceAuthorizations(deviceId: string): Promise { + if ((this.activeDeviceAuthorizations.get(deviceId) ?? 0) === 0) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const waiters = this.authorizationWaiters.get(deviceId) ?? new Set<() => void>(); + waiters.add(resolve); + this.authorizationWaiters.set(deviceId, waiters); + }); + } + + async revokeDevice(deviceId: string): Promise { + if (!boundedString(deviceId, 128)) return false; + const inFlight = this.pendingRevocations.get(deviceId); + if (inFlight) return inFlight; + const pending = (async () => { + this.blockedDeviceAuthorizations.add(deviceId); + let status: "revoked" | "already_revoked" | "missing"; + try { + status = await this.mutate((draft) => { + const device = draft.devices.find((candidate) => candidate.id === deviceId); + if (!device) return "missing" as const; + if (device.revokedAt !== undefined) return "already_revoked" as const; + device.revokedAt = this.dependencies.now(); + return "revoked" as const; + }); + } catch (error) { + this.blockedDeviceAuthorizations.delete(deviceId); + throw error; + } + if (status === "missing") this.blockedDeviceAuthorizations.delete(deviceId); + // Revocation is durable before waiting for already-admitted mutations. + // A stalled application operation may delay cleanup, but it can never + // make the credential valid again after a restart. + if (status !== "missing") await this.waitForDeviceAuthorizations(deviceId); + return status === "revoked"; + })(); + this.pendingRevocations.set(deviceId, pending); + try { + return await pending; + } finally { + if (this.pendingRevocations.get(deviceId) === pending) { + this.pendingRevocations.delete(deviceId); + } + } + } + + async setTailscaleOwnership( + ownership: AidenTailscaleOwnership | undefined, + ): Promise { + await this.mutate((draft) => { + if (ownership) draft.tailscaleOwnership = ownership; + else delete draft.tailscaleOwnership; + }); + } + + async beginTailscalePendingOutcome( + outcome: AidenTailscalePendingRouteOutcome, + ): Promise { + const validated = parsePendingOutcome(outcome); + if (!validated) throw new Error("Invalid pending Tailscale outcome."); + await this.mutate((draft) => { + if (draft.tailscalePendingOutcome) { + throw new Error("tailscale_reconciliation_required"); + } + draft.tailscalePendingOutcome = validated; + }); + } + + async clearTailscalePendingOutcome(): Promise { + await this.mutate((draft) => { + delete draft.tailscalePendingOutcome; + }); + } + + async commitTailscaleOutcome( + ownership: AidenTailscaleOwnership | undefined, + ): Promise { + await this.mutate((draft) => { + if (ownership) draft.tailscaleOwnership = ownership; + else delete draft.tailscaleOwnership; + delete draft.tailscalePendingOutcome; + }); + } + + async addApprovedRoot(root: AidenRemoteApprovedRoot): Promise { + if (!parseApprovedRoot(root)) throw new Error("Invalid approved root."); + await this.mutate((draft) => { + if (draft.approvedRoots.length >= MAX_APPROVED_ROOTS) { + throw new Error("Aiden Remote approved-root capacity reached."); + } + if (draft.approvedRoots.some((candidate) => candidate.id === root.id)) { + throw new Error("This approved root already exists."); + } + draft.approvedRoots.push(root); + }); + } + + async removeApprovedRoot(rootId: string): Promise { + if (!boundedString(rootId, 128)) return false; + return this.mutate((draft) => { + const index = draft.approvedRoots.findIndex((root) => root.id === rootId); + if (index < 0) return false; + draft.approvedRoots.splice(index, 1); + return true; + }); + } +} diff --git a/main/services/aiden-remote-streams.test.ts b/main/services/aiden-remote-streams.test.ts new file mode 100644 index 00000000..f4e079c1 --- /dev/null +++ b/main/services/aiden-remote-streams.test.ts @@ -0,0 +1,478 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; +import type { ServerResponse } from "node:http"; +import { + AidenRemoteStreamService, + normalizeAidenRemoteStreamSnapshot, + removeRevokedDeviceStreams, +} from "./aiden-remote-streams.js"; + +function fixture() { + let now = 1_000; + const cancelled: string[] = []; + const approvals: string[] = []; + const service = new AidenRemoteStreamService({ + now: () => now, + cancel: (streamId, ownerId) => { + cancelled.push(`${streamId}:${ownerId}`); + return true; + }, + approve: (approvalId, decision, ownerId) => { + approvals.push(`${approvalId}:${decision}:${ownerId}`); + return true; + }, + }); + return { service, cancelled, approvals, setNow: (value: number) => { now = value; } }; +} + +test("remote stream journals typed events and is isolated to its paired device", () => { + const app = fixture(); + const owner = app.service.create("device-1", "stream-1", "chat-1", "turn-1"); + owner.owner.send("chat:delta", { streamId: "stream-1", delta: "Hello" }); + owner.owner.send("chat:reasoning-delta", { streamId: "stream-1", delta: "Think" }); + owner.owner.send("chat:tool", { streamId: "stream-1", phase: "call", toolName: "read_file" }); + owner.owner.send("chat:tool", { streamId: "stream-1", phase: "result", toolName: "read_file" }); + owner.owner.send("chat:done", { + streamId: "stream-1", + chat: { messages: [{ id: "assistant-1", role: "assistant" }] }, + }); + const status = app.service.status("device-1", "stream-1"); + assert.equal(status.state, "done"); + assert.equal(status.lastSequence, 6); + assert.throws( + () => app.service.status("device-2", "stream-1"), + (error: unknown) => (error as { code?: string }).code === "not_found", + ); +}); + +test("remote stream forwards the renderer-safe chronological timeline without raw tool data", () => { + const app = fixture(); + const owner = app.service.create("device-1", "stream-1", "chat-1", "turn-1"); + const timeline = { + version: 3, + generationId: "stream-1", + status: "running", + startedAt: 1_000, + steps: [{ + id: "tool-1", + order: 0, + kind: "tool", + toolCallId: "call-1", + toolName: "run_command", + label: "Run command", + status: "running", + startedAt: 1_000, + updatedAt: 1_000, + contentOffset: 0, + detail: "Check project status", + }], + }; + owner.owner.send("chat:timeline", { timeline, rawCommand: "cat ~/.ssh/id_rsa" }); + const event = app.service.snapshot().streams[0]?.events[1]; + assert.equal(event?.type, "timeline"); + assert.deepEqual(event?.payload, { timeline }); + assert.doesNotMatch(JSON.stringify(event), /cat |\.ssh/u); +}); + +test("Mac-side cancellation is projected as cancelled instead of a successful completion", () => { + const app = fixture(); + const owner = app.service.create("device-1", "stream-1", "chat-1", "turn-1"); + owner.owner.send("chat:delta", { delta: "Partial" }); + owner.owner.send("chat:done", { + chat: { messages: [{ id: "assistant-1", role: "assistant" }] }, + timeline: { + version: 3, + generationId: "stream-1", + status: "cancelled", + startedAt: 1_000, + finishedAt: 2_000, + cancellationOrigin: "user_stop", + steps: [], + }, + }); + const status = app.service.status("device-1", "stream-1"); + const events = app.service.snapshot().streams[0]?.events ?? []; + const terminalEvent = events[events.length - 1]; + assert.equal(status.state, "cancelled"); + assert.equal(terminalEvent?.type, "cancelled"); + assert.deepEqual(terminalEvent?.payload, { source: "server" }); +}); + +test("Mac-side initialization cancellation without a timeline remains a terminal cancellation", () => { + const app = fixture(); + const owner = app.service.create("device-1", "stream-1", "chat-1", "turn-1"); + owner.owner.send("chat:done", { + streamId: "stream-1", + content: "", + cancelled: true, + cancellationOrigin: "user_stop", + }); + const status = app.service.status("device-1", "stream-1"); + const events = app.service.snapshot().streams[0]?.events ?? []; + const terminal = events[events.length - 1]; + assert.equal(status.state, "cancelled"); + assert.equal(terminal?.type, "cancelled"); + assert.deepEqual(terminal?.payload, { source: "server" }); +}); + +test("provider failure remains a replayable terminal error with its safe message", () => { + const app = fixture(); + const owner = app.service.create("device-1", "stream-1", "chat-1", "turn-1"); + owner.owner.send("chat:error", { message: "The model provider could not complete this response." }); + assert.equal(app.service.status("device-1", "stream-1").state, "error"); + + const output: string[] = []; + let ended = false; + const response = Object.assign(new EventEmitter(), { + writeHead() { return this; }, + write(value: string) { output.push(value); return true; }, + end() { ended = true; return this; }, + }) as unknown as ServerResponse; + app.service.openEvents("device-1", "stream-1", 0, response); + assert.equal(ended, true); + assert.match(output.join(""), /event: error/u); + assert.match(output.join(""), /The model provider could not complete this response\./u); +}); + +test("explicit cancellation dominates a racing provider error and private diagnostics stay local", async () => { + const app = fixture(); + const owner = app.service.create("device-1", "stream-1", "chat-1", "turn-1"); + await app.service.cancel("device-1", "stream-1", "cancel-race-key-0001"); + owner.owner.send("chat:error", { + message: "/Users/private/project: provider token sk-private failed", + }); + const events = app.service.snapshot().streams[0]?.events ?? []; + const terminal = events[events.length - 1]; + assert.equal(app.service.status("device-1", "stream-1").state, "cancelled"); + assert.equal(terminal?.type, "cancelled"); + assert.doesNotMatch(JSON.stringify(terminal), /Users|sk-private/u); +}); + +test("provider errors expose only fixed product-owned copy", () => { + const app = fixture(); + const owner = app.service.create("device-1", "stream-1", "chat-1", "turn-1"); + owner.owner.send("chat:error", { + message: "/Users/private/project: provider token sk-private failed", + }); + const events = app.service.snapshot().streams[0]?.events ?? []; + const terminal = events[events.length - 1]; + assert.equal(terminal?.type, "error"); + assert.deepEqual(terminal?.payload, { + code: "internal_error", + message: "The model provider could not complete this response.", + }); +}); + +test("subscriber disconnect does not cancel work and reconnect replays completion", () => { + const app = fixture(); + const owner = app.service.create("device-1", "stream-1", "chat-1", "turn-1"); + const firstOutput: string[] = []; + const first = Object.assign(new EventEmitter(), { + writeHead() { return this; }, + write(value: string) { firstOutput.push(value); return true; }, + end() { return this; }, + }) as unknown as ServerResponse; + app.service.openEvents("device-1", "stream-1", 0, first); + first.emit("close"); + owner.owner.send("chat:delta", { delta: "Finished while offline" }); + owner.owner.send("chat:done", { chat: { messages: [{ id: "assistant-1", role: "assistant" }] } }); + assert.equal(app.service.status("device-1", "stream-1").state, "done"); + + const replayOutput: string[] = []; + let replayEnded = false; + const replay = Object.assign(new EventEmitter(), { + writeHead() { return this; }, + write(value: string) { replayOutput.push(value); return true; }, + end() { replayEnded = true; return this; }, + }) as unknown as ServerResponse; + app.service.openEvents("device-1", "stream-1", 1, replay); + assert.equal(replayEnded, true); + assert.match(replayOutput.join(""), /Finished while offline/u); + assert.match(replayOutput.join(""), /event: done/u); +}); + +test("SSE replay emits frozen envelopes and closes after a terminal event", () => { + const app = fixture(); + const owner = app.service.create("device-1", "stream-1", "chat-1", "turn-1"); + owner.owner.send("chat:delta", { delta: "Hello" }); + owner.owner.send("chat:done", { chat: { messages: [{ id: "assistant-1", role: "assistant" }] } }); + const output: string[] = []; + let status = 0; + let ended = false; + const response = Object.assign(new EventEmitter(), { + writeHead(value: number) { status = value; return this; }, + write(value: string) { output.push(value); return true; }, + end() { ended = true; return this; }, + }) as unknown as ServerResponse; + app.service.openEvents("device-1", "stream-1", 0, response); + assert.equal(status, 200); + assert.equal(ended, true); + assert.match(output.join(""), /event: text_delta/u); + assert.match(output.join(""), /"messageId":"assistant-1"/u); +}); + +test("cancel and approval decisions are bound to the owning device and owner identity", async () => { + const app = fixture(); + const owner = app.service.create("device-1", "stream-1", "chat-1", "turn-1"); + owner.owner.send("chat:approval", { + approvalId: "approval-1", + summary: "Write a file", + }); + await assert.rejects( + app.service.respondApproval("device-2", "approval-1", "allow", "wrong-device-key-0001"), + (error: unknown) => (error as { code?: string }).code === "approval_expired", + ); + const resolved = await app.service.respondApproval("device-1", "approval-1", "deny", "approval-deny-key-001"); + assert.deepEqual( + await app.service.respondApproval("device-1", "approval-1", "deny", "approval-deny-key-001"), + resolved, + ); + await assert.rejects( + app.service.respondApproval("device-1", "approval-1", "allow", "approval-deny-key-001"), + (error: unknown) => (error as { code?: string }).code === "idempotency_conflict", + ); + assert.equal(resolved.decision, "deny"); + assert.equal(app.approvals.length, 1); + const status = await app.service.cancel("device-1", "stream-1", "cancel-stream-key-001"); + assert.deepEqual( + await app.service.cancel("device-1", "stream-1", "cancel-stream-key-001"), + status, + ); + assert.equal(status.state, "reconciling"); + assert.equal(app.cancelled.length, 1); +}); + +test("approval status is authoritative across reconnect and can be resolved from the host", () => { + const changed: string[] = []; + const decisions: string[] = []; + const service = new AidenRemoteStreamService({ + now: () => 1_000, + cancel: () => true, + approve: (approvalId, decision, ownerId) => { + decisions.push(`${approvalId}:${decision}:${ownerId}`); + return true; + }, + notifyApprovalChanged: (chatId) => changed.push(chatId), + }); + const owner = service.create("device-1", "stream-1", "chat-1", "turn-1"); + owner.owner.send("chat:approval", { + approvalId: "approval-1", + summary: "", + toolCallId: "tool-call-1", + toolName: "run_command", + }); + + assert.deepEqual(service.pendingApproval("device-1", "stream-1"), { + approvalId: "approval-1", + streamId: "stream-1", + chatId: "chat-1", + summary: "Aiden needs approval.", + toolCallId: "tool-call-1", + toolName: "run_command", + expiresAt: "1970-01-01T00:05:01.000Z", + canAllow: true, + }); + assert.equal(service.pendingApprovalForChat("chat-1")?.approvalId, "approval-1"); + assert.equal(service.respondApprovalFromHost("wrong-chat", "approval-1", "allow"), false); + assert.equal(service.respondApprovalFromHost("chat-1", "approval-1", "allow"), true); + assert.equal(service.pendingApproval("device-1", "stream-1"), null); + assert.equal(service.status("device-1", "stream-1").state, "running"); + assert.equal(decisions.length, 1); + assert.deepEqual(changed, ["chat-1", "chat-1"]); +}); + +test("privileged approval details remain host-only and mobile fails closed", () => { + const service = fixture().service; + const owner = service.create("device-1", "stream-1", "chat-1", "turn-1"); + const details = { + kind: "subagent-shell" as const, + childLabel: "Run checks", + command: "npm test", + initialCwd: "/Users/example/project", + shell: "/bin/zsh -f -c" as const, + argumentDigestPrefix: "a".repeat(12), + rootDigestPrefix: "b".repeat(12), + effectDigestPrefix: "c".repeat(12), + timeoutMs: 120_000, + stdoutLimitBytes: 512 * 1024, + stderrLimitBytes: 512 * 1024, + workspaceLabel: "Project", + isManagedWorktree: false, + worktreeLabel: null, + environmentProfile: "minimal-private-0700-v1" as const, + osSandboxed: false as const, + rollbackAvailable: false as const, + outputSentToModel: true as const, + arbitraryNetworkAvailable: true as const, + detachedProcessesMaySurvive: true as const, + }; + owner.owner.send("chat:approval", { + approvalId: "approval-1", + summary: "Run a full-host command for Run checks", + details, + }); + + assert.deepEqual(service.pendingApprovalForChat("chat-1")?.details, details); + const mobile = service.pendingApproval("device-1", "stream-1"); + assert.equal(mobile?.details, undefined); + assert.equal(mobile?.canAllow, false); +}); + +test("multiple approvals remain queued and cancellation synchronously clears them", async () => { + const app = fixture(); + const owner = app.service.create("device-1", "stream-1", "chat-1", "turn-1"); + owner.owner.send("chat:approval", { approvalId: "approval-1", summary: "First" }); + owner.owner.send("chat:approval", { approvalId: "approval-2", summary: "Second" }); + assert.equal(app.service.pendingApproval("device-1", "stream-1")?.approvalId, "approval-1"); + assert.equal(app.service.respondApprovalFromHost("chat-1", "approval-1", "allow"), true); + assert.equal(app.service.status("device-1", "stream-1").state, "waiting_for_approval"); + assert.equal(app.service.pendingApproval("device-1", "stream-1")?.approvalId, "approval-2"); + + const cancelled = await app.service.cancel("device-1", "stream-1", "cancel-waiting-key-01"); + assert.equal(cancelled.state, "reconciling"); + assert.equal(app.service.pendingApproval("device-1", "stream-1"), null); + assert.equal(app.service.pendingApprovalForChat("chat-1"), null); + assert.equal(app.approvals.some((entry) => entry.includes("approval-2:deny")), true); + await assert.rejects( + app.service.respondApproval("device-1", "approval-2", "allow", "approval-after-cancel-1"), + (error: unknown) => (error as { code?: string }).code === "approval_expired", + ); +}); + +test("empty assistant IDs never poison the durable stream journal", async () => { + let persisted = 0; + const service = new AidenRemoteStreamService({ + now: () => 1_000, + cancel: () => true, + approve: () => true, + persist: async (snapshot) => { + normalizeAidenRemoteStreamSnapshot(snapshot); + persisted += 1; + }, + }); + const owner = service.create("device-1", "stream-1", "chat-1", "turn-1"); + owner.owner.send("chat:delta", { delta: "" }); + owner.owner.send("chat:reasoning-delta", { delta: "" }); + owner.owner.send("chat:tool", { phase: "call", toolName: "" }); + owner.owner.send("chat:done", { + chat: { messages: [{ id: "", role: "assistant" }] }, + }); + await service.settlePersistence(); + + const events = service.snapshot().streams[0]?.events ?? []; + const terminal = events[events.length - 1]; + assert.deepEqual(terminal?.payload, { messageId: "assistant_turn-1" }); + assert.deepEqual(events[events.length - 2]?.payload, { + toolId: "tool_1", + name: "Tool", + }); + assert.doesNotThrow(() => normalizeAidenRemoteStreamSnapshot(service.snapshot())); + assert.equal(persisted > 0, true); +}); + +test("unpaired UTF-16 from provider notifications is sanitized before persistence", async () => { + const service = new AidenRemoteStreamService({ + now: () => 1_000, + cancel: () => true, + approve: () => true, + persist: async (snapshot) => { normalizeAidenRemoteStreamSnapshot(snapshot); }, + }); + const owner = service.create("device-1", "stream-1", "chat-1", "turn-1"); + owner.owner.send("chat:delta", { delta: "bad\ud800delta" }); + owner.owner.send("chat:reasoning-delta", { delta: "bad\udc00reasoning" }); + owner.owner.send("chat:tool", { phase: "call", toolName: "bad\ud800tool" }); + owner.owner.send("chat:timeline", { timeline: { steps: [{ kind: "tool", label: "bad\udc00label" }] } }); + owner.owner.send("chat:approval", { approvalId: "approval-1", summary: "bad\ud800summary" }); + service.respondApprovalFromHost("chat-1", "approval-1", "deny"); + owner.owner.send("chat:done", { chat: { messages: [{ id: "bad\udc00id", role: "assistant" }] } }); + await service.settlePersistence(); + assert.doesNotThrow(() => normalizeAidenRemoteStreamSnapshot(service.snapshot())); + assert.doesNotMatch(JSON.stringify(service.snapshot()), /\\ud800|\\udc00/u); +}); + +test("aggregate stream journals stay within the durable snapshot budget", async () => { + const service = new AidenRemoteStreamService({ + now: () => 1_000, + cancel: () => true, + approve: () => true, + persist: async (snapshot) => { normalizeAidenRemoteStreamSnapshot(snapshot); }, + }); + for (let streamIndex = 0; streamIndex < 3; streamIndex += 1) { + const owner = service.create("device-1", `stream-${streamIndex}`, `chat-${streamIndex}`, `turn-${streamIndex}`); + for (let index = 0; index < 35; index += 1) { + owner.owner.send("chat:delta", { delta: `${streamIndex}:${index}:` + "x".repeat(199_990) }); + } + } + await service.settlePersistence(); + const snapshot = service.snapshot(); + assert.doesNotThrow(() => normalizeAidenRemoteStreamSnapshot(snapshot)); + assert.equal(Buffer.byteLength(JSON.stringify(snapshot), "utf8") <= 16 * 1_024 * 1_024, true); +}); + +test("restart restores terminal journals and marks active work interrupted", () => { + const app = fixture(); + const owner = app.service.create("device-1", "stream-1", "chat-1", "turn-1"); + owner.owner.send("chat:delta", { delta: "Partial" }); + const snapshot = app.service.snapshot(); + const restarted = new AidenRemoteStreamService({ + now: () => 20_000, + cancel: () => false, + approve: () => false, + snapshot, + }); + const status = restarted.status("device-1", "stream-1"); + assert.equal(status.state, "interrupted"); + assert.equal(status.lastSequence, 3); +}); + +test("revocation closes only the selected device streams and approval expiry denies safely", async () => { + const app = fixture(); + const first = app.service.create("device-1", "stream-1", "chat-1", "turn-1"); + const second = app.service.create("device-2", "stream-2", "chat-2", "turn-2"); + first.owner.send("chat:approval", { approvalId: "approval-1", summary: "Change a file" }); + await app.service.revokeDevice("device-1"); + assert.throws( + () => app.service.status("device-1", "stream-1"), + (error: unknown) => (error as { code?: string }).code === "not_found", + ); + assert.equal(app.service.status("device-2", "stream-2").state, "queued"); + assert.equal(app.cancelled.length, 1); + assert.equal(app.approvals.some((entry) => entry.includes("approval-1:deny")), true); + + second.owner.send("chat:approval", { approvalId: "approval-2", summary: "Run a command" }); + app.setNow(1_000 + 5 * 60 * 1_000 + 1); + const expiredStatus = app.service.status("device-2", "stream-2"); + assert.equal(app.approvals.some((entry) => entry.includes("approval-2:deny")), true); + assert.equal(expiredStatus.state, "running"); + assert.equal(app.service.pendingApproval("device-2", "stream-2"), null); +}); + +test("revoking one device releases every retained journal without consuming another device's capacity", async () => { + const app = fixture(); + for (let index = 0; index < 256; index += 1) { + const streamId = `stream-a-${index}`; + const owner = app.service.create("device-a", streamId, `chat-${index}`, `turn-${index}`); + owner.owner.send("chat:done", { + chat: { messages: [{ id: `assistant-${index}`, role: "assistant" }] }, + }); + } + assert.throws( + () => app.service.create("device-b", "stream-b-blocked", "chat-b", "turn-b"), + (error: unknown) => (error as { code?: string }).code === "rate_limited", + ); + + await app.service.revokeDevice("device-a"); + const owner = app.service.create("device-b", "stream-b", "chat-b", "turn-b"); + assert.equal(owner.owner.documentId.length > 0, true); + assert.equal(app.service.status("device-b", "stream-b").state, "queued"); + assert.equal(app.service.snapshot().streams.some(({ deviceId }) => deviceId === "device-a"), false); +}); + +test("restart filtering durably excludes journals owned by authoritative revoked devices", () => { + const app = fixture(); + app.service.create("device-a", "stream-a", "chat-a", "turn-a"); + app.service.create("device-b", "stream-b", "chat-b", "turn-b"); + const filtered = removeRevokedDeviceStreams(app.service.snapshot(), new Set(["device-a"])); + assert.deepEqual(filtered.streams.map(({ deviceId }) => deviceId), ["device-b"]); +}); diff --git a/main/services/aiden-remote-streams.ts b/main/services/aiden-remote-streams.ts new file mode 100644 index 00000000..533ad6ff --- /dev/null +++ b/main/services/aiden-remote-streams.ts @@ -0,0 +1,979 @@ +import type { ServerResponse } from "node:http"; +import type { NotificationChannel } from "../../renderer/preload-channels.js"; +import { parseGenerationTimeline } from "../../renderer/shared/generation-timeline.js"; +import type { ToolApprovalDetails } from "../../renderer/shared/assistant.js"; +import { + isAssistantAutomationApprovalDetails, + isSubagentMcpMutationApprovalDetails, + isSubagentShellApprovalDetails, + isSubagentWorkspaceWriteApprovalDetails, +} from "../../renderer/shared/assistant.js"; +import { + createRemoteChatGenerationOwner, + type RemoteChatGenerationOwnerController, +} from "./chat-generation-owner.js"; +import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; +import { + AIDEN_REMOTE_PROTOCOL_VERSION, + parseAidenRemoteStreamEvent, +} from "./aiden-remote-protocol.js"; +import { + AidenIdempotencyLedger, + type AidenIdempotencySnapshot, + AidenOperationContractError, +} from "./aiden-remote-operation-contract.js"; + +const MAX_STREAMS = 256; +const MAX_EVENTS_PER_STREAM = 4_096; +const MAX_STREAM_EVENT_BYTES = 8 * 1_024 * 1_024; +const TERMINAL_RETENTION_MS = 24 * 60 * 60 * 1_000; +const APPROVAL_LIFETIME_MS = 5 * 60 * 1_000; +export const MAX_AIDEN_REMOTE_STREAM_SNAPSHOT_BYTES = 16 * 1_024 * 1_024; + +export type AidenRemoteStreamState = + | "queued" + | "running" + | "waiting_for_approval" + | "reconciling" + | "done" + | "error" + | "cancelled" + | "interrupted"; + +export interface AidenRemoteStreamEvent { + protocolVersion: typeof AIDEN_REMOTE_PROTOCOL_VERSION; + streamId: string; + sequence: number; + timestamp: string; + type: string; + terminal: boolean; + payload: Record; +} + +export interface AidenRemoteStreamStatus { + streamId: string; + chatId: string; + turnId: string; + state: AidenRemoteStreamState; + lastSequence: number; + updatedAt: string; +} + +export interface AidenRemotePendingApproval { + approvalId: string; + streamId: string; + chatId: string; + summary: string; + toolCallId: string; + toolName: string; + expiresAt: string; + canAllow: boolean; + /** Exact renderer-safe facts are host-only and never enter the mobile wire contract. */ + details?: ToolApprovalDetails; +} + +export interface AidenRemoteStreamSnapshot { + version: 1; + streams: Array<{ + streamId: string; + chatId: string; + turnId: string; + deviceId: string; + state: AidenRemoteStreamState; + updatedAt: number; + events: AidenRemoteStreamEvent[]; + }>; +} + +interface StreamSubscriber { + response: ServerResponse; + heartbeat: ReturnType; +} + +interface StreamRecord { + streamId: string; + chatId: string; + turnId: string; + deviceId: string; + state: AidenRemoteStreamState; + updatedAt: number; + events: AidenRemoteStreamEvent[]; + eventBytes: number; + subscribers: Set; + owner: RemoteChatGenerationOwnerController; + cancelRequested: boolean; + cancellationSource: "device" | "server"; + activeTools: Map; + toolCounter: number; +} + +interface ApprovalRecord { + streamId: string; + deviceId: string; + ownerDocumentId: string; + chatId: string; + summary: string; + toolCallId: string; + toolName: string; + canAllow: boolean; + details?: ToolApprovalDetails; + expiresAt: number; + expiry: ReturnType; +} + +function ownRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function boundedText(value: unknown, maximum: number): string { + if (typeof value !== "string") return ""; + const sliced = value.slice(0, maximum); + let result = ""; + for (let index = 0; index < sliced.length; index += 1) { + const code = sliced.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = sliced.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + result += sliced[index] + sliced[index + 1]; + index += 1; + } else { + result += "\ufffd"; + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + result += "\ufffd"; + } else { + result += sliced[index]; + } + } + return result; +} + +function approvalDetails(value: unknown): ToolApprovalDetails | undefined { + return isAssistantAutomationApprovalDetails(value) + || isSubagentWorkspaceWriteApprovalDetails(value) + || isSubagentMcpMutationApprovalDetails(value) + || isSubagentShellApprovalDetails(value) + ? structuredClone(value) + : undefined; +} + +function terminal(state: AidenRemoteStreamState): boolean { + return state === "done" || state === "error" || state === "cancelled" || state === "interrupted"; +} + +function parseSnapshot(value: unknown): AidenRemoteStreamSnapshot { + const record = ownRecord(value); + if ( + !record || + record.version !== 1 || + !Array.isArray(record.streams) || + record.streams.length > MAX_STREAMS || + Buffer.byteLength(JSON.stringify(value), "utf8") > MAX_AIDEN_REMOTE_STREAM_SNAPSHOT_BYTES + ) { + throw new Error("Invalid Aiden Remote stream snapshot."); + } + const streamIds = new Set(); + const streams: AidenRemoteStreamSnapshot["streams"] = []; + for (const raw of record.streams) { + const stream = ownRecord(raw); + if ( + !stream || + Object.keys(stream).some((key) => !["streamId", "chatId", "turnId", "deviceId", "state", "updatedAt", "events"].includes(key)) || + !["streamId", "chatId", "turnId", "deviceId", "state", "updatedAt", "events"].every((key) => Object.prototype.hasOwnProperty.call(stream, key)) || + typeof stream.streamId !== "string" || + !/^[A-Za-z0-9._:-]{1,128}$/u.test(stream.streamId) || + streamIds.has(stream.streamId) || + typeof stream.chatId !== "string" || + !/^[A-Za-z0-9._:-]{1,128}$/u.test(stream.chatId) || + typeof stream.turnId !== "string" || + !/^[A-Za-z0-9._:-]{1,128}$/u.test(stream.turnId) || + typeof stream.deviceId !== "string" || + stream.deviceId.length === 0 || + stream.deviceId.length > 128 || + typeof stream.state !== "string" || + !["queued", "running", "waiting_for_approval", "reconciling", "done", "error", "cancelled", "interrupted"].includes(stream.state) || + !Number.isSafeInteger(stream.updatedAt) || + Number(stream.updatedAt) < 0 || + !Array.isArray(stream.events) || + stream.events.length > MAX_EVENTS_PER_STREAM + ) { + throw new Error("Invalid Aiden Remote stream snapshot."); + } + let previous = 0; + const events = stream.events.map((rawEvent) => { + const event = ownRecord(rawEvent); + const payload = ownRecord(event?.payload); + if ( + !event || + !payload || + event.protocolVersion !== AIDEN_REMOTE_PROTOCOL_VERSION || + event.streamId !== stream.streamId || + !Number.isSafeInteger(event.sequence) || + (previous === 0 + ? Number(event.sequence) < 1 + : Number(event.sequence) !== previous + 1) || + typeof event.timestamp !== "string" || + !Number.isFinite(Date.parse(event.timestamp)) || + typeof event.type !== "string" || + event.type.length === 0 || + event.type.length > 80 || + typeof event.terminal !== "boolean" || + !parseAidenRemoteStreamEvent(rawEvent) + ) { + throw new Error("Invalid Aiden Remote stream snapshot."); + } + previous = Number(event.sequence); + return structuredClone(rawEvent) as AidenRemoteStreamEvent; + }); + streamIds.add(stream.streamId); + streams.push({ + streamId: stream.streamId, + chatId: stream.chatId, + turnId: stream.turnId, + deviceId: stream.deviceId, + state: stream.state as AidenRemoteStreamState, + updatedAt: stream.updatedAt as number, + events, + }); + } + return { version: 1, streams }; +} + +export function normalizeAidenRemoteStreamSnapshot(value: unknown): AidenRemoteStreamSnapshot { + return parseSnapshot(value); +} + +export function removeRevokedDeviceStreams( + value: unknown, + revokedDeviceIds: ReadonlySet, +): AidenRemoteStreamSnapshot { + const snapshot = parseSnapshot(value); + return { + version: 1, + streams: snapshot.streams.filter(({ deviceId }) => !revokedDeviceIds.has(deviceId)), + }; +} + +function sseFrame(event: AidenRemoteStreamEvent): string { + return `id: ${event.sequence}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`; +} + +export class AidenRemoteStreamService { + private readonly streams = new Map(); + private readonly approvals = new Map(); + private persistTail: Promise = Promise.resolve(); + private persistDirty = false; + private persistRunning = false; + private persistenceError: unknown; + private readonly idempotency: AidenIdempotencyLedger; + + constructor( + private readonly options: { + now(): number; + cancel(streamId: string, ownerDocumentId: string): boolean; + approve(approvalId: string, decision: "allow" | "deny", ownerDocumentId: string): boolean; + notifyChatChanged?: (chatId: string) => void; + notifyApprovalChanged?: (chatId: string) => void; + snapshot?: AidenRemoteStreamSnapshot; + persist?: (snapshot: AidenRemoteStreamSnapshot) => Promise; + idempotency?: AidenIdempotencyLedger; + persistIdempotency?: (snapshot: AidenIdempotencySnapshot) => Promise; + onPersistenceError?: (error: unknown) => void; + }, + ) { + this.idempotency = options.idempotency ?? new AidenIdempotencyLedger(); + if (options.snapshot) this.restore(options.snapshot); + } + + private async executeIdempotent( + scope: { deviceId: string; route: string; resourceId: string; key: string }, + input: unknown, + action: () => Promise, + ): Promise { + if (!/^[\x21-\x7e]{16,128}$/u.test(scope.key)) { + throw new AidenRemoteServiceError("invalid_request", "Idempotency-Key is invalid.", 400); + } + if (!this.options.persistIdempotency) { + try { + return await this.idempotency.execute(scope, input, action); + } catch (error) { + return this.mapIdempotencyError(error); + } + } + let admit!: () => void; + let reject!: (error: unknown) => void; + const durable = new Promise((resolve, rejectPromise) => { + admit = resolve; + reject = rejectPromise; + }); + const pending = this.idempotency.execute(scope, input, async () => { + await durable; + return action(); + }); + try { + await this.options.persistIdempotency(this.idempotency.snapshot()); + admit(); + } catch (error) { + reject(error); + await pending.catch(() => undefined); + throw new AidenRemoteServiceError("internal_error", "Aiden could not prepare this stream request.", 500); + } + let result: T | undefined; + let failure: unknown; + try { + result = await pending; + } catch (error) { + failure = error; + } + try { + await this.options.persistIdempotency(this.idempotency.snapshot()); + } catch { + throw new AidenRemoteServiceError("idempotency_in_flight", "The stream request outcome is unknown.", 409); + } + if (failure) throw failure; + return result!; + } + + private mapIdempotencyError(error: unknown): never { + if (error instanceof AidenRemoteServiceError) throw error; + if (error instanceof AidenOperationContractError) { + throw new AidenRemoteServiceError( + error.code, + "This stream request cannot be safely repeated.", + error.code === "idempotency_capacity" ? 429 : 409, + ); + } + throw error; + } + + private ownerFor(record: Omit): RemoteChatGenerationOwnerController { + return createRemoteChatGenerationOwner({ + deviceId: record.deviceId, + streamId: record.streamId, + publish: (channel, payload) => this.projectNotification(this.streams.get(record.streamId)!, channel, payload), + }); + } + + private restore(snapshot: AidenRemoteStreamSnapshot): void { + for (const saved of parseSnapshot(snapshot).streams) { + const base: Omit = { + ...saved, + eventBytes: saved.events.reduce( + (total, event) => total + Buffer.byteLength(JSON.stringify(event), "utf8"), + 0, + ), + subscribers: new Set(), + cancelRequested: false, + cancellationSource: "server", + activeTools: new Map(), + toolCounter: 0, + }; + const record: StreamRecord = { ...base, owner: this.ownerFor(base) }; + this.streams.set(record.streamId, record); + if (!terminal(record.state)) { + this.append( + record, + "error", + { code: "server_interrupted", message: "Aiden restarted before this response finished." }, + true, + "interrupted", + ); + } + } + } + + snapshot(): AidenRemoteStreamSnapshot { + return { + version: 1, + streams: [...this.streams.values()].map((stream) => ({ + streamId: stream.streamId, + chatId: stream.chatId, + turnId: stream.turnId, + deviceId: stream.deviceId, + state: stream.state, + updatedAt: stream.updatedAt, + events: structuredClone(stream.events), + })), + }; + } + + private persist(): void { + if (!this.options.persist) return; + this.persistDirty = true; + if (this.persistRunning) return; + this.persistRunning = true; + this.persistTail = (async () => { + while (this.persistDirty) { + this.persistDirty = false; + await this.options.persist!(this.snapshot()); + this.persistenceError = undefined; + } + })() + .catch((error: unknown) => { + this.persistenceError = error; + this.options.onPersistenceError?.(error); + }) + .finally(() => { + this.persistRunning = false; + if (this.persistDirty) this.persist(); + }); + } + + async settlePersistence(): Promise { + while (this.persistRunning || this.persistDirty) await this.persistTail; + if (this.persistenceError) throw this.persistenceError; + } + + private prune(): void { + const now = this.options.now(); + for (const [approvalId, approval] of this.approvals) { + if (approval.expiresAt <= now) { + this.resolveApproval(approvalId, "deny"); + } + } + for (const [streamId, stream] of this.streams) { + if (terminal(stream.state) && stream.updatedAt + TERMINAL_RETENTION_MS <= now) { + stream.owner.invalidate(); + for (const subscriber of stream.subscribers) { + clearInterval(subscriber.heartbeat); + subscriber.response.end(); + } + this.streams.delete(streamId); + } + } + } + + private pendingApprovalForStream(streamId: string): AidenRemotePendingApproval | undefined { + const approval = [...this.approvals.entries()].find(([, entry]) => entry.streamId === streamId); + if (!approval) return undefined; + const [approvalId, entry] = approval; + return { + approvalId, + streamId: entry.streamId, + chatId: entry.chatId, + summary: entry.summary, + toolCallId: entry.toolCallId, + toolName: entry.toolName, + expiresAt: new Date(entry.expiresAt).toISOString(), + canAllow: entry.canAllow, + ...(entry.details ? { details: structuredClone(entry.details) } : {}), + }; + } + + private resolveApproval(approvalId: string, decision: "allow" | "deny"): boolean { + const approval = this.approvals.get(approvalId); + if (!approval) return false; + clearTimeout(approval.expiry); + const resolved = this.options.approve(approvalId, decision, approval.ownerDocumentId); + this.approvals.delete(approvalId); + const stream = this.streams.get(approval.streamId); + const nextApproval = stream ? this.pendingApprovalForStream(stream.streamId) : undefined; + if (stream && !terminal(stream.state)) { + if (!resolved) { + this.append(stream, "status", { state: "reconciling" }, false, "reconciling"); + } else if (nextApproval) { + this.append( + stream, + "approval_required", + { + approvalId: nextApproval.approvalId, + summary: nextApproval.summary, + expiresAt: nextApproval.expiresAt, + }, + false, + "waiting_for_approval", + ); + } else { + this.append(stream, "status", { state: "running" }, false, "running"); + } + } + this.options.notifyApprovalChanged?.(approval.chatId); + return resolved; + } + + private requireStream(deviceId: string, streamId: string): StreamRecord { + this.prune(); + const stream = this.streams.get(streamId); + if (!stream || stream.deviceId !== deviceId) { + throw new AidenRemoteServiceError("not_found", "This Aiden stream is unavailable.", 404); + } + return stream; + } + + create(deviceId: string, streamId: string, chatId: string, turnId: string): RemoteChatGenerationOwnerController { + this.prune(); + if (this.streams.has(streamId)) { + throw new AidenRemoteServiceError("already_exists", "That stream already exists.", 409); + } + if (this.streams.size >= MAX_STREAMS) { + throw new AidenRemoteServiceError("rate_limited", "Too many remote streams are retained.", 429, true); + } + const base: Omit = { + streamId, + chatId, + turnId, + deviceId, + state: "queued", + updatedAt: this.options.now(), + events: [], + eventBytes: 0, + subscribers: new Set(), + cancelRequested: false, + cancellationSource: "device", + activeTools: new Map(), + toolCounter: 0, + }; + const owner = this.ownerFor(base); + const record: StreamRecord = { ...base, owner }; + this.streams.set(streamId, record); + this.append(record, "status", { state: "queued" }, false, "queued"); + return owner; + } + + private append( + stream: StreamRecord, + type: string, + payload: Record, + isTerminal: boolean, + state?: AidenRemoteStreamState, + ): AidenRemoteStreamEvent { + if (terminal(stream.state)) return stream.events[stream.events.length - 1]!; + const event: AidenRemoteStreamEvent = { + protocolVersion: AIDEN_REMOTE_PROTOCOL_VERSION, + streamId: stream.streamId, + sequence: (stream.events[stream.events.length - 1]?.sequence ?? 0) + 1, + timestamp: new Date(this.options.now()).toISOString(), + type, + terminal: isTerminal, + payload, + }; + const bytes = Buffer.byteLength(JSON.stringify(event), "utf8"); + stream.events.push(event); + stream.eventBytes += bytes; + while ( + stream.events.length > MAX_EVENTS_PER_STREAM || + (stream.eventBytes > MAX_STREAM_EVENT_BYTES && stream.events.length > 1) + ) { + const removed = stream.events.shift(); + if (removed) stream.eventBytes -= Buffer.byteLength(JSON.stringify(removed), "utf8"); + } + this.enforceAggregateBudget(stream.streamId); + stream.state = state ?? stream.state; + stream.updatedAt = this.options.now(); + const frame = sseFrame(event); + for (const subscriber of [...stream.subscribers]) { + if (!subscriber.response.write(frame)) { + // Node applies backpressure. The durable in-memory journal remains the + // source of truth, so the client can reconnect from its last event ID. + } + if (isTerminal) { + clearInterval(subscriber.heartbeat); + subscriber.response.end(); + stream.subscribers.delete(subscriber); + } + } + if (isTerminal) { + stream.owner.invalidate(); + for (const [approvalId, approval] of this.approvals) { + if (approval.streamId === stream.streamId) { + clearTimeout(approval.expiry); + this.approvals.delete(approvalId); + this.options.notifyApprovalChanged?.(approval.chatId); + } + } + this.options.notifyChatChanged?.(stream.chatId); + } + this.persist(); + return event; + } + + private enforceAggregateBudget(currentStreamId: string): void { + const snapshotBytes = () => Buffer.byteLength(JSON.stringify(this.snapshot()), "utf8"); + if (snapshotBytes() <= MAX_AIDEN_REMOTE_STREAM_SNAPSHOT_BYTES) return; + const terminalStreams = [...this.streams.values()] + .filter((entry) => entry.streamId !== currentStreamId && terminal(entry.state)) + .sort((left, right) => left.updatedAt - right.updatedAt); + for (const entry of terminalStreams) { + entry.owner.invalidate(); + this.streams.delete(entry.streamId); + if (snapshotBytes() <= MAX_AIDEN_REMOTE_STREAM_SNAPSHOT_BYTES) return; + } + while (snapshotBytes() > MAX_AIDEN_REMOTE_STREAM_SNAPSHOT_BYTES) { + const candidate = [...this.streams.values()] + .filter((entry) => entry.events.length > 0) + .sort((left, right) => right.eventBytes - left.eventBytes)[0]; + if (!candidate) break; + if (candidate.events.length > 1) { + const removed = candidate.events.shift(); + if (removed) candidate.eventBytes -= Buffer.byteLength(JSON.stringify(removed), "utf8"); + continue; + } + const retained = candidate.events[0]!; + if (retained.terminal || retained.type === "snapshot") break; + const replacement: AidenRemoteStreamEvent = { + ...retained, + type: "snapshot", + payload: { + chatId: candidate.chatId, + turnId: candidate.turnId, + nextSequence: retained.sequence + 1, + }, + }; + candidate.events[0] = replacement; + candidate.eventBytes = Buffer.byteLength(JSON.stringify(replacement), "utf8"); + } + } + + private projectNotification( + stream: StreamRecord, + channel: NotificationChannel, + rawPayload: unknown, + ): void { + const payload = ownRecord(rawPayload) ?? {}; + if (channel === "chat:delta") { + if (payload.reset === true) { + const nextSequence = (stream.events[stream.events.length - 1]?.sequence ?? 0) + 2; + this.append( + stream, + "snapshot", + { chatId: stream.chatId, turnId: stream.turnId, nextSequence }, + false, + "reconciling", + ); + return; + } + const text = boundedText(payload.delta, 200_000); + if (text) this.append(stream, "text_delta", { text }, false, "running"); + return; + } + if (channel === "chat:reasoning-delta") { + const text = boundedText(payload.delta, 200_000); + if (text) this.append(stream, "reasoning_delta", { text }, false, "running"); + return; + } + if (channel === "chat:status") { + this.append(stream, "status", { state: "running" }, false, "running"); + return; + } + if (channel === "chat:tool") { + const name = boundedText(payload.toolName, 120) || "Tool"; + const phase = payload.phase; + if (phase === "call") { + const toolId = `tool_${++stream.toolCounter}`; + const queue = stream.activeTools.get(name) ?? []; + queue.push(toolId); + stream.activeTools.set(name, queue); + this.append(stream, "tool_started", { toolId, name }, false, "running"); + } else { + const queue = stream.activeTools.get(name) ?? []; + const toolId = queue.shift() ?? `tool_${++stream.toolCounter}`; + if (queue.length === 0) stream.activeTools.delete(name); + const status = phase === "result" ? "succeeded" : "failed"; + this.append(stream, "tool_finished", { toolId, status }, false, "running"); + } + return; + } + if (channel === "chat:timeline") { + const timeline = parseGenerationTimeline(payload.timeline); + if (timeline) this.append(stream, "timeline", { timeline }, false, "running"); + return; + } + if (channel === "chat:approval") { + const approvalId = boundedText(payload.approvalId, 128); + if (!approvalId) return; + const previousApproval = this.approvals.get(approvalId); + if (previousApproval) clearTimeout(previousApproval.expiry); + const summary = boundedText(payload.summary, 2_000) || "Aiden needs approval."; + const toolCallId = boundedText(payload.toolCallId, 128) || "remote-tool"; + const toolName = boundedText(payload.toolName, 120) || "Tool"; + const details = approvalDetails(payload.details); + const claimsStructuredDetails = ownRecord(payload.details)?.kind !== undefined; + const expiresAt = this.options.now() + APPROVAL_LIFETIME_MS; + const expiry = setTimeout(() => { + const current = this.approvals.get(approvalId); + if (!current || current.expiresAt !== expiresAt) return; + this.resolveApproval(approvalId, "deny"); + }, APPROVAL_LIFETIME_MS); + expiry.unref?.(); + this.approvals.set(approvalId, { + streamId: stream.streamId, + deviceId: stream.deviceId, + ownerDocumentId: stream.owner.owner.documentId, + chatId: stream.chatId, + summary, + toolCallId, + toolName, + canAllow: !claimsStructuredDetails || details !== undefined, + ...(details ? { details } : {}), + expiresAt, + expiry, + }); + this.append( + stream, + "approval_required", + { + approvalId, + summary, + expiresAt: new Date(expiresAt).toISOString(), + }, + false, + "waiting_for_approval", + ); + this.options.notifyApprovalChanged?.(stream.chatId); + return; + } + if (channel === "chat:error") { + const finalTimeline = parseGenerationTimeline(payload.timeline); + if ( + stream.cancelRequested || + payload.cancelled === true || + finalTimeline?.status === "cancelled" + ) { + this.append( + stream, + "cancelled", + { source: stream.cancelRequested ? stream.cancellationSource : "server" }, + true, + "cancelled", + ); + return; + } + this.append( + stream, + "error", + { code: "internal_error", message: "The model provider could not complete this response." }, + true, + "error", + ); + return; + } + if (channel === "chat:done") { + const finalTimeline = parseGenerationTimeline(payload.timeline); + if ( + stream.cancelRequested || + payload.cancelled === true || + finalTimeline?.status === "cancelled" + ) { + this.append( + stream, + "cancelled", + { source: stream.cancelRequested ? stream.cancellationSource : "server" }, + true, + "cancelled", + ); + return; + } + const chat = ownRecord(payload.chat); + const messages = Array.isArray(chat?.messages) ? chat.messages : []; + const assistant = [...messages].reverse().find((message) => ownRecord(message)?.role === "assistant"); + const messageId = boundedText(ownRecord(assistant)?.id, 128) || `assistant_${stream.turnId}`; + this.append(stream, "done", { messageId }, true, "done"); + } + } + + markRunning(deviceId: string, streamId: string): void { + const stream = this.requireStream(deviceId, streamId); + this.append(stream, "status", { state: "running" }, false, "running"); + } + + markStartError(deviceId: string, streamId: string, error: unknown): void { + const stream = this.requireStream(deviceId, streamId); + if (stream.cancelRequested) { + this.append( + stream, + "cancelled", + { source: stream.cancellationSource }, + true, + "cancelled", + ); + return; + } + void error; + this.append( + stream, + "error", + { + code: "internal_error", + message: "Aiden could not start this response.", + }, + true, + "error", + ); + } + + status(deviceId: string, streamId: string): AidenRemoteStreamStatus { + const stream = this.requireStream(deviceId, streamId); + return { + streamId: stream.streamId, + chatId: stream.chatId, + turnId: stream.turnId, + state: stream.state, + lastSequence: stream.events[stream.events.length - 1]?.sequence ?? 0, + updatedAt: new Date(stream.updatedAt).toISOString(), + }; + } + + pendingApproval(deviceId: string, streamId: string): AidenRemotePendingApproval | null { + const stream = this.requireStream(deviceId, streamId); + const approval = this.pendingApprovalForStream(stream.streamId); + if (!approval) return null; + const { details: _hostOnly, ...mobile } = approval; + return { ...mobile, canAllow: approval.details ? false : approval.canAllow }; + } + + pendingApprovalForChat(chatId: string): AidenRemotePendingApproval | null { + this.prune(); + for (const stream of this.streams.values()) { + if (stream.chatId !== chatId || terminal(stream.state)) continue; + const approval = this.pendingApprovalForStream(stream.streamId); + if (approval) return approval; + } + return null; + } + + respondApprovalFromHost( + chatId: string, + approvalId: string, + decision: "allow" | "deny", + ): boolean { + this.prune(); + const approval = this.approvals.get(approvalId); + if (!approval || approval.chatId !== chatId || approval.expiresAt <= this.options.now()) { + return false; + } + return this.resolveApproval(approvalId, decision); + } + + async cancel(deviceId: string, streamId: string, key: string): Promise { + try { + return await this.executeIdempotent( + { deviceId, route: "POST /streams/{id}/cancel", resourceId: streamId, key }, + { streamId }, + async () => { + const stream = this.requireStream(deviceId, streamId); + if (!terminal(stream.state) && !stream.cancelRequested) { + stream.cancelRequested = true; + stream.cancellationSource = "device"; + for (const [approvalId, approval] of [...this.approvals]) { + if (approval.streamId !== stream.streamId) continue; + clearTimeout(approval.expiry); + this.options.approve(approvalId, "deny", approval.ownerDocumentId); + this.approvals.delete(approvalId); + this.options.notifyApprovalChanged?.(approval.chatId); + } + this.options.cancel(streamId, stream.owner.owner.documentId); + this.append(stream, "status", { state: "reconciling" }, false, "reconciling"); + } + return this.status(deviceId, streamId); + }, + ); + } catch (error) { + return this.mapIdempotencyError(error); + } + } + + async revokeDevice(deviceId: string): Promise { + for (const [approvalId, approval] of this.approvals) { + if (approval.deviceId !== deviceId) continue; + clearTimeout(approval.expiry); + this.options.approve(approvalId, "deny", approval.ownerDocumentId); + this.approvals.delete(approvalId); + this.options.notifyApprovalChanged?.(approval.chatId); + } + for (const [streamId, stream] of this.streams) { + if (stream.deviceId !== deviceId) continue; + if (!terminal(stream.state)) { + stream.cancelRequested = true; + stream.cancellationSource = "server"; + this.options.cancel(stream.streamId, stream.owner.owner.documentId); + this.append(stream, "cancelled", { source: "server" }, true, "cancelled"); + } + stream.owner.invalidate(); + stream.activeTools.clear(); + for (const subscriber of stream.subscribers) { + clearInterval(subscriber.heartbeat); + subscriber.response.end(); + } + stream.subscribers.clear(); + this.streams.delete(streamId); + } + this.persist(); + await this.settlePersistence(); + } + + async respondApproval( + deviceId: string, + approvalId: string, + decision: "allow" | "deny", + key: string, + ): Promise<{ approvalId: string; decision: "allow" | "deny"; resolvedAt: string }> { + try { + return await this.executeIdempotent( + { deviceId, route: "POST /approvals/{id}/respond", resourceId: approvalId, key }, + { approvalId, decision }, + async () => { + this.prune(); + const approval = this.approvals.get(approvalId); + if (!approval || approval.deviceId !== deviceId || approval.expiresAt <= this.options.now()) { + throw new AidenRemoteServiceError("approval_expired", "This approval is no longer available.", 409); + } + if (!this.resolveApproval(approvalId, decision)) { + throw new AidenRemoteServiceError("approval_already_resolved", "This approval was already resolved.", 409); + } + return { approvalId, decision, resolvedAt: new Date(this.options.now()).toISOString() }; + }, + ); + } catch (error) { + return this.mapIdempotencyError(error); + } + } + + openEvents(deviceId: string, streamId: string, after: number, response: ServerResponse): void { + const stream = this.requireStream(deviceId, streamId); + const lastSequence = stream.events[stream.events.length - 1]?.sequence ?? 0; + if (after > lastSequence) { + throw new AidenRemoteServiceError("invalid_request", "The stream cursor is ahead of Aiden.", 400); + } + const earliest = stream.events[0]?.sequence ?? 1; + if (after < earliest - 1) { + const snapshot = this.append( + stream, + "snapshot", + { + chatId: stream.chatId, + turnId: stream.turnId, + nextSequence: (stream.events[stream.events.length - 1]?.sequence ?? 0) + 2, + }, + false, + ); + after = snapshot.sequence - 1; + } + response.writeHead(200, { + "aiden-protocol-version": String(AIDEN_REMOTE_PROTOCOL_VERSION), + "cache-control": "no-store", + connection: "keep-alive", + "content-type": "text/event-stream; charset=utf-8", + "x-accel-buffering": "no", + "x-content-type-options": "nosniff", + }); + for (const event of stream.events) { + if (event.sequence > after) response.write(sseFrame(event)); + } + if (terminal(stream.state)) { + response.end(); + return; + } + const subscriber: StreamSubscriber = { + response, + heartbeat: setInterval(() => response.write(": heartbeat\n\n"), 15_000), + }; + subscriber.heartbeat.unref?.(); + stream.subscribers.add(subscriber); + response.once("close", () => { + clearInterval(subscriber.heartbeat); + stream.subscribers.delete(subscriber); + }); + } +} diff --git a/main/services/aiden-remote-tailscale-route.test.ts b/main/services/aiden-remote-tailscale-route.test.ts new file mode 100644 index 00000000..7a943a55 --- /dev/null +++ b/main/services/aiden-remote-tailscale-route.test.ts @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + aidenTailscaleHealthEndpoint, + aidenTailscaleCanonicalLoopbackPort, + aidenTailscaleCanonicalLoopbackTargets, + classifyAidenTailscaleRoute, + planAidenTailscaleConnect, + planAidenTailscaleDisconnect, + type AidenTailscaleStatus, +} from "./aiden-remote-tailscale-route.js"; + +const target = "http://127.0.0.1:43177/api/aiden/v1"; +const status = (proxy?: string, funnel = false): AidenTailscaleStatus => ({ + TCP: { "443": { HTTPS: true } }, + Web: { "aiden-device.example.ts.net:443": { Funnel: funnel, Handlers: { "/other": { Proxy: "http://127.0.0.1:9" }, ...(proxy ? { "/api/aiden/v1": { Proxy: proxy } } : {}) } } }, +}); + +test("Tailscale planning owns only the exact Aiden path and never resets or changes Funnel", () => { + const before = status(); + const unrelated = JSON.stringify(before); + const connect = planAidenTailscaleConnect(before, target); + assert.deepEqual(connect, { action: "set", args: ["serve", "--https=443", "--set-path=/api/aiden/v1", target], ownership: { path: "/api/aiden/v1", target } }); + assert.equal(JSON.stringify(before), unrelated); + assert.equal(planAidenTailscaleConnect(status(target), target, connect.ownership).action, "noop"); + assert.deepEqual(planAidenTailscaleDisconnect(status(target), target, connect.ownership), { action: "clear", args: ["serve", "--https=443", "--set-path=/api/aiden/v1", "off"] }); + assert.equal(planAidenTailscaleDisconnect(status(), target).action, "noop"); + assert.throws(() => planAidenTailscaleConnect(status(target), target), /tailscale_route_conflict/); + assert.throws(() => planAidenTailscaleDisconnect(status(target), target), /tailscale_route_conflict/); + assert.throws(() => planAidenTailscaleConnect(status("http://127.0.0.1:2/api/aiden/v1"), target), /tailscale_route_conflict/); + assert.throws(() => planAidenTailscaleDisconnect(status("http://127.0.0.1:2/api/aiden/v1"), target), /tailscale_route_conflict/); + assert.throws(() => planAidenTailscaleConnect(status(undefined, true), target), /tailscale_funnel_conflict/); + assert.throws( + () => planAidenTailscaleDisconnect(status(target, true), target, connect.ownership), + /tailscale_funnel_conflict/u, + ); + for (const unsafeTarget of [ + "https://127.0.0.1:43177", + "http://127.0.0.2:43177", + "http://0.0.0.0:43177", + "http://127.0.0.1:43177/private", + "http://127.0.0.1:43177/api/aiden/v1/private", + "http://user:secret@127.0.0.1:43177", + "http://aiden.example.test:43177", + ]) { + assert.throws(() => planAidenTailscaleConnect(status(), unsafeTarget), /tailscale_target_invalid/); + assert.throws(() => planAidenTailscaleDisconnect(status(), unsafeTarget), /tailscale_target_invalid/); + } + assert.doesNotThrow(() => planAidenTailscaleConnect(status(), "http://[::1]:43177/api/aiden/v1")); + assert.doesNotThrow(() => planAidenTailscaleConnect(status(), "http://localhost:43177/api/aiden/v1")); +}); + +test("Tailscale route classification is exact, ownership-aware, and Funnel-safe", () => { + const ownership = { path: "/api/aiden/v1" as const, target }; + assert.deepEqual(classifyAidenTailscaleRoute(status(), target), { kind: "available" }); + assert.deepEqual(classifyAidenTailscaleRoute(status(target), target, ownership), { kind: "owned", target }); + const missingHttpsListener = status(target); + delete missingHttpsListener.TCP; + assert.deepEqual( + classifyAidenTailscaleRoute(missingHttpsListener, target, ownership), + { kind: "unrelated_conflict" }, + ); + assert.throws( + () => planAidenTailscaleConnect(missingHttpsListener, target, ownership, true), + /tailscale_route_conflict/u, + ); + assert.deepEqual(classifyAidenTailscaleRoute(status(target), target), { kind: "other_aiden", target }); + assert.deepEqual( + classifyAidenTailscaleRoute(status("http://127.0.0.1:43179/api/aiden/v1"), target), + { kind: "other_aiden", target: "http://127.0.0.1:43179/api/aiden/v1" }, + ); + assert.deepEqual( + classifyAidenTailscaleRoute(status("http://127.0.0.1:43179/private"), target), + { kind: "unrelated_conflict" }, + ); + assert.deepEqual(classifyAidenTailscaleRoute(status(undefined, true), target), { kind: "funnel_conflict" }); + assert.deepEqual( + classifyAidenTailscaleRoute(status(target, true), target, ownership), + { kind: "funnel_conflict" }, + ); +}); + +test("Tailscale health endpoints accept only exact canonical or legacy loopback targets", () => { + assert.equal(aidenTailscaleHealthEndpoint(target), `${target}/health`); + assert.equal( + aidenTailscaleHealthEndpoint("http://localhost:43177"), + "http://localhost:43177/api/aiden/v1/health", + ); + assert.throws( + () => aidenTailscaleHealthEndpoint("http://127.0.0.1:43177/private"), + /tailscale_target_invalid/u, + ); +}); + +test("canonical route inspection returns only an exact loopback Aiden target port", () => { + assert.equal(aidenTailscaleCanonicalLoopbackPort(status(target)), 43_177); + assert.equal( + aidenTailscaleCanonicalLoopbackPort(status("http://127.0.0.1:43177")), + 43_177, + ); + assert.equal(aidenTailscaleCanonicalLoopbackPort(status()), undefined); + assert.equal( + aidenTailscaleCanonicalLoopbackPort(status("http://127.0.0.1:43177/private")), + undefined, + ); + assert.deepEqual( + aidenTailscaleCanonicalLoopbackTargets({ + Web: { + "malformed authority": { + Handlers: { "/api/aiden/v1": { Proxy: target } }, + }, + "second.example.ts.net:443": { + Handlers: { "/api/aiden/v1": { Proxy: "http://localhost:43179" } }, + }, + }, + }), + [ + { target, port: 43_177 }, + { target: "http://localhost:43179", port: 43_179 }, + ], + ); +}); + +test("Tailscale permits origin-only legacy targets for exact owned cleanup but never for connect", () => { + const legacyTarget = "http://127.0.0.1:43177"; + const ownership = { path: "/api/aiden/v1" as const, target: legacyTarget }; + assert.throws( + () => planAidenTailscaleConnect(status(), legacyTarget), + /tailscale_target_invalid/, + ); + assert.deepEqual( + planAidenTailscaleDisconnect(status(legacyTarget), legacyTarget, ownership), + { action: "clear", args: ["serve", "--https=443", "--set-path=/api/aiden/v1", "off"] }, + ); +}); + +test("Tailscale rejects raw and encoded path/query/fragment aliases before URL normalization", () => { + for (const unsafeTarget of [ + `${target}/./`, + `${target}/../`, + `${target}/a/../`, + `${target}/%2e/`, + `${target}/%2e%2e/`, + `${target}/%2E%2E/`, + `${target}//`, + `${target}?`, + `${target}#`, + `${target}/?`, + `${target}/#`, + `${target}?probe=1`, + `${target}#probe`, + "http://127.0.0.1:0/api/aiden/v1", + "http://127.0.0.1:00001/api/aiden/v1", + "http://127.0.0.1:65536/api/aiden/v1", + ]) { + assert.throws(() => planAidenTailscaleConnect(status(), unsafeTarget), /tailscale_target_invalid/); + assert.throws(() => planAidenTailscaleDisconnect(status(), unsafeTarget), /tailscale_target_invalid/); + } +}); + +test("Tailscale connect requires explicit TCP HTTPS capability while owned disconnect remains safe", () => { + const withoutCapability: AidenTailscaleStatus = { Web: status(target).Web }; + const disabledCapability: AidenTailscaleStatus = { ...status(target), TCP: { "443": { HTTPS: false } } }; + for (const unavailable of [withoutCapability, disabledCapability]) { + assert.throws(() => planAidenTailscaleConnect(unavailable, target), /tailscale_https_unavailable/); + assert.throws(() => planAidenTailscaleConnect(unavailable, target, { path: "/api/aiden/v1", target }), /tailscale_https_unavailable/); + assert.deepEqual( + planAidenTailscaleDisconnect(unavailable, target, { path: "/api/aiden/v1", target }), + { action: "clear", args: ["serve", "--https=443", "--set-path=/api/aiden/v1", "off"] }, + ); + } + assert.deepEqual( + planAidenTailscaleConnect({}, target, undefined, true), + { + action: "set", + args: ["serve", "--https=443", "--set-path=/api/aiden/v1", target], + ownership: { path: "/api/aiden/v1", target }, + }, + ); + assert.throws( + () => planAidenTailscaleConnect(disabledCapability, target, undefined, true), + /tailscale_https_unavailable/, + ); +}); + +test("Tailscale rejects malformed and noncanonical Web listener authorities", () => { + for (const authority of [ + "aiden-device.example.ts.net:0443", + "aiden-device.example.ts.net:+443", + "aiden-device.example.ts.net:443 ", + "aiden-device.example.ts.net", + "aiden-device.example.ts.net:not-a-port", + "aiden-device.example.ts.net:0", + "[aiden-device.example.ts.net]:443", + ]) { + const malformedStatus: AidenTailscaleStatus = { + TCP: { "443": { HTTPS: true } }, + Web: { [authority]: { Handlers: {} } }, + }; + assert.throws(() => planAidenTailscaleConnect(malformedStatus, target), /tailscale_route_conflict/); + assert.throws( + () => planAidenTailscaleDisconnect(malformedStatus, target, { path: "/api/aiden/v1", target }), + /tailscale_route_conflict/, + ); + } +}); + +test("Tailscale rejects multiple canonical HTTPS listeners instead of selecting one", () => { + const multipleListeners: AidenTailscaleStatus = { + TCP: { "443": { HTTPS: true } }, + Web: { + "aiden-device.example.ts.net:443": { Handlers: {} }, + "other-device.example.ts.net:443": { Handlers: {} }, + }, + }; + assert.throws(() => planAidenTailscaleConnect(multipleListeners, target), /tailscale_route_conflict/); + assert.throws( + () => planAidenTailscaleDisconnect(multipleListeners, target, { path: "/api/aiden/v1", target }), + /tailscale_route_conflict/, + ); +}); diff --git a/main/services/aiden-remote-tailscale-route.ts b/main/services/aiden-remote-tailscale-route.ts new file mode 100644 index 00000000..2ef0cdee --- /dev/null +++ b/main/services/aiden-remote-tailscale-route.ts @@ -0,0 +1,261 @@ +import { isIP } from "node:net"; +import { AIDEN_REMOTE_BASE_PATH } from "./aiden-remote-protocol.js"; + +export interface AidenTailscaleHandler { Proxy: string } +export interface AidenTailscaleStatus { TCP?: Record; Web?: Record; Funnel?: boolean }> } + +// The public Serve prefix is the canonical API base. Tailscale strips this +// prefix before proxying to the loopback listener, while the client retains +// the one OpenAPI-approved `/api/aiden/v1` endpoint spelling. +export const AIDEN_TAILSCALE_PATH = AIDEN_REMOTE_BASE_PATH; +export interface AidenTailscaleOwnership { path: typeof AIDEN_TAILSCALE_PATH; target: string } +export type AidenTailscaleRouteClassification = + | { kind: "available" } + | { kind: "owned"; target: string } + | { kind: "other_aiden"; target: string } + | { kind: "unrelated_conflict" } + | { kind: "funnel_conflict" }; + +const CANONICAL_LOOPBACK_HTTP_TARGET = new RegExp( + `^http://(?:localhost|127\\.0\\.0\\.1|\\[::1\\]):([1-9]\\d{0,4})${AIDEN_REMOTE_BASE_PATH}$`, +); +const LEGACY_LOOPBACK_HTTP_ORIGIN = /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):([1-9]\d{0,4})\/?$/; + +interface ParsedTailscaleAuthority { + port: number; + canonicalPort: boolean; +} + +function hasUnsafeAuthorityCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint === undefined || codePoint <= 0x20 || codePoint === 0x7f || "[]/?#@\\%".includes(character)) return true; + } + return false; +} + +/** + * Tailscale serializes Web keys from net.JoinHostPort as an SNI host and a + * decimal port. Do not use URL parsing here: it accepts/canonicalizes aliases + * that are not the exact keys Tailscale reports. + */ +function parseTailscaleAuthority(authority: string): ParsedTailscaleAuthority | undefined { + let host: string; + let rawPort: string; + if (authority.startsWith("[")) { + const closingBracket = authority.indexOf("]"); + if ( + closingBracket <= 1 || + authority[closingBracket + 1] !== ":" || + authority.indexOf("[", 1) !== -1 || + authority.indexOf("]", closingBracket + 1) !== -1 + ) { + return undefined; + } + host = authority.slice(1, closingBracket); + rawPort = authority.slice(closingBracket + 2); + if (isIP(host) !== 6) return undefined; + } else { + const colon = authority.lastIndexOf(":"); + if (colon <= 0 || authority.indexOf(":") !== colon) return undefined; + host = authority.slice(0, colon); + rawPort = authority.slice(colon + 1); + } + if (!host || hasUnsafeAuthorityCharacter(host) || !/^\d{1,5}$/u.test(rawPort)) return undefined; + + const port = Number(rawPort); + if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined; + return { port, canonicalPort: rawPort === String(port) }; +} + +function httpsEndpoint(status: AidenTailscaleStatus): { handlers: Record; funnel: boolean } { + const entries: Array<[string, { Handlers?: Record; Funnel?: boolean }]> = []; + for (const [authority, listener] of Object.entries(status.Web ?? {})) { + const parsed = parseTailscaleAuthority(authority); + if (!parsed || !parsed.canonicalPort) throw new Error("tailscale_route_conflict"); + if (parsed.port === 443) entries.push([authority, listener]); + } + if (entries.length > 1) throw new Error("tailscale_route_conflict"); + return { + handlers: entries[0]?.[1].Handlers ?? {}, + funnel: entries[0]?.[1].Funnel === true, + }; +} + +export function aidenTailscaleCanonicalRouteSnapshot(status: AidenTailscaleStatus): { + target?: string; + funnel: boolean; + preservedStatus: AidenTailscaleStatus; +} { + const endpoint = httpsEndpoint(status); + const preservedStatus = structuredClone(status); + for (const [authority, listener] of Object.entries(preservedStatus.Web ?? {})) { + const parsed = parseTailscaleAuthority(authority); + if (!parsed || !parsed.canonicalPort || parsed.port !== 443) continue; + if (listener.Handlers) { + delete listener.Handlers[AIDEN_TAILSCALE_PATH]; + if (Object.keys(listener.Handlers).length === 0) delete listener.Handlers; + } + if (Object.keys(listener).length === 0) delete preservedStatus.Web?.[authority]; + } + if (preservedStatus.Web && Object.keys(preservedStatus.Web).length === 0) delete preservedStatus.Web; + return { + ...(endpoint.handlers[AIDEN_TAILSCALE_PATH]?.Proxy + ? { target: endpoint.handlers[AIDEN_TAILSCALE_PATH]!.Proxy } + : {}), + funnel: endpoint.funnel, + preservedStatus, + }; +} + +function assertHttpsCapability(status: AidenTailscaleStatus, nodeHttpsAvailable: boolean): void { + const configuredListener = status.TCP?.["443"]; + if (configuredListener !== undefined && configuredListener?.HTTPS !== true) { + throw new Error("tailscale_https_unavailable"); + } + if (configuredListener?.HTTPS !== true && !nodeHttpsAvailable) { + throw new Error("tailscale_https_unavailable"); + } +} + +function assertServerOwnedLoopbackTarget(target: string, allowLegacyOrigin = false): void { + // Tailscale strips the mounted public prefix before reverse proxying. The + // target restores exactly that canonical API base so the shared LAN and + // loopback router receive identical paths. Validate the wire form before + // WHATWG URL normalization; arbitrary local paths remain forbidden. + const canonicalTarget = CANONICAL_LOOPBACK_HTTP_TARGET.exec(target); + const legacyTarget = allowLegacyOrigin ? LEGACY_LOOPBACK_HTTP_ORIGIN.exec(target) : null; + const rawTarget = canonicalTarget ?? legacyTarget; + const rawPort = Number(rawTarget?.[1]); + if (!rawTarget || !Number.isInteger(rawPort) || rawPort > 65_535) { + throw new Error("tailscale_target_invalid"); + } + let endpoint: URL; + try { + endpoint = new URL(target); + } catch { + throw new Error("tailscale_target_invalid"); + } + if ( + endpoint.protocol !== "http:" || + endpoint.username || + endpoint.password || + endpoint.pathname !== (canonicalTarget ? AIDEN_REMOTE_BASE_PATH : "/") || + endpoint.search || + endpoint.hash || + !endpoint.port || + !["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname) + ) { + throw new Error("tailscale_target_invalid"); + } +} + +function isAidenLoopbackTarget(target: string, allowLegacyOrigin = true): boolean { + try { + assertServerOwnedLoopbackTarget(target, allowLegacyOrigin); + return true; + } catch { + return false; + } +} + +export function aidenTailscaleHealthEndpoint(target: string): string { + assertServerOwnedLoopbackTarget(target, true); + if (CANONICAL_LOOPBACK_HTTP_TARGET.test(target)) return `${target}/health`; + return `${target.replace(/\/$/u, "")}${AIDEN_REMOTE_BASE_PATH}/health`; +} + +export function classifyAidenTailscaleRoute( + status: AidenTailscaleStatus, + target: string, + ownership?: AidenTailscaleOwnership, +): AidenTailscaleRouteClassification { + assertServerOwnedLoopbackTarget(target); + const canonicalListeners = Object.entries(status.Web ?? {}).filter(([authority]) => { + const parsed = parseTailscaleAuthority(authority); + return parsed?.canonicalPort === true && parsed.port === 443; + }); + let endpoint: ReturnType; + try { + endpoint = httpsEndpoint(status); + } catch { + if (canonicalListeners.some(([, listener]) => listener.Funnel === true)) { + return { kind: "funnel_conflict" }; + } + return { kind: "unrelated_conflict" }; + } + const handler = endpoint.handlers[AIDEN_TAILSCALE_PATH]; + if (endpoint.funnel) return { kind: "funnel_conflict" }; + if (handler && status.TCP?.["443"]?.HTTPS !== true) { + return { kind: "unrelated_conflict" }; + } + if ( + handler?.Proxy === target + && ownership?.path === AIDEN_TAILSCALE_PATH + && ownership.target === target + ) { + return { kind: "owned", target }; + } + if (!handler) return { kind: "available" }; + if (isAidenLoopbackTarget(handler.Proxy)) { + return { kind: "other_aiden", target: handler.Proxy }; + } + return { kind: "unrelated_conflict" }; +} + +export function aidenTailscaleCanonicalLoopbackPort( + status: AidenTailscaleStatus, +): number | undefined { + return aidenTailscaleCanonicalLoopbackTargets(status)[0]?.port; +} + +export function aidenTailscaleCanonicalHandlerTarget( + status: AidenTailscaleStatus, +): string | undefined { + return httpsEndpoint(status).handlers[AIDEN_TAILSCALE_PATH]?.Proxy; +} + +export function aidenTailscaleCanonicalLoopbackTargets( + status: AidenTailscaleStatus, +): Array<{ target: string; port: number }> { + const targets: Array<{ target: string; port: number }> = []; + for (const listener of Object.values(status.Web ?? {})) { + const target = listener.Handlers?.[AIDEN_TAILSCALE_PATH]?.Proxy; + if (!target) continue; + const match = CANONICAL_LOOPBACK_HTTP_TARGET.exec(target) + ?? LEGACY_LOOPBACK_HTTP_ORIGIN.exec(target); + if (!match) continue; + const port = Number(match[1]); + if (!Number.isInteger(port) || port > 65_535) continue; + if (!targets.some((value) => value.target === target)) targets.push({ target, port }); + } + return targets; +} + +export function planAidenTailscaleConnect( + status: AidenTailscaleStatus, + target: string, + ownership?: AidenTailscaleOwnership, + nodeHttpsAvailable = false, +): { action: "set" | "noop"; args?: string[]; ownership: AidenTailscaleOwnership } { + assertHttpsCapability(status, nodeHttpsAvailable); + assertServerOwnedLoopbackTarget(target); + const classification = classifyAidenTailscaleRoute(status, target, ownership); + const nextOwnership = { path: AIDEN_TAILSCALE_PATH, target } as const; + if (classification.kind === "available") return { action: "set", args: ["serve", "--https=443", `--set-path=${AIDEN_TAILSCALE_PATH}`, target], ownership: nextOwnership }; + if (classification.kind === "owned") return { action: "noop", ownership: nextOwnership }; + if (classification.kind === "funnel_conflict") throw new Error("tailscale_funnel_conflict"); + throw new Error("tailscale_route_conflict"); +} + +export function planAidenTailscaleDisconnect(status: AidenTailscaleStatus, target: string, ownership?: AidenTailscaleOwnership): { action: "clear" | "noop"; args?: string[] } { + // Origin-only targets were persisted by pre-acceptance builds. They may be + // recognized only for exact owned cleanup, never for a new connection. + assertServerOwnedLoopbackTarget(target, true); + const endpoint = httpsEndpoint(status); + if (endpoint.funnel) throw new Error("tailscale_funnel_conflict"); + const handler = endpoint.handlers[AIDEN_TAILSCALE_PATH]; + if (!handler) return { action: "noop" }; + if (handler.Proxy !== target || ownership?.path !== AIDEN_TAILSCALE_PATH || ownership.target !== target) throw new Error("tailscale_route_conflict"); + return { action: "clear", args: ["serve", "--https=443", `--set-path=${AIDEN_TAILSCALE_PATH}`, "off"] }; +} diff --git a/main/services/aiden-remote-tailscale.test.ts b/main/services/aiden-remote-tailscale.test.ts new file mode 100644 index 00000000..794d0204 --- /dev/null +++ b/main/services/aiden-remote-tailscale.test.ts @@ -0,0 +1,611 @@ +import assert from "node:assert/strict"; +import { createSocket } from "node:dgram"; +import { createServer } from "node:net"; +import test from "node:test"; +import { + AidenRemoteTailscaleController, + withAidenTailscaleRouteLock, + type AidenTailscaleCommandRunner, +} from "./aiden-remote-tailscale.js"; + +const target = "http://127.0.0.1:43177/api/aiden/v1"; + +async function availableLoopbackPort(): Promise { + const socket = createSocket({ type: "udp4", reuseAddr: false }); + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.bind({ address: "127.0.0.1", port: 0, exclusive: true }, resolve); + }); + const address = socket.address(); + await new Promise((resolve) => socket.close(() => resolve())); + return address.port; +} + +function fixture(options: { emptyServeStatus?: boolean; certDomains?: unknown } = {}) { + const calls: string[][] = []; + let connected = false; + const runner: AidenTailscaleCommandRunner = { + run: async (args) => { + calls.push([...args]); + if (args[0] === "status") { + return JSON.stringify({ + Self: { DNSName: "aiden.tailnet.ts.net." }, + CertDomains: options.certDomains ?? ["aiden.tailnet.ts.net"], + }); + } + if (args[0] === "serve" && args[1] === "status") { + if (options.emptyServeStatus && !connected) return "{}"; + return JSON.stringify({ + TCP: { "443": { HTTPS: true } }, + Web: { + "aiden.tailnet.ts.net:443": { + Handlers: connected + ? { + "/api/aiden/v1": { Proxy: target }, + ...(options.emptyServeStatus ? {} : { "/other": { Proxy: "http://127.0.0.1:9" } }), + } + : { "/other": { Proxy: "http://127.0.0.1:9" } }, + }, + }, + }); + } + if (args.includes("off")) connected = false; + else connected = true; + return ""; + }, + }; + return { controller: new AidenRemoteTailscaleController(runner), calls }; +} + +test("Tailscale controller connects and verifies only Aiden's route", async () => { + const app = fixture(); + const ownership = await app.controller.connect(target); + assert.deepEqual(ownership, { path: "/api/aiden/v1", target }); + assert.deepEqual(app.calls.find((args) => args.includes("--set-path=/api/aiden/v1") && !args.includes("off")), [ + "serve", "--yes", "--bg", "--https=443", "--set-path=/api/aiden/v1", target, + ]); + assert.equal(app.calls.some((args) => args.includes("reset")), false); + assert.equal(app.calls.some((args) => args.includes("funnel")), false); + + await app.controller.disconnect(target, ownership); + assert.deepEqual(app.calls[app.calls.length - 2], [ + "serve", "--https=443", "--set-path=/api/aiden/v1", "off", + ]); +}); + +test("Tailscale controller reports stable URL identity without mutating configuration", async () => { + const app = fixture(); + assert.deepEqual(await app.controller.status(), { + installed: true, + dnsName: "aiden.tailnet.ts.net", + httpsAvailable: true, + serveStatus: { + TCP: { "443": { HTTPS: true } }, + Web: { + "aiden.tailnet.ts.net:443": { + Handlers: { "/other": { Proxy: "http://127.0.0.1:9" } }, + }, + }, + }, + }); + assert.equal(app.calls.length, 2); +}); + +test("Tailscale controller creates the first HTTPS listener only after exact certificate-domain proof", async () => { + const app = fixture({ emptyServeStatus: true }); + const ownership = await app.controller.connect(target); + assert.deepEqual(ownership, { path: "/api/aiden/v1", target }); + assert.deepEqual(app.calls.find((args) => args.includes("--set-path=/api/aiden/v1") && !args.includes("off")), [ + "serve", "--yes", "--bg", "--https=443", "--set-path=/api/aiden/v1", target, + ]); +}); + +test("first-listener verification rejects a route without explicit TCP 443 HTTPS state", async () => { + const calls: string[][] = []; + const brokenStatus = { + Web: { + "aiden.tailnet.ts.net:443": { + Handlers: {} as Record, + }, + }, + }; + const runner: AidenTailscaleCommandRunner = { + run: async (args) => { + calls.push([...args]); + if (args[0] === "status") { + return JSON.stringify({ + Self: { DNSName: "aiden.tailnet.ts.net." }, + CertDomains: ["aiden.tailnet.ts.net"], + }); + } + if (args[0] === "serve" && args[1] === "status") { + return calls.filter((call) => call.includes("--set-path=/api/aiden/v1")).length === 0 + ? "{}" + : JSON.stringify(brokenStatus); + } + const nextTarget = args[args.length - 1]; + if (nextTarget !== "off") { + brokenStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/api/aiden/v1"] = { + Proxy: nextTarget!, + }; + } + return ""; + }, + }; + const controller = new AidenRemoteTailscaleController(runner); + let persistCalls = 0; + await assert.rejects( + controller.connect(target, undefined, async () => { persistCalls += 1; }), + /tailscale_route_(verification|recovery)_failed/u, + ); + assert.equal(persistCalls, 0); +}); + +test("Tailscale controller refuses first-listener mutation without exact HTTPS eligibility", async () => { + for (const certDomains of [[], ["other.tailnet.ts.net"], ["aiden.tailnet.ts.net.evil"]]) { + const app = fixture({ emptyServeStatus: true, certDomains }); + await assert.rejects(app.controller.connect(target), /tailscale_https_unavailable/u); + assert.equal(app.calls.some((args) => args.includes("--set-path=/api/aiden/v1")), false); + } +}); + +test("an owned handler without TCP 443 HTTPS is never accepted as a connected no-op", async () => { + const app = takeoverFixture({ incumbent: target }); + delete (app.serveStatus as { TCP?: unknown }).TCP; + const ownership = { path: "/api/aiden/v1" as const, target }; + assert.deepEqual( + await app.controller.assessRoute(target, ownership), + { state: "unrelated_conflict" }, + ); + await assert.rejects( + app.controller.connect(target, ownership), + /tailscale_route_conflict/u, + ); +}); + +test("missing Tailscale is explicit and cannot mutate routes", async () => { + const controller = new AidenRemoteTailscaleController(null); + assert.deepEqual(await controller.status(), { + installed: false, + errorCode: "not_installed", + }); + await assert.rejects(controller.connect(target), /tailscale_not_installed/u); +}); + +function takeoverFixture(options: { + incumbent?: string; + healthy?: boolean; + now?: number; + failMutation?: boolean; +} = {}) { + const incumbent = options.incumbent ?? "http://127.0.0.1:43179/api/aiden/v1"; + const calls: string[][] = []; + let healthy = options.healthy ?? false; + let now = options.now ?? 1_000; + let monotonicNow = options.now ?? 1_000; + let failMutation = options.failMutation ?? false; + let failAfterMutation = false; + let dropTcpAfterMutation = false; + let postMutationStatusFailures = 0; + let mutationApplied = false; + let mutateAfterNextStatusRead: string | undefined; + let pendingOutcome: import("./aiden-remote-tailscale.js").AidenTailscalePendingRouteOutcome | undefined; + let reconciledOwnership: { path: "/api/aiden/v1"; target: string } | undefined; + const serveStatus = { + TCP: { "443": { HTTPS: true } }, + Web: { + "aiden.tailnet.ts.net:443": { + Handlers: { + "/api/aiden/v1": { Proxy: incumbent }, + "/other": { Proxy: "http://127.0.0.1:9" }, + }, + }, + }, + }; + const runner: AidenTailscaleCommandRunner = { + run: async (args) => { + calls.push([...args]); + if (args[0] === "status") { + return JSON.stringify({ + Self: { DNSName: "aiden.tailnet.ts.net." }, + CertDomains: ["aiden.tailnet.ts.net"], + }); + } + if (args[0] === "serve" && args[1] === "status") { + if (mutationApplied && postMutationStatusFailures > 0) { + postMutationStatusFailures -= 1; + throw new Error("tailscaled status unavailable"); + } + const serialized = JSON.stringify(serveStatus); + if (mutateAfterNextStatusRead) { + (serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers as Record)["/api/aiden/v1"] = { + Proxy: mutateAfterNextStatusRead, + }; + mutateAfterNextStatusRead = undefined; + } + return serialized; + } + if (failMutation) throw new Error("command failed"); + const nextTarget = args[args.length - 1]; + if (nextTarget === "off") { + delete (serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers as Record)["/api/aiden/v1"]; + } else { + (serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers as Record)["/api/aiden/v1"] = { Proxy: nextTarget! }; + } + mutationApplied = true; + if (dropTcpAfterMutation) delete (serveStatus as { TCP?: unknown }).TCP; + if (failAfterMutation) throw new Error("command outcome unknown"); + return ""; + }, + }; + const controller = new AidenRemoteTailscaleController(runner, { + now: () => now, + monotonicNow: () => monotonicNow, + randomToken: () => "A".repeat(32), + probeHealth: async () => healthy, + outcomeStore: { + begin: async (outcome) => { pendingOutcome = structuredClone(outcome); }, + snapshot: async () => structuredClone(pendingOutcome), + commit: async (ownership) => { + reconciledOwnership = ownership; + pendingOutcome = undefined; + }, + clear: async () => { pendingOutcome = undefined; }, + }, + }); + return { + controller, + runner, + calls, + serveStatus, + setHealthy: (value: boolean) => { healthy = value; }, + setNow: (value: number) => { now = value; monotonicNow = value; }, + setWallClock: (value: number) => { now = value; }, + setFailMutation: (value: boolean) => { failMutation = value; }, + setFailAfterMutation: (value: boolean) => { failAfterMutation = value; }, + setDropTcpAfterMutation: (value: boolean) => { dropTcpAfterMutation = value; }, + failNextPostMutationStatusReads: (count: number) => { postMutationStatusFailures = count; }, + pendingOutcome: () => structuredClone(pendingOutcome), + reconciledOwnership: () => structuredClone(reconciledOwnership), + setRouteTarget: (value: string) => { + (serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers as Record)["/api/aiden/v1"] = { Proxy: value }; + }, + mutateRouteAfterNextStatusRead: (value: string) => { mutateAfterNextStatusRead = value; }, + }; +} + +test("live Aiden route is classified and cannot produce a takeover review", async () => { + const app = takeoverFixture({ healthy: true }); + assert.deepEqual(await app.controller.assessRoute(target), { state: "other_aiden_live" }); + await assert.rejects(app.controller.reviewTakeover(target), /tailscale_route_live/u); + assert.equal(app.calls.some((args) => args.includes("--set-path=/api/aiden/v1")), false); +}); + +test("unrelated handlers and Funnel conflicts never offer takeover", async () => { + const unrelated = takeoverFixture(); + unrelated.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/api/aiden/v1"].Proxy = "http://127.0.0.1:43179/private"; + assert.deepEqual(await unrelated.controller.assessRoute(target), { state: "unrelated_conflict" }); + await assert.rejects(unrelated.controller.reviewTakeover(target), /tailscale_takeover_unavailable/u); + + const funnel = takeoverFixture(); + (funnel.serveStatus.Web["aiden.tailnet.ts.net:443"] as { Funnel?: boolean }).Funnel = true; + assert.deepEqual(await funnel.controller.assessRoute(target), { state: "funnel_conflict" }); + await assert.rejects(funnel.controller.reviewTakeover(target), /tailscale_takeover_unavailable/u); + assert.equal(funnel.calls.some((args) => args.includes("--set-path=/api/aiden/v1")), false); +}); + +test("stale Aiden route requires a one-use review and preserves unrelated Serve state", async () => { + const app = takeoverFixture(); + assert.deepEqual(await app.controller.assessRoute(target), { state: "other_aiden_stale" }); + const unrelatedBefore = JSON.stringify( + app.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/other"], + ); + const review = await app.controller.reviewTakeover(target); + assert.equal(review.token, "A".repeat(32)); + let persisted: unknown; + await app.controller.takeOver(target, review.token, async (ownership) => { + persisted = ownership; + }); + assert.deepEqual(persisted, { path: "/api/aiden/v1", target }); + assert.equal( + app.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/api/aiden/v1"].Proxy, + target, + ); + assert.equal( + JSON.stringify(app.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/other"]), + unrelatedBefore, + ); + assert.equal(app.calls.some((args) => args.includes("reset") || args.includes("funnel")), false); + await assert.rejects( + app.controller.takeOver(target, review.token, async () => undefined), + /tailscale_takeover_expired/u, + ); +}); + +test("takeover fails closed when Serve state or incumbent health changes after review", async () => { + const changed = takeoverFixture(); + const changedReview = await changed.controller.reviewTakeover(target); + changed.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/other"].Proxy = "http://127.0.0.1:10"; + await assert.rejects( + changed.controller.takeOver(target, changedReview.token, async () => undefined), + /tailscale_takeover_changed/u, + ); + assert.equal(changed.calls.some((args) => args.includes("--set-path=/api/aiden/v1")), false); + + const revived = takeoverFixture(); + const revivedReview = await revived.controller.reviewTakeover(target); + revived.setHealthy(true); + await assert.rejects( + revived.controller.takeOver(target, revivedReview.token, async () => undefined), + /tailscale_route_live/u, + ); + assert.equal(revived.calls.some((args) => args.includes("--set-path=/api/aiden/v1")), false); +}); + +test("expired takeover reviews and failed commands never persist ownership", async () => { + const expired = takeoverFixture({ now: 10 }); + const review = await expired.controller.reviewTakeover(target); + expired.setNow(review.expiresAt); + await assert.rejects( + expired.controller.takeOver(target, review.token, async () => undefined), + /tailscale_takeover_expired/u, + ); + + const failed = takeoverFixture({ failMutation: true }); + const failedReview = await failed.controller.reviewTakeover(target); + let persistCalls = 0; + await assert.rejects( + failed.controller.takeOver(target, failedReview.token, async () => { persistCalls += 1; }), + /tailscale_route_outcome_unknown/u, + ); + assert.equal(persistCalls, 0); +}); + +test("ownership persistence failure restores the exact stale incumbent route", async () => { + const incumbent = "http://127.0.0.1:43179/api/aiden/v1"; + const app = takeoverFixture({ incumbent }); + const review = await app.controller.reviewTakeover(target); + await assert.rejects( + app.controller.takeOver(target, review.token, async () => { throw new Error("disk full"); }), + /tailscale_ownership_commit_failed/u, + ); + assert.equal( + app.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/api/aiden/v1"].Proxy, + incumbent, + ); +}); + +test("persistence rollback never overwrites a route changed by an external successor", async () => { + const successor = "http://127.0.0.1:43183/api/aiden/v1"; + + const connect = takeoverFixture(); + delete (connect.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers as Record)["/api/aiden/v1"]; + await assert.rejects( + connect.controller.connect(target, undefined, async () => { + await connect.runner.run(["serve", "--yes", "--bg", "--https=443", "--set-path=/api/aiden/v1", successor]); + throw new Error("disk full"); + }), + /tailscale_route_recovery_failed/u, + ); + assert.equal(connect.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/api/aiden/v1"].Proxy, successor); + + const takeover = takeoverFixture(); + const takeoverReview = await takeover.controller.reviewTakeover(target); + await assert.rejects( + takeover.controller.takeOver(target, takeoverReview.token, async () => { + await takeover.runner.run(["serve", "--yes", "--bg", "--https=443", "--set-path=/api/aiden/v1", successor]); + throw new Error("disk full"); + }), + /tailscale_route_recovery_failed/u, + ); + assert.equal(takeover.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/api/aiden/v1"].Proxy, successor); + + const disconnect = takeoverFixture({ incumbent: target }); + await assert.rejects( + disconnect.controller.disconnect(target, { path: "/api/aiden/v1", target }, async () => { + await disconnect.runner.run(["serve", "--yes", "--bg", "--https=443", "--set-path=/api/aiden/v1", successor]); + throw new Error("disk full"); + }), + /tailscale_route_recovery_failed/u, + ); + assert.equal(disconnect.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/api/aiden/v1"].Proxy, successor); +}); + +test("an ambiguous CLI error is reconciled when the exact route was safely applied", async () => { + const app = takeoverFixture(); + const review = await app.controller.reviewTakeover(target); + app.setFailAfterMutation(true); + let persisted = false; + await app.controller.takeOver(target, review.token, async () => { persisted = true; }); + assert.equal(persisted, true); + assert.equal(app.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/api/aiden/v1"].Proxy, target); +}); + +test("post-mutation status retries transient failures and durably reconciles an unknown exact outcome", async () => { + const transient = takeoverFixture(); + const transientReview = await transient.controller.reviewTakeover(target); + transient.failNextPostMutationStatusReads(2); + let transientPersisted = false; + await transient.controller.takeOver(target, transientReview.token, async () => { + transientPersisted = true; + }); + assert.equal(transientPersisted, true); + + const unknown = takeoverFixture(); + const unknownReview = await unknown.controller.reviewTakeover(target); + unknown.failNextPostMutationStatusReads(3); + let persistCalls = 0; + await assert.rejects( + unknown.controller.takeOver(target, unknownReview.token, async () => { persistCalls += 1; }), + /tailscale_route_outcome_unknown/u, + ); + assert.equal(persistCalls, 0); + assert.equal(unknown.pendingOutcome()?.operation, "takeover"); + unknown.setHealthy(true); + assert.equal(await unknown.controller.reconcilePendingOutcome(), "connected"); + assert.deepEqual(unknown.reconciledOwnership(), { path: "/api/aiden/v1", target }); + assert.equal(unknown.pendingOutcome(), undefined); +}); + +test("not-applied reconciliation retains its durable record when a delayed mutation appears", async () => { + const incumbent = "http://127.0.0.1:43179/api/aiden/v1"; + const app = takeoverFixture({ incumbent }); + const review = await app.controller.reviewTakeover(target); + app.failNextPostMutationStatusReads(3); + await assert.rejects( + app.controller.takeOver(target, review.token, async () => undefined), + /tailscale_route_outcome_unknown/u, + ); + app.setRouteTarget(incumbent); + app.mutateRouteAfterNextStatusRead(target); + await assert.rejects( + app.controller.reconcilePendingOutcome(), + /tailscale_reconciliation_conflict/u, + ); + assert.equal(app.pendingOutcome()?.operation, "takeover"); +}); + +test("applied-disconnect reconciliation retains its record when a route reappears", async () => { + const app = takeoverFixture({ incumbent: target }); + app.failNextPostMutationStatusReads(3); + await assert.rejects( + app.controller.disconnect(target, { path: "/api/aiden/v1", target }), + /tailscale_route_outcome_unknown/u, + ); + app.mutateRouteAfterNextStatusRead(target); + await assert.rejects( + app.controller.reconcilePendingOutcome(), + /tailscale_reconciliation_conflict/u, + ); + assert.equal(app.pendingOutcome()?.operation, "disconnect"); +}); + +test("takeover verification rejects removal of TCP 443 needed by unrelated handlers", async () => { + const app = takeoverFixture(); + const review = await app.controller.reviewTakeover(target); + app.setDropTcpAfterMutation(true); + let persistCalls = 0; + await assert.rejects( + app.controller.takeOver(target, review.token, async () => { persistCalls += 1; }), + /tailscale_route_(verification|recovery)_failed/u, + ); + assert.equal(persistCalls, 0); +}); + +test("takeover re-reads Serve immediately after the health probe", async () => { + const app = takeoverFixture(); + let probes = 0; + const successor = "http://127.0.0.1:43183/api/aiden/v1"; + const controller = new AidenRemoteTailscaleController(app.runner, { + now: () => 1_000, + monotonicNow: () => 1_000, + randomToken: () => "C".repeat(32), + probeHealth: async () => { + probes += 1; + if (probes === 2) { + app.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/api/aiden/v1"].Proxy = successor; + } + return false; + }, + }); + const review = await controller.reviewTakeover(target); + await assert.rejects( + controller.takeOver(target, review.token, async () => undefined), + /tailscale_takeover_changed/u, + ); + assert.equal(app.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/api/aiden/v1"].Proxy, successor); +}); + +test("takeover expiry uses monotonic time even when the wall clock moves backward", async () => { + const app = takeoverFixture({ now: 5_000 }); + const review = await app.controller.reviewTakeover(target); + app.setWallClock(-50_000); + app.setNow(review.expiresAt); + app.setWallClock(-50_000); + await assert.rejects( + app.controller.takeOver(target, review.token, async () => undefined), + /tailscale_takeover_expired/u, + ); +}); + +test("an old owner cannot disconnect a successor route", async () => { + const successor = "http://127.0.0.1:43179/api/aiden/v1"; + const app = takeoverFixture({ incumbent: successor }); + await assert.rejects( + app.controller.disconnect(target, { path: "/api/aiden/v1", target }), + /tailscale_route_conflict/u, + ); + assert.equal( + app.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/api/aiden/v1"].Proxy, + successor, + ); +}); + +test("separate Aiden controllers serialize competing takeovers across the Mac", async () => { + const app = takeoverFixture(); + const second = new AidenRemoteTailscaleController(app.runner, { + now: () => 1_000, + randomToken: () => "B".repeat(32), + probeHealth: async () => false, + }); + const [firstReview, secondReview] = await Promise.all([ + app.controller.reviewTakeover(target), + second.reviewTakeover("http://127.0.0.1:43181/api/aiden/v1"), + ]); + const persisted: string[] = []; + const results = await Promise.allSettled([ + app.controller.takeOver(target, firstReview.token, async (ownership) => { + persisted.push(ownership.target); + }), + second.takeOver( + "http://127.0.0.1:43181/api/aiden/v1", + secondReview.token, + async (ownership) => { persisted.push(ownership.target); }, + ), + ]); + assert.equal(results.filter((result) => result.status === "fulfilled").length, 1); + assert.equal(results.filter((result) => result.status === "rejected").length, 1); + assert.equal(persisted.length, 1); + assert.equal( + app.serveStatus.Web["aiden.tailnet.ts.net:443"].Handlers["/api/aiden/v1"].Proxy, + persisted[0], + ); +}); + +test("kernel-owned route lock blocks concurrent owners and releases cleanly", async () => { + const port = await availableLoopbackPort(); + let release!: () => void; + let acquired!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + const didAcquire = new Promise((resolve) => { acquired = resolve; }); + const holder = withAidenTailscaleRouteLock(async () => { + acquired(); + return held; + }, { port, attempts: 1, retryMs: 1 }); + await didAcquire; + await assert.rejects( + withAidenTailscaleRouteLock(async () => undefined, { port, attempts: 1, retryMs: 1 }), + /tailscale_route_busy/u, + ); + release(); + await holder; + await withAidenTailscaleRouteLock(async () => undefined, { port, attempts: 1, retryMs: 1 }); +}); + +test("route lock cannot collide with a retained Aiden TCP listener on the same port", async () => { + const port = await availableLoopbackPort(); + const listener = createServer(); + await new Promise((resolve, reject) => { + listener.once("error", reject); + listener.listen({ host: "127.0.0.1", port, exclusive: true }, resolve); + }); + try { + await withAidenTailscaleRouteLock(async () => undefined, { + port, + attempts: 1, + retryMs: 1, + }); + } finally { + await new Promise((resolve) => listener.close(() => resolve())); + } +}); diff --git a/main/services/aiden-remote-tailscale.ts b/main/services/aiden-remote-tailscale.ts new file mode 100644 index 00000000..f8fc3438 --- /dev/null +++ b/main/services/aiden-remote-tailscale.ts @@ -0,0 +1,794 @@ +import { execFile } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { createSocket } from "node:dgram"; +import * as fs from "node:fs/promises"; +import { get as httpGet } from "node:http"; +import { performance } from "node:perf_hooks"; +import { promisify } from "node:util"; +import { parseAidenRemoteJson } from "./aiden-remote-protocol.js"; +import { + aidenTailscaleCanonicalRouteSnapshot, + aidenTailscaleHealthEndpoint, + classifyAidenTailscaleRoute, + planAidenTailscaleConnect, + planAidenTailscaleDisconnect, + type AidenTailscaleOwnership, + type AidenTailscaleStatus, +} from "./aiden-remote-tailscale-route.js"; + +const execFileAsync = promisify(execFile); +const TAILSCALE_CANDIDATES = [ + "/Applications/Tailscale.app/Contents/MacOS/Tailscale", + "/usr/local/bin/tailscale", + "/opt/homebrew/bin/tailscale", +] as const; +const MAX_STATUS_BYTES = 256 * 1_024; +const MAX_HEALTH_BYTES = 1_024; +const HEALTH_TIMEOUT_MS = 800; +const TAKEOVER_REVIEW_TTL_MS = 30_000; +const MAX_TAKEOVER_REVIEWS = 16; +const ROUTE_LOCK_ATTEMPTS = 20; +const ROUTE_LOCK_RETRY_MS = 50; +const ROUTE_LOCK_PORT = 49_191; +const POST_MUTATION_STATUS_ATTEMPTS = 3; +const POST_MUTATION_STATUS_RETRY_MS = 50; + +export interface AidenTailscaleCommandRunner { + run(args: readonly string[]): Promise; +} + +export interface AidenTailscaleConnectionStatus { + installed: boolean; + dnsName?: string; + httpsAvailable?: boolean; + serveStatus?: AidenTailscaleStatus; + errorCode?: "not_installed" | "not_connected" | "https_unavailable" | "status_unavailable"; +} + +export type AidenTailscaleRouteState = + | "available" + | "owned" + | "other_aiden_live" + | "other_aiden_stale" + | "unrelated_conflict" + | "funnel_conflict" + | "reconciliation_required" + | "unavailable"; + +export interface AidenTailscalePendingRouteOutcome { + operation: "connect" | "takeover" | "disconnect"; + target: string; + previousTarget?: string; + beforeFingerprint: string; + preservedFingerprint: string; + normalizeListenerScaffolding: boolean; + createdAt: number; +} + +export interface AidenTailscaleOutcomeStore { + begin(outcome: AidenTailscalePendingRouteOutcome): Promise; + snapshot(): Promise; + commit(ownership: AidenTailscaleOwnership | undefined): Promise; + clear(): Promise; +} + +export interface AidenTailscaleRouteAssessment { + state: AidenTailscaleRouteState; + errorCode?: AidenTailscaleConnectionStatus["errorCode"]; +} + +export interface AidenTailscaleTakeoverReview { + token: string; + expiresAt: number; +} + +interface AidenTailscaleTakeoverRecord { + target: string; + incumbentTarget: string; + serveFingerprint: string; + monotonicExpiresAt: number; +} + +export interface AidenRemoteTailscaleControllerOptions { + now?: () => number; + monotonicNow?: () => number; + probeHealth?: (target: string) => Promise; + randomToken?: () => string; + withRouteLock?: (action: () => Promise) => Promise; + outcomeStore?: AidenTailscaleOutcomeStore; +} + +export interface AidenTailscaleRouteLockOptions { + port?: number; + attempts?: number; + retryMs?: number; +} + +interface AidenTailscaleNodeStatus { + dnsName?: string; + httpsAvailable: boolean; +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function parseBoundedJson(serialized: string, label: string): unknown { + if (Buffer.byteLength(serialized, "utf8") > MAX_STATUS_BYTES) { + throw new Error(`${label}_too_large`); + } + return parseAidenRemoteJson(serialized, label); +} + +function normalizeDnsName(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const dnsName = value.trim().replace(/\.$/u, "").toLowerCase(); + return /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(dnsName) + ? dnsName + : undefined; +} + +function parseNodeStatus(serialized: string): AidenTailscaleNodeStatus { + const root = record(parseBoundedJson(serialized, "Tailscale status")); + const self = record(root?.Self); + const dnsName = normalizeDnsName(self?.DNSName); + const certDomains = Array.isArray(root?.CertDomains) + ? root.CertDomains.map(normalizeDnsName).filter((value): value is string => value !== undefined) + : []; + return { + ...(dnsName ? { dnsName } : {}), + // An exact certificate-domain match proves that the tailnet owner has + // already enabled HTTPS. Aiden never follows or accepts Tailscale's + // interactive authorization flow on the owner's behalf. + httpsAvailable: dnsName !== undefined && certDomains.includes(dnsName), + }; +} + +function parseServeStatus(serialized: string): AidenTailscaleStatus { + const value = parseBoundedJson(serialized, "Tailscale Serve status"); + if (!record(value)) throw new Error("tailscale_status_invalid"); + return value as AidenTailscaleStatus; +} + +function stableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stableValue); + if (value === null || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, stableValue(entry)]), + ); +} + +function serveFingerprint(status: AidenTailscaleStatus): string { + return createHash("sha256") + .update(JSON.stringify(stableValue(status)), "utf8") + .digest("hex"); +} + +function preservedServeFingerprint(status: AidenTailscaleStatus): string { + return serveFingerprint(aidenTailscaleCanonicalRouteSnapshot(status).preservedStatus); +} + +function preservedServeFingerprintForListenerTransition(status: AidenTailscaleStatus): string { + const preservedStatus = structuredClone( + aidenTailscaleCanonicalRouteSnapshot(status).preservedStatus, + ); + const tcp443 = preservedStatus.TCP?.["443"]; + if ( + preservedStatus.Web === undefined + && tcp443?.HTTPS === true + && Object.keys(tcp443).length === 1 + && Object.keys(preservedStatus.TCP ?? {}).length === 1 + && Object.keys(preservedStatus).every((key) => key === "TCP") + ) { + delete preservedStatus.TCP; + } + return serveFingerprint(preservedStatus); +} + +function permitsHttpsListenerScaffoldingChange( + before: AidenTailscaleStatus, + nextTarget: string | undefined, +): boolean { + const snapshot = aidenTailscaleCanonicalRouteSnapshot(before); + if (nextTarget !== undefined) { + return snapshot.target === undefined + && before.TCP?.["443"] === undefined + && Object.keys(snapshot.preservedStatus).length === 0; + } + return snapshot.target !== undefined + && preservedServeFingerprintForListenerTransition(before) + === serveFingerprint({}); +} + +async function probeLoopbackHealth(target: string): Promise { + const endpoint = aidenTailscaleHealthEndpoint(target); + return new Promise((resolve) => { + let settled = false; + let request: ReturnType | undefined; + const deadline = setTimeout(() => { + request?.destroy(); + finish(false); + }, HEALTH_TIMEOUT_MS); + const finish = (healthy: boolean) => { + if (settled) return; + settled = true; + clearTimeout(deadline); + resolve(healthy); + }; + request = httpGet(endpoint, { + headers: { accept: "application/json", connection: "close" }, + timeout: HEALTH_TIMEOUT_MS, + }, (response) => { + const chunks: Buffer[] = []; + let bytes = 0; + response.on("data", (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.length; + if (bytes > MAX_HEALTH_BYTES) { + request?.destroy(); + finish(false); + return; + } + chunks.push(buffer); + }); + response.on("end", () => { + if (response.statusCode !== 200) return finish(false); + try { + const parsed = parseAidenRemoteJson(Buffer.concat(chunks).toString("utf8"), "Aiden health response"); + const health = record(parsed); + finish( + health?.ok === true + && health.protocolVersion === 1 + && Object.keys(health).length === 2, + ); + } catch { + finish(false); + } + }); + response.once("aborted", () => finish(false)); + response.once("error", () => finish(false)); + }); + request.once("timeout", () => { + request.destroy(); + finish(false); + }); + request.once("error", () => finish(false)); + }); +} + +export async function withAidenTailscaleRouteLock( + action: () => Promise, + options: AidenTailscaleRouteLockOptions = {}, +): Promise { + const port = options.port ?? ROUTE_LOCK_PORT; + const attempts = options.attempts ?? ROUTE_LOCK_ATTEMPTS; + const retryMs = options.retryMs ?? ROUTE_LOCK_RETRY_MS; + let lockSocket: ReturnType | undefined; + for (let attempt = 0; attempt < attempts; attempt += 1) { + // The UDP namespace cannot collide with Aiden's retained TCP listener + // ports, while the kernel still releases this process-owned mutex on exit. + const candidate = createSocket({ type: "udp4", reuseAddr: false }); + candidate.unref(); + const acquired = await new Promise((resolve, reject) => { + candidate.once("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE") resolve(false); + else reject(error); + }); + candidate.bind({ address: "127.0.0.1", port, exclusive: true }, () => resolve(true)); + }); + if (acquired) { + lockSocket = candidate; + break; + } + if (attempt + 1 < attempts) { + await new Promise((resolve) => setTimeout(resolve, retryMs)); + } + } + if (!lockSocket) throw new Error("tailscale_route_busy"); + try { + return await action(); + } finally { + await new Promise((resolve) => lockSocket?.close(() => resolve())); + } +} + +export async function resolveTailscaleBinary(): Promise { + for (const candidate of TAILSCALE_CANDIDATES) { + try { + await fs.access(candidate, fs.constants.X_OK); + return candidate; + } catch { + // Continue through fixed, trusted installation locations. + } + } + return null; +} + +export async function createSystemTailscaleCommandRunner(): Promise { + const binary = await resolveTailscaleBinary(); + if (!binary) return null; + return { + run: async (args) => { + const { stdout } = await execFileAsync(binary, [...args], { + encoding: "utf8", + maxBuffer: MAX_STATUS_BYTES, + timeout: 15_000, + windowsHide: true, + }); + return stdout; + }, + }; +} + +export class AidenRemoteTailscaleController { + private readonly now: () => number; + private readonly monotonicNow: () => number; + private readonly probeHealth: (target: string) => Promise; + private readonly randomToken: () => string; + private readonly withRouteLock: (action: () => Promise) => Promise; + private readonly outcomeStore?: AidenTailscaleOutcomeStore; + private readonly takeoverReviews = new Map(); + + constructor( + private readonly runner: AidenTailscaleCommandRunner | null, + options: AidenRemoteTailscaleControllerOptions = {}, + ) { + this.now = options.now ?? Date.now; + this.monotonicNow = options.monotonicNow ?? (() => performance.now()); + this.probeHealth = options.probeHealth ?? probeLoopbackHealth; + this.randomToken = options.randomToken ?? (() => randomBytes(24).toString("base64url")); + this.withRouteLock = options.withRouteLock + ?? ((action) => withAidenTailscaleRouteLock(action)); + this.outcomeStore = options.outcomeStore; + } + + private async serveStatus(): Promise { + if (!this.runner) throw new Error("tailscale_not_installed"); + return parseServeStatus(await this.runner.run(["serve", "status", "--json"])); + } + + private async nodeStatus(): Promise { + if (!this.runner) throw new Error("tailscale_not_installed"); + return parseNodeStatus(await this.runner.run(["status", "--json"])); + } + + async status(): Promise { + if (!this.runner) return { installed: false, errorCode: "not_installed" }; + try { + const [nodeStatus, serveStatus] = await Promise.all([ + this.nodeStatus(), + this.serveStatus(), + ]); + const errorCode = !nodeStatus.dnsName + ? "not_connected" as const + : !nodeStatus.httpsAvailable + ? "https_unavailable" as const + : undefined; + return { + installed: true, + ...(nodeStatus.dnsName ? { dnsName: nodeStatus.dnsName } : {}), + httpsAvailable: nodeStatus.httpsAvailable, + ...(errorCode ? { errorCode } : {}), + serveStatus, + }; + } catch { + return { installed: true, errorCode: "status_unavailable" }; + } + } + + private async nodeAndServeStatus(): Promise<{ + nodeStatus: AidenTailscaleNodeStatus; + serveStatus: AidenTailscaleStatus; + }> { + if (!this.runner) throw new Error("tailscale_not_installed"); + const [nodeStatus, serveStatus] = await Promise.all([ + this.nodeStatus(), + this.serveStatus(), + ]); + if (!nodeStatus.dnsName) throw new Error("tailscale_not_connected"); + if (!nodeStatus.httpsAvailable) throw new Error("tailscale_https_unavailable"); + return { nodeStatus, serveStatus }; + } + + private async assessmentFromStatus( + serveStatus: AidenTailscaleStatus, + target: string, + ownership?: AidenTailscaleOwnership, + ): Promise { + const classification = classifyAidenTailscaleRoute(serveStatus, target, ownership); + if (classification.kind === "other_aiden") { + return { + state: await this.probeHealth(classification.target) + ? "other_aiden_live" + : "other_aiden_stale", + }; + } + return { state: classification.kind }; + } + + async assessRoute( + target: string, + ownership?: AidenTailscaleOwnership, + ): Promise { + if (!this.runner) return { state: "unavailable", errorCode: "not_installed" }; + try { + const [nodeStatus, serveStatus] = await Promise.all([ + this.nodeStatus(), + this.serveStatus(), + ]); + const classification = classifyAidenTailscaleRoute(serveStatus, target, ownership); + const errorCode = !nodeStatus.dnsName + ? "not_connected" as const + : !nodeStatus.httpsAvailable + ? "https_unavailable" as const + : undefined; + if (classification.kind === "owned") { + return { state: "owned", ...(errorCode ? { errorCode } : {}) }; + } + if (errorCode) return { state: "unavailable", errorCode }; + return this.assessmentFromStatus(serveStatus, target, ownership); + } catch (error) { + const code = error instanceof Error ? error.message : ""; + return { + state: "unavailable", + errorCode: code === "tailscale_not_connected" + ? "not_connected" + : code === "tailscale_https_unavailable" + ? "https_unavailable" + : "status_unavailable", + }; + } + } + + private async setExactRoute(target: string): Promise { + if (!this.runner) throw new Error("tailscale_not_installed"); + await this.runner.run([ + "serve", "--yes", "--bg", "--https=443", + `--set-path=/api/aiden/v1`, target, + ]); + } + + private async clearExactRoute(): Promise { + if (!this.runner) throw new Error("tailscale_not_installed"); + await this.runner.run([ + "serve", "--https=443", `--set-path=/api/aiden/v1`, "off", + ]); + } + + private async conditionalRecoverRoute( + expectedCurrentFingerprint: string, + expectedCurrentTarget: string | undefined, + previousTarget: string | undefined, + ): Promise { + const current = await this.serveStatusAfterMutation("tailscale_route_recovery_failed"); + const currentSnapshot = aidenTailscaleCanonicalRouteSnapshot(current); + if ( + serveFingerprint(current) !== expectedCurrentFingerprint + || currentSnapshot.funnel + || currentSnapshot.target !== expectedCurrentTarget + ) { + throw new Error("tailscale_route_recovery_failed"); + } + const permitsScaffoldingChange = permitsHttpsListenerScaffoldingChange( + current, + previousTarget, + ); + const preserved = permitsScaffoldingChange + ? preservedServeFingerprintForListenerTransition(current) + : preservedServeFingerprint(current); + try { + if (previousTarget) await this.setExactRoute(previousTarget); + else await this.clearExactRoute(); + } catch { + // Reconcile below: the CLI may fail after the daemon applied the route. + } + const recovered = await this.serveStatusAfterMutation("tailscale_route_recovery_failed"); + const recoveredSnapshot = aidenTailscaleCanonicalRouteSnapshot(recovered); + if ( + recoveredSnapshot.funnel + || recoveredSnapshot.target !== previousTarget + || (previousTarget !== undefined && recovered.TCP?.["443"]?.HTTPS !== true) + || (permitsScaffoldingChange + ? preservedServeFingerprintForListenerTransition(recovered) + : preservedServeFingerprint(recovered)) !== preserved + ) { + throw new Error("tailscale_route_recovery_failed"); + } + } + + private async serveStatusAfterMutation(failureCode: string): Promise { + for (let attempt = 0; attempt < POST_MUTATION_STATUS_ATTEMPTS; attempt += 1) { + try { + return await this.serveStatus(); + } catch { + if (attempt + 1 < POST_MUTATION_STATUS_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, POST_MUTATION_STATUS_RETRY_MS)); + } + } + } + throw new Error(failureCode); + } + + private async applyExactRouteMutation( + before: AidenTailscaleStatus, + nextTarget: string | undefined, + operation: AidenTailscalePendingRouteOutcome["operation"], + ): Promise { + const beforeSnapshot = aidenTailscaleCanonicalRouteSnapshot(before); + const permitsScaffoldingChange = permitsHttpsListenerScaffoldingChange(before, nextTarget); + const preserved = permitsScaffoldingChange + ? preservedServeFingerprintForListenerTransition(before) + : preservedServeFingerprint(before); + await this.outcomeStore?.begin({ + operation, + target: nextTarget ?? beforeSnapshot.target ?? "", + ...(beforeSnapshot.target ? { previousTarget: beforeSnapshot.target } : {}), + beforeFingerprint: serveFingerprint(before), + preservedFingerprint: preserved, + normalizeListenerScaffolding: permitsScaffoldingChange, + createdAt: this.now(), + }); + let commandFailed = false; + try { + if (nextTarget) await this.setExactRoute(nextTarget); + else await this.clearExactRoute(); + } catch { + commandFailed = true; + } + const observed = await this.serveStatusAfterMutation("tailscale_route_outcome_unknown"); + const observedSnapshot = aidenTailscaleCanonicalRouteSnapshot(observed); + const observedFingerprint = serveFingerprint(observed); + if ( + observedSnapshot.funnel + || observedSnapshot.target !== nextTarget + || (nextTarget !== undefined && observed.TCP?.["443"]?.HTTPS !== true) + || (permitsScaffoldingChange + ? preservedServeFingerprintForListenerTransition(observed) + : preservedServeFingerprint(observed)) !== preserved + ) { + if (!observedSnapshot.funnel && observedSnapshot.target === nextTarget) { + try { + await this.conditionalRecoverRoute( + observedFingerprint, + nextTarget, + beforeSnapshot.target, + ); + } catch { + throw new Error("tailscale_route_recovery_failed"); + } + await this.outcomeStore?.clear(); + } else if (observedFingerprint === serveFingerprint(before)) { + await this.outcomeStore?.clear(); + } + throw new Error(commandFailed + ? "tailscale_route_outcome_unknown" + : "tailscale_route_verification_failed"); + } + return observedFingerprint; + } + + async connect( + target: string, + ownership?: AidenTailscaleOwnership, + persistOwnership?: (ownership: AidenTailscaleOwnership) => Promise, + ): Promise { + return this.withRouteLock(async () => { + const { nodeStatus, serveStatus } = await this.nodeAndServeStatus(); + const plan = planAidenTailscaleConnect( + serveStatus, + target, + ownership, + nodeStatus.httpsAvailable, + ); + let committedRouteFingerprint = serveFingerprint(serveStatus); + if (plan.action === "set") { + committedRouteFingerprint = await this.applyExactRouteMutation(serveStatus, target, "connect"); + } + if (persistOwnership) { + try { + await persistOwnership(plan.ownership); + } catch { + if (plan.action === "set") { + try { + await this.conditionalRecoverRoute( + committedRouteFingerprint, + target, + undefined, + ); + } catch { + throw new Error("tailscale_route_recovery_failed"); + } + await this.outcomeStore?.clear(); + } + throw new Error("tailscale_ownership_commit_failed"); + } + } + return plan.ownership; + }); + } + + async reviewTakeover( + target: string, + ownership?: AidenTailscaleOwnership, + ): Promise { + const { serveStatus } = await this.nodeAndServeStatus(); + const classification = classifyAidenTailscaleRoute(serveStatus, target, ownership); + if (classification.kind !== "other_aiden") { + throw new Error("tailscale_takeover_unavailable"); + } + if (await this.probeHealth(classification.target)) { + throw new Error("tailscale_route_live"); + } + const now = this.now(); + const monotonicNow = this.monotonicNow(); + for (const [token, review] of this.takeoverReviews) { + if (review.monotonicExpiresAt <= monotonicNow) this.takeoverReviews.delete(token); + } + while (this.takeoverReviews.size >= MAX_TAKEOVER_REVIEWS) { + const oldest = this.takeoverReviews.keys().next().value as string | undefined; + if (!oldest) break; + this.takeoverReviews.delete(oldest); + } + let token = this.randomToken(); + for (let attempt = 0; this.takeoverReviews.has(token) && attempt < 4; attempt += 1) { + token = this.randomToken(); + } + if (!/^[A-Za-z0-9_-]{32}$/u.test(token) || this.takeoverReviews.has(token)) { + throw new Error("tailscale_takeover_token_failed"); + } + const expiresAt = now + TAKEOVER_REVIEW_TTL_MS; + this.takeoverReviews.set(token, { + target, + incumbentTarget: classification.target, + serveFingerprint: serveFingerprint(serveStatus), + monotonicExpiresAt: monotonicNow + TAKEOVER_REVIEW_TTL_MS, + }); + return { token, expiresAt }; + } + + async takeOver( + target: string, + token: string, + persistOwnership: (ownership: AidenTailscaleOwnership) => Promise, + ): Promise { + return this.withRouteLock(async () => { + const review = this.takeoverReviews.get(token); + this.takeoverReviews.delete(token); + if (!review || review.target !== target || review.monotonicExpiresAt <= this.monotonicNow()) { + throw new Error("tailscale_takeover_expired"); + } + const { serveStatus } = await this.nodeAndServeStatus(); + if (serveFingerprint(serveStatus) !== review.serveFingerprint) { + throw new Error("tailscale_takeover_changed"); + } + const classification = classifyAidenTailscaleRoute(serveStatus, target); + if ( + classification.kind !== "other_aiden" + || classification.target !== review.incumbentTarget + ) { + throw new Error("tailscale_takeover_changed"); + } + if (await this.probeHealth(classification.target)) { + throw new Error("tailscale_route_live"); + } + const immediateStatus = await this.serveStatus(); + if (serveFingerprint(immediateStatus) !== review.serveFingerprint) { + throw new Error("tailscale_takeover_changed"); + } + const immediateClassification = classifyAidenTailscaleRoute(immediateStatus, target); + if ( + immediateClassification.kind !== "other_aiden" + || immediateClassification.target !== review.incumbentTarget + ) { + throw new Error("tailscale_takeover_changed"); + } + const nextOwnership = { path: "/api/aiden/v1", target } as const; + const committedRouteFingerprint = await this.applyExactRouteMutation( + immediateStatus, + target, + "takeover", + ); + try { + await persistOwnership(nextOwnership); + } catch { + try { + await this.conditionalRecoverRoute( + committedRouteFingerprint, + target, + review.incumbentTarget, + ); + } catch { + throw new Error("tailscale_route_recovery_failed"); + } + await this.outcomeStore?.clear(); + throw new Error("tailscale_ownership_commit_failed"); + } + return nextOwnership; + }); + } + + async disconnect( + target: string, + ownership?: AidenTailscaleOwnership, + clearOwnership?: () => Promise, + ): Promise { + await this.withRouteLock(async () => { + if (!this.runner) throw new Error("tailscale_not_installed"); + const serveStatus = await this.serveStatus(); + const plan = planAidenTailscaleDisconnect(serveStatus, target, ownership); + let committedRouteFingerprint = serveFingerprint(serveStatus); + if (plan.action === "clear") { + committedRouteFingerprint = await this.applyExactRouteMutation(serveStatus, undefined, "disconnect"); + } + if (clearOwnership) { + try { + await clearOwnership(); + } catch { + if (plan.action === "clear") { + try { + await this.conditionalRecoverRoute( + committedRouteFingerprint, + undefined, + target, + ); + } catch { + throw new Error("tailscale_route_recovery_failed"); + } + await this.outcomeStore?.clear(); + } + throw new Error("tailscale_ownership_commit_failed"); + } + } + }); + } + + async reconcilePendingOutcome(): Promise<"connected" | "disconnected" | "not_applied"> { + return this.withRouteLock(async () => { + const pending = await this.outcomeStore?.snapshot(); + if (!pending || !this.outcomeStore) throw new Error("tailscale_reconciliation_unavailable"); + const status = await this.serveStatus(); + if (serveFingerprint(status) === pending.beforeFingerprint) { + const confirmed = await this.serveStatus(); + if (serveFingerprint(confirmed) !== pending.beforeFingerprint) { + throw new Error("tailscale_reconciliation_conflict"); + } + await this.outcomeStore.clear(); + return "not_applied"; + } + const snapshot = aidenTailscaleCanonicalRouteSnapshot(status); + const expectedTarget = pending.operation === "disconnect" ? undefined : pending.target; + const preserved = pending.normalizeListenerScaffolding + ? preservedServeFingerprintForListenerTransition(status) + : preservedServeFingerprint(status); + if ( + snapshot.funnel + || snapshot.target !== expectedTarget + || preserved !== pending.preservedFingerprint + || (expectedTarget !== undefined && status.TCP?.["443"]?.HTTPS !== true) + ) { + throw new Error("tailscale_reconciliation_conflict"); + } + if (expectedTarget !== undefined) { + if (!await this.probeHealth(expectedTarget)) { + throw new Error("tailscale_reconciliation_unhealthy"); + } + const confirmed = await this.serveStatus(); + if (serveFingerprint(confirmed) !== serveFingerprint(status)) { + throw new Error("tailscale_reconciliation_conflict"); + } + await this.outcomeStore.commit({ path: "/api/aiden/v1", target: expectedTarget }); + return "connected"; + } + const confirmed = await this.serveStatus(); + if (serveFingerprint(confirmed) !== serveFingerprint(status)) { + throw new Error("tailscale_reconciliation_conflict"); + } + await this.outcomeStore.commit(undefined); + return "disconnected"; + }); + } +} diff --git a/main/services/aiden-remote-tls-identity.test.ts b/main/services/aiden-remote-tls-identity.test.ts new file mode 100644 index 00000000..f18607d7 --- /dev/null +++ b/main/services/aiden-remote-tls-identity.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { X509Certificate } from "node:crypto"; +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"; + +async function temporaryDirectory(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), "aiden-remote-tls-")); +} + +test("TLS identity persists one P-256 server key and renews without changing its pin", async () => { + const directory = await temporaryDirectory(); + try { + const first = await loadOrCreateAidenRemoteTlsIdentity({ + directory, + hostnames: ["Aiden-Studio"], + }); + const restarted = await loadOrCreateAidenRemoteTlsIdentity({ directory }); + const renewed = await loadOrCreateAidenRemoteTlsIdentity({ + directory, + renewBeforeMs: 20 * 365 * 24 * 60 * 60 * 1_000, + }); + + assert.equal(restarted.serverSpkiSha256, first.serverSpkiSha256); + assert.equal(renewed.serverSpkiSha256, first.serverSpkiSha256); + assert.equal(renewed.privateKey, first.privateKey); + const certificate = new X509Certificate(first.certificate); + assert.equal(certificate.checkHost("aiden-studio"), "aiden-studio"); + assert.equal(certificate.checkHost("aiden-studio.local"), "aiden-studio.local"); + assert.equal(certificate.checkIP("127.0.0.1"), "127.0.0.1"); + assert.equal(certificate.publicKey.asymmetricKeyType, "ec"); + assert.match(first.serverSpkiSha256, /^sha256\/[A-Za-z0-9+/]{43}=$/u); + } finally { + await fs.rm(directory, { force: true, recursive: true }); + } +}); + +test("TLS identity directory and every persisted identity file are owner-only", async () => { + const directory = await temporaryDirectory(); + try { + await loadOrCreateAidenRemoteTlsIdentity({ directory }); + assert.equal((await fs.stat(directory)).mode & 0o777, 0o700); + for (const name of [ + "ca-key.pem", + "ca-certificate.pem", + "server-key.pem", + "server-certificate.pem", + ]) { + assert.equal((await fs.stat(path.join(directory, name))).mode & 0o777, 0o600); + } + } finally { + await fs.rm(directory, { force: true, recursive: true }); + } +}); + +test("TLS identity fails closed instead of silently rotating an incomplete identity", async () => { + const directory = await temporaryDirectory(); + try { + await fs.writeFile(path.join(directory, "server-key.pem"), "partial", { mode: 0o600 }); + await assert.rejects( + loadOrCreateAidenRemoteTlsIdentity({ directory }), + /server identity is incomplete/u, + ); + } finally { + await fs.rm(directory, { force: true, recursive: true }); + } +}); diff --git a/main/services/aiden-remote-tls-identity.ts b/main/services/aiden-remote-tls-identity.ts new file mode 100644 index 00000000..06bcd687 --- /dev/null +++ b/main/services/aiden-remote-tls-identity.ts @@ -0,0 +1,319 @@ +import { execFile } from "node:child_process"; +import { + createHash, + createPublicKey, + randomBytes, + X509Certificate, +} from "node:crypto"; +import * as fs from "node:fs/promises"; +import path from "node:path"; +import tls from "node:tls"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const DEFAULT_OPENSSL_PATH = "/usr/bin/openssl"; +const RENEW_BEFORE_MS = 7 * 24 * 60 * 60 * 1_000; +const CERTIFICATE_DAYS = 90; +const CA_CERTIFICATE_DAYS = 3_650; + +export interface AidenRemoteTlsIdentity { + privateKey: string; + certificate: string; + certificateChain: string; + caCertificate: string; + serverSpkiSha256: string; + validUntil: string; +} + +export interface AidenRemoteTlsIdentityOptions { + directory: string; + hostnames?: string[]; + now?: Date; + renewBeforeMs?: number; + opensslPath?: string; +} + +interface IdentityPaths { + caKey: string; + caCertificate: string; + serverKey: string; + serverCertificate: string; +} + +function identityPaths(directory: string): IdentityPaths { + return { + caKey: path.join(directory, "ca-key.pem"), + caCertificate: path.join(directory, "ca-certificate.pem"), + serverKey: path.join(directory, "server-key.pem"), + serverCertificate: path.join(directory, "server-certificate.pem"), + }; +} + +async function exists(target: string): Promise { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + +function validatedHostnames(values: string[]): string[] { + const result = new Set(["localhost"]); + for (const raw of values) { + const value = raw.trim().replace(/\.$/u, "").toLowerCase(); + if ( + value.length === 0 || + value.length > 253 || + !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(value) || + value.split(".").some((label) => label.length === 0 || label.length > 63) + ) { + continue; + } + result.add(value); + if (!value.includes(".")) result.add(`${value}.local`); + } + return [...result]; +} + +function spkiDigest(value: string | Buffer): string { + const certificate = new X509Certificate(value); + const spki = certificate.publicKey.export({ type: "spki", format: "der" }); + return `sha256/${createHash("sha256").update(spki).digest("base64")}`; +} + +export async function fetchTlsServerSpkiSha256( + hostname: string, + port = 443, +): Promise { + if ( + !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(hostname) || + !Number.isInteger(port) || + port < 1 || + port > 65_535 + ) { + throw new Error("Aiden Remote TLS endpoint is invalid."); + } + return new Promise((resolve, reject) => { + const socket = tls.connect({ + host: hostname, + port, + servername: hostname, + rejectUnauthorized: true, + }); + const timeout = setTimeout(() => { + socket.destroy(new Error("Aiden Remote TLS endpoint timed out.")); + }, 5_000); + socket.once("secureConnect", () => { + try { + const certificate = socket.getPeerCertificate(true); + if (!certificate.raw?.length) throw new Error("Aiden Remote TLS endpoint has no certificate."); + resolve(spkiDigest(certificate.raw)); + } catch (error) { + reject(error); + } finally { + clearTimeout(timeout); + socket.end(); + } + }); + socket.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + }); +} + +function keySpkiDigest(privateKey: string): string { + const spki = createPublicKey(privateKey).export({ type: "spki", format: "der" }); + return `sha256/${createHash("sha256").update(spki).digest("base64")}`; +} + +function certificateNeedsRenewal( + certificatePem: string, + now: Date, + renewBeforeMs: number, +): boolean { + const certificate = new X509Certificate(certificatePem); + const validFrom = new Date(certificate.validFrom).getTime(); + const validTo = new Date(certificate.validTo).getTime(); + const current = now.getTime(); + return current < validFrom || validTo - current <= renewBeforeMs; +} + +async function secureFile(target: string): Promise { + await fs.chmod(target, 0o600); +} + +async function assertPairCompleteness( + first: string, + second: string, + label: string, +): Promise { + const [hasFirst, hasSecond] = await Promise.all([exists(first), exists(second)]); + if (hasFirst !== hasSecond) { + throw new Error(`Aiden Remote ${label} identity is incomplete; refusing to rotate it implicitly.`); + } + return hasFirst; +} + +async function createCertificateAuthority( + paths: IdentityPaths, + opensslPath: string, +): Promise { + await execFileAsync(opensslPath, [ + "ecparam", "-name", "prime256v1", "-genkey", "-noout", "-out", paths.caKey, + ]); + await secureFile(paths.caKey); + await issueCertificateAuthorityCertificate(paths, opensslPath); +} + +async function issueCertificateAuthorityCertificate( + paths: IdentityPaths, + opensslPath: string, +): Promise { + await execFileAsync(opensslPath, [ + "req", "-x509", "-new", "-key", paths.caKey, "-sha256", + "-days", String(CA_CERTIFICATE_DAYS), + "-subj", "/CN=Aiden Agent Local CA", + "-addext", "basicConstraints=critical,CA:TRUE,pathlen:0", + "-addext", "keyUsage=critical,keyCertSign,cRLSign", + "-out", paths.caCertificate, + ]); + await secureFile(paths.caCertificate); +} + +async function createServerKey(paths: IdentityPaths, opensslPath: string): Promise { + await execFileAsync(opensslPath, [ + "ecparam", "-name", "prime256v1", "-genkey", "-noout", "-out", paths.serverKey, + ]); + await secureFile(paths.serverKey); +} + +async function issueServerCertificate( + directory: string, + paths: IdentityPaths, + opensslPath: string, + hostnames: string[], +): Promise { + const suffix = randomBytes(12).toString("hex"); + const requestPath = path.join(directory, `.server-${suffix}.csr.pem`); + const certificatePath = path.join(directory, `.server-${suffix}.certificate.pem`); + const extensionsPath = path.join(directory, `.server-${suffix}.extensions.cnf`); + const subjectAltName = [ + "IP:127.0.0.1", + "IP:::1", + ...hostnames.map((hostname) => `DNS:${hostname}`), + ].join(","); + try { + await fs.writeFile( + extensionsPath, + [ + "[server_ext]", + `subjectAltName=${subjectAltName}`, + "basicConstraints=critical,CA:FALSE", + "keyUsage=critical,digitalSignature", + "extendedKeyUsage=serverAuth", + "", + ].join("\n"), + { encoding: "utf8", mode: 0o600 }, + ); + await execFileAsync(opensslPath, [ + "req", "-new", "-key", paths.serverKey, + "-subj", "/CN=Aiden Agent Local", + "-out", requestPath, + ]); + await execFileAsync(opensslPath, [ + "x509", "-req", "-in", requestPath, + "-CA", paths.caCertificate, "-CAkey", paths.caKey, + "-set_serial", `0x${randomBytes(16).toString("hex")}`, + "-days", String(CERTIFICATE_DAYS), "-sha256", + "-extfile", extensionsPath, "-extensions", "server_ext", + "-out", certificatePath, + ]); + await secureFile(certificatePath); + await fs.rename(certificatePath, paths.serverCertificate); + await secureFile(paths.serverCertificate); + } finally { + await Promise.all([ + fs.rm(requestPath, { force: true }), + fs.rm(certificatePath, { force: true }), + fs.rm(extensionsPath, { force: true }), + ]); + } +} + +export async function loadOrCreateAidenRemoteTlsIdentity( + options: AidenRemoteTlsIdentityOptions, +): Promise { + const opensslPath = options.opensslPath ?? DEFAULT_OPENSSL_PATH; + const now = options.now ?? new Date(); + const renewBeforeMs = options.renewBeforeMs ?? RENEW_BEFORE_MS; + if (!path.isAbsolute(options.directory)) { + throw new Error("Aiden Remote TLS identity directory must be absolute."); + } + await fs.mkdir(options.directory, { recursive: true, mode: 0o700 }); + await fs.chmod(options.directory, 0o700); + const paths = identityPaths(options.directory); + + const hasCa = await assertPairCompleteness( + paths.caKey, + paths.caCertificate, + "certificate authority", + ); + if (!hasCa) await createCertificateAuthority(paths, opensslPath); + + const hasServer = await assertPairCompleteness( + paths.serverKey, + paths.serverCertificate, + "server", + ); + if (!hasServer) await createServerKey(paths, opensslPath); + + let [caKey, caCertificate, serverKey, existingServerCertificate] = await Promise.all([ + fs.readFile(paths.caKey, "utf8"), + fs.readFile(paths.caCertificate, "utf8"), + fs.readFile(paths.serverKey, "utf8"), + hasServer ? fs.readFile(paths.serverCertificate, "utf8") : Promise.resolve(null), + ]); + const ca = new X509Certificate(caCertificate); + if (!ca.ca || keySpkiDigest(caKey) !== spkiDigest(caCertificate)) { + throw new Error("Aiden Remote certificate authority identity is invalid."); + } + if (certificateNeedsRenewal(caCertificate, now, renewBeforeMs)) { + await issueCertificateAuthorityCertificate(paths, opensslPath); + caCertificate = await fs.readFile(paths.caCertificate, "utf8"); + caKey = await fs.readFile(paths.caKey, "utf8"); + } + + let certificate = existingServerCertificate; + if (certificate) { + try { + if (keySpkiDigest(serverKey) !== spkiDigest(certificate)) certificate = null; + } catch { + certificate = null; + } + } + if ( + !certificate || + certificateNeedsRenewal(certificate, now, renewBeforeMs) + ) { + await issueServerCertificate( + options.directory, + paths, + opensslPath, + validatedHostnames(options.hostnames ?? []), + ); + certificate = await fs.readFile(paths.serverCertificate, "utf8"); + } + + await Promise.all(Object.values(paths).map(secureFile)); + const parsedCertificate = new X509Certificate(certificate); + return { + privateKey: serverKey, + certificate, + certificateChain: `${certificate.trim()}\n${caCertificate.trim()}\n`, + caCertificate, + serverSpkiSha256: spkiDigest(certificate), + validUntil: new Date(parsedCertificate.validTo).toISOString(), + }; +} diff --git a/main/services/aiden-remote-workspace-browser.test.ts b/main/services/aiden-remote-workspace-browser.test.ts new file mode 100644 index 00000000..da74f5a3 --- /dev/null +++ b/main/services/aiden-remote-workspace-browser.test.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { AidenRemoteWorkspaceBrowserService } from "./aiden-remote-workspace-browser.js"; +import type { AidenRemoteApprovedRoot } from "./aiden-remote-state.js"; + +async function fixture() { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-browser-test-")); + const rootDirectory = path.join(temporary, "Approved"); + const outsideDirectory = path.join(temporary, "Outside"); + await fs.mkdir(rootDirectory); + await fs.mkdir(outsideDirectory); + const rootPath = await fs.realpath(rootDirectory); + const outside = await fs.realpath(outsideDirectory); + await fs.mkdir(path.join(rootPath, "Alpha")); + await fs.mkdir(path.join(rootPath, "Beta")); + await fs.mkdir(path.join(rootPath, ".Hidden")); + await fs.mkdir(path.join(rootPath, "Library")); + await fs.writeFile(path.join(rootPath, "file.txt"), "not a directory"); + await fs.symlink(outside, path.join(rootPath, "Escape")); + const identity = await fs.stat(rootPath, { bigint: true }); + const root: AidenRemoteApprovedRoot = { + id: "root-1", + label: "Projects", + folderPath: rootPath, + device: identity.dev.toString(), + inode: identity.ino.toString(), + policyRevision: "remote-browser-v1:no-hidden-system", + createdAt: 1, + }; + let approvedRoots = [root]; + let now = 1_000; + const service = new AidenRemoteWorkspaceBrowserService({ + instanceId: "instance-1", + state: { + snapshot: async () => ({ approvedRoots }) as never, + }, + now: () => now, + }); + return { + service, + root, + rootPath, + setRoots: (value: AidenRemoteApprovedRoot[]) => { approvedRoots = value; }, + advance: (milliseconds: number) => { now += milliseconds; }, + cleanup: () => fs.rm(temporary, { recursive: true, force: true }), + }; +} + +test("approved-root browsing exposes only opaque directory entries and consumes selections once", async () => { + const app = await fixture(); + try { + const roots = await app.service.listRoots("device-1"); + assert.equal(roots.roots.length, 1); + assert.match(roots.roots[0]!.location, /^loc_[A-Za-z0-9_-]{43}$/u); + assert.equal(JSON.stringify(roots).includes(app.rootPath), false); + + const page = await app.service.listChildren( + "device-1", + roots.roots[0]!.location, + ); + assert.deepEqual(page.entries.map((entry) => entry.name), ["Alpha", "Beta"]); + assert.equal(page.entries.every((entry) => /^loc_[A-Za-z0-9_-]{43}$/u.test(entry.location)), true); + assert.equal(JSON.stringify(page).includes(app.rootPath), false); + + const selection = await app.service.createSelection( + "device-1", + page.entries[0]!.location, + ); + const claims = await app.service.consumeSelection("device-1", selection.selection); + assert.equal(claims.canonicalPath, path.join(app.rootPath, "Alpha")); + await assert.rejects( + app.service.consumeSelection("device-1", selection.selection), + (error: unknown) => (error as { code?: string }).code === "handle_invalid", + ); + } finally { + await app.cleanup(); + } +}); + +test("browser handles are device-bound, expire, and fail closed after root-policy removal", async () => { + const app = await fixture(); + try { + const location = (await app.service.listRoots("device-1")).roots[0]!.location; + await assert.rejects( + app.service.listChildren("device-2", location), + (error: unknown) => (error as { code?: string }).code === "handle_wrong_device", + ); + + const selection = await app.service.createSelection("device-1", location); + const consumed = await app.service.consumeSelection("device-1", selection.selection); + + app.setRoots([]); + await assert.rejects( + app.service.listChildren("device-1", location), + (error: unknown) => (error as { code?: string }).code === "root_policy_changed", + ); + await assert.rejects( + app.service.revalidateConsumedSelection("device-1", consumed), + (error: unknown) => (error as { code?: string }).code === "root_policy_changed", + ); + + app.setRoots([app.root]); + const expiring = (await app.service.listRoots("device-1")).roots[0]!.location; + app.advance(10 * 60_000 + 1); + await assert.rejects( + app.service.listChildren("device-1", expiring), + (error: unknown) => (error as { code?: string }).code === "handle_expired", + ); + } finally { + await app.cleanup(); + } +}); + +test("directory pagination binds cursors to the exact location and snapshot", async () => { + const app = await fixture(); + try { + await Promise.all( + Array.from({ length: 205 }, (_, index) => + fs.mkdir(path.join(app.rootPath, `Paged-${String(index).padStart(3, "0")}`)), + ), + ); + const rootLocation = (await app.service.listRoots("device-1")).roots[0]!.location; + const first = await app.service.listChildren("device-1", rootLocation); + assert.equal(first.entries.length, 200); + assert.match(first.nextCursor ?? "", /^cur_[A-Za-z0-9_-]{43}$/u); + const second = await app.service.listChildren( + "device-1", + rootLocation, + first.nextCursor, + ); + assert.equal(second.entries.length, 7); + + await fs.mkdir(path.join(app.rootPath, "Snapshot-Changed")); + await assert.rejects( + app.service.listChildren("device-1", rootLocation, first.nextCursor), + (error: unknown) => (error as { code?: string }).code === "filesystem_identity_changed", + ); + } finally { + await app.cleanup(); + } +}); diff --git a/main/services/aiden-remote-workspace-browser.ts b/main/services/aiden-remote-workspace-browser.ts new file mode 100644 index 00000000..e0e4c46a --- /dev/null +++ b/main/services/aiden-remote-workspace-browser.ts @@ -0,0 +1,440 @@ +import { createHash } from "node:crypto"; +import * as fs from "node:fs/promises"; +import path from "node:path"; +import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; +import { + AidenOpaqueHandleError, + AidenOpaqueHandleStore, + inspectAidenFilesystemIdentity, + type AidenOpaqueHandleClaims, +} from "./aiden-remote-opaque-handles.js"; +import type { + AidenRemoteApprovedRoot, + AidenRemoteStateRegistry, +} from "./aiden-remote-state.js"; + +const LOCATION_TTL_MS = 10 * 60_000; +const CURSOR_TTL_MS = 2 * 60_000; +const SELECTION_TTL_MS = 2 * 60_000; +const PAGE_SIZE = 200; +const MAX_DIRECTORY_ENTRIES = 5_000; +const MAX_DEPTH = 20; +const SYSTEM_DIRECTORY_NAMES = new Set([ + ".DocumentRevisions-V100", + ".Spotlight-V100", + ".TemporaryItems", + ".Trashes", + "Library", + "System", +]); + +export interface AidenRemoteBrowserRootProjection { + id: string; + label: string; + location: string; + policyRevision: string; +} + +export interface AidenRemoteBrowserPage { + rootId: string; + label: string; + breadcrumbs: Array<{ label: string; location: string }>; + entries: Array<{ id: string; name: string; location: string }>; + nextCursor?: string; +} + +export interface AidenRemoteWorkspaceSelection { + selection: string; + displayName: string; + expiresAt: string; +} + +interface DirectoryEntryIdentity { + name: string; + canonicalPath: string; + filesystemDevice: string; + filesystemInode: string; +} + +function tokenDigest(token: string): string { + return createHash("sha256").update(token).digest("base64url"); +} + +function opaqueEntryId(rootId: string, entry: DirectoryEntryIdentity): string { + return createHash("sha256") + .update(`${rootId}\0${entry.filesystemDevice}\0${entry.filesystemInode}`) + .digest("base64url"); +} + +function compareNames(left: DirectoryEntryIdentity, right: DirectoryEntryIdentity): number { + return left.name < right.name ? -1 : left.name > right.name ? 1 : 0; +} + +function safeLabel(value: string, fallback: string): string { + const candidate = [...(value || fallback)] + .map((character) => { + const code = character.codePointAt(0) ?? 0; + return code <= 0x1f || code === 0x7f ? "�" : character; + }) + .join(""); + return [...candidate].slice(0, 255).join("") || fallback; +} + +function mapHandleError(error: unknown): never { + if (!(error instanceof AidenOpaqueHandleError)) throw error; + const status = error.code === "handle_wrong_device" + ? 403 + : error.code === "handle_expired" + ? 410 + : error.code === "handle_capacity" + ? 429 + : error.code === "root_policy_changed" || + error.code === "filesystem_identity_changed" || + error.code === "path_outside_root" + ? 409 + : 400; + throw new AidenRemoteServiceError( + error.code, + error.code === "handle_expired" + ? "This folder-browser link expired. Refresh the approved roots and try again." + : error.code === "handle_capacity" + ? "Aiden's folder-browser handle capacity is temporarily full." + : "This folder-browser link is no longer valid.", + status, + error.code === "handle_capacity", + ); +} + +export class AidenRemoteWorkspaceBrowserService { + constructor( + private readonly options: { + instanceId: string; + state: Pick; + handles?: AidenOpaqueHandleStore; + now?: () => number; + }, + ) {} + + private get handles(): AidenOpaqueHandleStore { + this.handleStore ??= this.options.handles ?? new AidenOpaqueHandleStore({ now: this.now }); + return this.handleStore; + } + + private handleStore: AidenOpaqueHandleStore | undefined; + private readonly now = (): number => this.options.now?.() ?? Date.now(); + + private async approvedRoot(rootId: string): Promise { + const root = (await this.options.state.snapshot()).approvedRoots.find( + (candidate) => candidate.id === rootId, + ); + if (!root) { + throw new AidenRemoteServiceError( + "root_policy_changed", + "This approved folder is no longer available for remote browsing.", + 409, + ); + } + return root; + } + + private async claimsForPath( + deviceId: string, + root: AidenRemoteApprovedRoot, + candidatePath: string, + extra: Pick< + AidenOpaqueHandleClaims, + "expiresAt" | "depth" | "snapshotId" | "cursorOffset" | "parentHandleDigest" + >, + ): Promise { + try { + const rootIdentity = await fs.stat(root.folderPath, { bigint: true }); + const canonicalRoot = await fs.realpath(root.folderPath); + if ( + !rootIdentity.isDirectory() || + canonicalRoot !== root.folderPath || + rootIdentity.dev.toString() !== root.device || + rootIdentity.ino.toString() !== root.inode + ) { + throw new AidenOpaqueHandleError("filesystem_identity_changed"); + } + const identity = await inspectAidenFilesystemIdentity(root.folderPath, candidatePath); + if (identity.kind !== "directory") { + throw new AidenOpaqueHandleError("handle_invalid"); + } + return { + instanceId: this.options.instanceId, + deviceId, + rootId: root.id, + policyRevision: root.policyRevision, + ...identity, + ...extra, + }; + } catch (error) { + if (error instanceof AidenRemoteServiceError) throw error; + if (error instanceof AidenOpaqueHandleError) mapHandleError(error); + throw new AidenRemoteServiceError( + "workspace_unavailable", + "This approved folder is not currently available on the Mac.", + 409, + ); + } + } + + private async resolveLocation( + deviceId: string, + token: string, + ): Promise<{ claims: AidenOpaqueHandleClaims; root: AidenRemoteApprovedRoot }> { + try { + const stored = this.handles.claimsFor(token, "loc"); + const root = await this.approvedRoot(stored.rootId); + const current = await this.claimsForPath(deviceId, root, stored.canonicalPath, { + expiresAt: stored.expiresAt, + depth: stored.depth, + snapshotId: stored.snapshotId, + cursorOffset: stored.cursorOffset, + parentHandleDigest: stored.parentHandleDigest, + }); + return { claims: this.handles.resolve(token, "loc", current), root }; + } catch (error) { + mapHandleError(error); + } + } + + async listRoots(deviceId: string): Promise<{ roots: AidenRemoteBrowserRootProjection[] }> { + const state = await this.options.state.snapshot(); + const roots: AidenRemoteBrowserRootProjection[] = []; + for (const root of state.approvedRoots) { + const claims = await this.claimsForPath(deviceId, root, root.folderPath, { + expiresAt: this.now() + LOCATION_TTL_MS, + depth: 0, + snapshotId: undefined, + cursorOffset: undefined, + parentHandleDigest: undefined, + }); + roots.push({ + id: root.id, + label: safeLabel(root.label, "Approved folder"), + location: this.handles.issue("loc", claims), + policyRevision: root.policyRevision, + }); + } + return { roots }; + } + + private async directoryEntries( + root: AidenRemoteApprovedRoot, + directoryPath: string, + ): Promise { + const entries = await fs.readdir(directoryPath, { withFileTypes: true }); + if (entries.length > MAX_DIRECTORY_ENTRIES) { + throw new AidenRemoteServiceError( + "workspace_unavailable", + "This folder contains too many entries to browse remotely.", + 409, + false, + { limit: MAX_DIRECTORY_ENTRIES }, + ); + } + const directories: DirectoryEntryIdentity[] = []; + for (const entry of entries) { + if ( + !entry.isDirectory() || + entry.name.startsWith(".") || + SYSTEM_DIRECTORY_NAMES.has(entry.name) + ) { + continue; + } + try { + const identity = await inspectAidenFilesystemIdentity( + root.folderPath, + path.join(directoryPath, entry.name), + ); + if (identity.kind !== "directory") continue; + directories.push({ + name: safeLabel(entry.name, "Folder"), + canonicalPath: identity.canonicalPath, + filesystemDevice: identity.filesystemDevice, + filesystemInode: identity.filesystemInode, + }); + } catch { + // A raced, unreadable, or redirected entry is omitted. The parent + // location remains usable and no local path crosses the wire. + } + } + return directories.sort(compareNames); + } + + private async breadcrumbs( + deviceId: string, + root: AidenRemoteApprovedRoot, + currentPath: string, + ): Promise> { + const relative = path.relative(root.folderPath, currentPath); + const segments = relative ? relative.split(path.sep) : []; + const result: Array<{ label: string; location: string }> = []; + let candidate = root.folderPath; + for (let index = 0; index <= segments.length; index += 1) { + if (index > 0) candidate = path.join(candidate, segments[index - 1]!); + const claims = await this.claimsForPath(deviceId, root, candidate, { + expiresAt: this.now() + LOCATION_TTL_MS, + depth: index, + snapshotId: undefined, + cursorOffset: undefined, + parentHandleDigest: undefined, + }); + result.push({ + label: index === 0 ? safeLabel(root.label, "Approved folder") : safeLabel(segments[index - 1]!, "Folder"), + location: this.handles.issue("loc", claims), + }); + } + return result; + } + + async listChildren( + deviceId: string, + location: string, + cursor?: string, + ): Promise { + const { claims, root } = await this.resolveLocation(deviceId, location); + const depth = claims.depth ?? 0; + if (depth > MAX_DEPTH) { + throw new AidenRemoteServiceError( + "handle_invalid", + "This folder is deeper than Aiden's remote browser limit.", + 400, + false, + { limit: MAX_DEPTH }, + ); + } + const entries = await this.directoryEntries(root, claims.canonicalPath); + const snapshotId = createHash("sha256") + .update(entries.map((entry) => `${entry.name}\0${entry.filesystemDevice}\0${entry.filesystemInode}`).join("\n")) + .digest("base64url"); + let offset = 0; + if (cursor) { + try { + const storedCursor = this.handles.claimsFor(cursor, "cur"); + const currentCursor = await this.claimsForPath(deviceId, root, claims.canonicalPath, { + expiresAt: storedCursor.expiresAt, + depth, + snapshotId, + cursorOffset: storedCursor.cursorOffset, + parentHandleDigest: tokenDigest(location), + }); + const resolved = this.handles.resolve(cursor, "cur", currentCursor); + offset = resolved.cursorOffset ?? -1; + if (!Number.isSafeInteger(offset) || offset < 0 || offset >= entries.length) { + throw new AidenOpaqueHandleError("handle_invalid"); + } + } catch (error) { + mapHandleError(error); + } + } + const pageEntries = entries.slice(offset, offset + PAGE_SIZE); + const projectedEntries = await Promise.all( + pageEntries.map(async (entry) => ({ + id: opaqueEntryId(root.id, entry), + name: entry.name, + location: this.handles.issue( + "loc", + await this.claimsForPath(deviceId, root, entry.canonicalPath, { + expiresAt: this.now() + LOCATION_TTL_MS, + depth: depth + 1, + snapshotId: undefined, + cursorOffset: undefined, + parentHandleDigest: undefined, + }), + ), + })), + ); + const nextOffset = offset + pageEntries.length; + const nextCursor = nextOffset < entries.length + ? this.handles.issue("cur", { + ...claims, + expiresAt: this.now() + CURSOR_TTL_MS, + snapshotId, + cursorOffset: nextOffset, + parentHandleDigest: tokenDigest(location), + }) + : undefined; + return { + rootId: root.id, + label: safeLabel(path.basename(claims.canonicalPath), root.label), + breadcrumbs: await this.breadcrumbs(deviceId, root, claims.canonicalPath), + entries: projectedEntries, + ...(nextCursor ? { nextCursor } : {}), + }; + } + + async createSelection( + deviceId: string, + location: string, + ): Promise { + const { claims, root } = await this.resolveLocation(deviceId, location); + const expiresAt = this.now() + SELECTION_TTL_MS; + const fresh = await this.claimsForPath(deviceId, root, claims.canonicalPath, { + expiresAt, + depth: claims.depth, + snapshotId: undefined, + cursorOffset: undefined, + parentHandleDigest: undefined, + }); + return { + selection: this.handles.issue("sel", fresh), + displayName: safeLabel(path.basename(fresh.canonicalPath), root.label), + expiresAt: new Date(expiresAt).toISOString(), + }; + } + + async consumeSelection(deviceId: string, selection: string): Promise { + try { + const stored = this.handles.claimsFor(selection, "sel"); + const root = await this.approvedRoot(stored.rootId); + const current = await this.claimsForPath(deviceId, root, stored.canonicalPath, { + expiresAt: stored.expiresAt, + depth: stored.depth, + snapshotId: stored.snapshotId, + cursorOffset: stored.cursorOffset, + parentHandleDigest: stored.parentHandleDigest, + }); + return this.handles.consumeSelection( + selection, + current, + (claims) => ({ ...claims }), + this.now(), + ); + } catch (error) { + mapHandleError(error); + } + } + + async revalidateConsumedSelection( + deviceId: string, + claims: AidenOpaqueHandleClaims, + ): Promise { + if (this.now() >= claims.expiresAt) { + mapHandleError(new AidenOpaqueHandleError("handle_expired")); + } + if (claims.deviceId !== deviceId || claims.instanceId !== this.options.instanceId) { + mapHandleError(new AidenOpaqueHandleError("handle_wrong_device")); + } + const root = await this.approvedRoot(claims.rootId); + const current = await this.claimsForPath(deviceId, root, claims.canonicalPath, { + expiresAt: claims.expiresAt, + depth: claims.depth, + snapshotId: claims.snapshotId, + cursorOffset: claims.cursorOffset, + parentHandleDigest: claims.parentHandleDigest, + }); + if ( + current.policyRevision !== claims.policyRevision || + current.canonicalRootPath !== claims.canonicalRootPath || + current.canonicalPath !== claims.canonicalPath || + current.filesystemDevice !== claims.filesystemDevice || + current.filesystemInode !== claims.filesystemInode + ) { + mapHandleError(new AidenOpaqueHandleError("filesystem_identity_changed")); + } + return current; + } +} diff --git a/main/services/aiden-remote-workspace-http.test.ts b/main/services/aiden-remote-workspace-http.test.ts new file mode 100644 index 00000000..c9fb5582 --- /dev/null +++ b/main/services/aiden-remote-workspace-http.test.ts @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import { createServer } from "node:http"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createAidenRemoteRequestHandler } from "./aiden-remote-router.js"; +import { AidenRemoteWorkspaceBrowserService } from "./aiden-remote-workspace-browser.js"; +import { AidenRemoteWorkspaceService } from "./aiden-remote-workspaces.js"; +import { createWorkspaceApplicationService } from "./workspace-application-service.js"; +import { WorkspaceMutationGate } from "./workspace-mutation-gate.js"; +import { WorkspaceOperationRegistry } from "./workspace-operation-registry.js"; +import type { Workspace } from "./types.js"; + +test("HTTP client completes approved-folder selection and revision-checked workspace CRUD", async () => { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-workspace-http-")); + const approvedDirectory = path.join(temporary, "Approved"); + await fs.mkdir(approvedDirectory); + const approvedPath = await fs.realpath(approvedDirectory); + await fs.mkdir(path.join(approvedPath, "Selected")); + const selectedMarker = path.join(approvedPath, "Selected", "kept-after-unregister.txt"); + await fs.writeFile(selectedMarker, "workspace files stay on disk"); + const approvedIdentity = await fs.stat(approvedPath, { bigint: true }); + let sequence = 0; + let workspaces: Workspace[] = [{ + id: "default", + name: "Default", + permission: "ask", + createdAt: 1, + updatedAt: 1, + }]; + const application = createWorkspaceApplicationService({ + configStore: { + listWorkspaces: async () => structuredClone(workspaces), + getWorkspace: async (id) => structuredClone(workspaces.find((workspace) => workspace.id === id)), + saveWorkspace: async (workspace) => { + const saved = { ...workspace, updatedAt: workspace.updatedAt + 1 }; + const index = workspaces.findIndex((candidate) => candidate.id === saved.id); + if (index >= 0) workspaces[index] = saved; + else workspaces.push(saved); + return structuredClone(saved); + }, + removeWorkspace: async (id) => { + workspaces = workspaces.filter((workspace) => workspace.id !== id); + }, + }, + llmClient: { cancelWorkspaceAndSettle: async () => undefined }, + scheduleService: { + cancelWorkspace: async () => undefined, + resumeWorkspace: async () => undefined, + }, + terminalService: { closeForWorkspace: () => undefined }, + workspaceMutationGate: new WorkspaceMutationGate(), + workspaceOperationRegistry: new WorkspaceOperationRegistry(), + createScratchWorkspaceDirectory: async () => { + const scratch = path.join(temporary, `Scratch-${sequence}`); + await fs.mkdir(scratch); + return { name: "Scratch", folderPath: scratch }; + }, + realpath: (value) => fs.realpath(value), + stat: (value) => fs.stat(value), + removeEmptyDirectory: (value) => fs.rmdir(value), + createId: () => `workspace-${++sequence}`, + now: () => 10_000 + sequence, + logError: () => undefined, + }); + const state = { + snapshot: async () => ({ + approvedRoots: [{ + id: "root-1", + label: "Projects", + folderPath: approvedPath, + device: approvedIdentity.dev.toString(), + inode: approvedIdentity.ino.toString(), + policyRevision: "remote-browser-v1:no-hidden-system", + createdAt: 1, + }], + }) as never, + }; + const browser = new AidenRemoteWorkspaceBrowserService({ + instanceId: "instance-1", + state, + }); + const workspaceService = new AidenRemoteWorkspaceService({ + application, + browser, + }); + const handler = createAidenRemoteRequestHandler({ + instanceId: "instance-1", + displayName: () => "Studio Mac", + appVersion: "test", + devices: { + acquireDeviceAuthorization: () => () => undefined, + authenticate: async () => ({ + id: "device-1", + revoked: false, + capabilities: new Set([ + "workspace:read" as const, + "workspace:browse" as const, + "workspace:manage" as const, + ]), + }), + }, + pairing: { exchange: async () => { throw new Error("not used"); } }, + workspaces: workspaceService, + workspaceBrowser: browser, + connectionMode: () => "lan", + now: Date.now, + log: () => undefined, + }); + const server = createServer(handler); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("HTTP test server did not bind"); + const base = `http://127.0.0.1:${address.port}/api/aiden/v1`; + const headers = { + authorization: `Bearer ${"a".repeat(43)}`, + "aiden-protocol-version": "1", + }; + try { + const roots = await (await fetch(`${base}/workspace-browser/roots`, { headers })).json(); + const page = await ( + await fetch( + `${base}/workspace-browser/children?location=${roots.roots[0].location}`, + { headers }, + ) + ).json(); + assert.deepEqual(page.entries.map((entry: { name: string }) => entry.name), ["Selected"]); + const selectionResponse = await fetch(`${base}/workspace-browser/selections`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ location: page.entries[0].location }), + }); + assert.equal(selectionResponse.status, 201); + const selection = await selectionResponse.json(); + + const createResponse = await fetch(`${base}/workspaces`, { + method: "POST", + headers: { + ...headers, + "content-type": "application/json", + "idempotency-key": "workspace-http-create-0001", + }, + body: JSON.stringify({ + mode: "selected-folder", + selection: selection.selection, + name: "Remote Selected", + }), + }); + assert.equal(createResponse.status, 201); + const created = await createResponse.json(); + assert.equal(created.name, "Remote Selected"); + assert.equal(created.hasFolder, true); + assert.equal(JSON.stringify(created).includes(approvedPath), false); + + const patchResponse = await fetch(`${base}/workspaces/${created.id}`, { + method: "PATCH", + headers: { + ...headers, + "content-type": "application/json", + "if-match": created.revision, + }, + body: JSON.stringify({ confirmedForeground: true, permission: "full" }), + }); + assert.equal(patchResponse.status, 200); + const updated = await patchResponse.json(); + assert.equal(updated.permission, "full"); + + const deleteResponse = await fetch(`${base}/workspaces/${created.id}`, { + method: "DELETE", + headers: { ...headers, "if-match": updated.revision }, + }); + assert.equal(deleteResponse.status, 204); + const listed = await (await fetch(`${base}/workspaces`, { headers })).json(); + assert.deepEqual(listed.workspaces.map((workspace: { id: string }) => workspace.id), ["default"]); + assert.equal(await fs.readFile(selectedMarker, "utf8"), "workspace files stay on disk"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + await fs.rm(temporary, { recursive: true, force: true }); + } +}); diff --git a/main/services/aiden-remote-workspace-owners.ts b/main/services/aiden-remote-workspace-owners.ts new file mode 100644 index 00000000..abb1db4b --- /dev/null +++ b/main/services/aiden-remote-workspace-owners.ts @@ -0,0 +1,42 @@ +import type { WorkspaceOperationDocumentOwner } from "./workspace-operation-registry.js"; + +class AidenRemoteWorkspaceOperationOwner implements WorkspaceOperationDocumentOwner { + private invalidated = false; + private readonly listeners = new Set<() => void>(); + + isDestroyed(): boolean { + return this.invalidated; + } + + onInvalidated(listener: () => void): () => void { + if (this.invalidated) listener(); + else this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + revoke(): void { + if (this.invalidated) return; + this.invalidated = true; + for (const listener of this.listeners) listener(); + this.listeners.clear(); + } +} + +/** Remote ownership survives a socket disconnect and ends only on revocation. */ +export class AidenRemoteWorkspaceOwnerRegistry { + private readonly owners = new Map(); + + owner(deviceId: string): WorkspaceOperationDocumentOwner { + let owner = this.owners.get(deviceId); + if (!owner || owner.isDestroyed()) { + owner = new AidenRemoteWorkspaceOperationOwner(); + this.owners.set(deviceId, owner); + } + return owner; + } + + revokeDevice(deviceId: string): void { + this.owners.get(deviceId)?.revoke(); + this.owners.delete(deviceId); + } +} diff --git a/main/services/aiden-remote-workspaces.test.ts b/main/services/aiden-remote-workspaces.test.ts new file mode 100644 index 00000000..b312c478 --- /dev/null +++ b/main/services/aiden-remote-workspaces.test.ts @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Workspace } from "./types.js"; +import { + AidenRemoteWorkspaceService, + projectAidenRemoteWorkspace, +} from "./aiden-remote-workspaces.js"; +import { + AidenIdempotencyLedger, + type AidenIdempotencySnapshot, +} from "./aiden-remote-operation-contract.js"; + +function workspace(overrides: Partial = {}): Workspace { + return { + id: "workspace-1", + name: "Workspace", + permission: "ask", + createdAt: 1_000, + updatedAt: 2_000, + ...overrides, + }; +} + +function fixture( + initial: Workspace[] = [workspace()], + options: { + idempotency?: AidenIdempotencyLedger; + persistIdempotency?: (snapshot: AidenIdempotencySnapshot) => Promise; + } = {}, +) { + let workspaces = initial.map((value) => structuredClone(value)); + let createCalls = 0; + let notifications = 0; + const service = new AidenRemoteWorkspaceService({ + application: { + list: async () => structuredClone(workspaces), + get: async (id: string) => structuredClone(workspaces.find((value) => value.id === id) ?? null), + create: async (input: unknown) => { + createCalls += 1; + const value = workspace({ + id: `workspace-${createCalls + 1}`, + name: (input as { name: string }).name, + }); + workspaces.push(value); + return structuredClone(value); + }, + createScratch: async () => { + createCalls += 1; + const value = workspace({ id: `workspace-${createCalls + 1}`, name: "Scratch", folderPath: "/private/scratch" }); + workspaces.push(value); + return structuredClone(value); + }, + createFromFolder: async ( + folderPath: string, + name?: string, + options?: { assertCurrent?: (identity: { canonicalPath: string; filesystemDevice: string; filesystemInode: string }) => Promise | void }, + ) => { + await options?.assertCurrent?.({ + canonicalPath: folderPath, + filesystemDevice: "1", + filesystemInode: "2", + }); + createCalls += 1; + const value = workspace({ id: `workspace-${createCalls + 1}`, name: name ?? "Selected", folderPath }); + workspaces.push(value); + return structuredClone(value); + }, + update: async (id: string, patch: unknown) => { + const index = workspaces.findIndex((value) => value.id === id); + if (index < 0) throw new Error("missing"); + workspaces[index] = { ...workspaces[index]!, ...(patch as Partial), updatedAt: workspaces[index]!.updatedAt + 1 }; + return structuredClone(workspaces[index]!); + }, + remove: async (id: string) => { + const existing = workspaces.find((value) => value.id === id); + if (existing?.managedWorktree) throw new Error("Delete worktree through the managed worktree workflow."); + workspaces = workspaces.filter((value) => value.id !== id); + }, + }, + browser: { + consumeSelection: async () => ({ + instanceId: "instance-1", + deviceId: "device-1", + rootId: "root-1", + policyRevision: "policy-1", + canonicalRootPath: "/approved", + canonicalPath: "/approved/Selected", + filesystemDevice: "1", + filesystemInode: "2", + expiresAt: 60_000, + kind: "directory" as const, + }), + revalidateConsumedSelection: async (_deviceId, claims) => claims, + }, + ...options, + notifyChanged: () => { notifications += 1; }, + }); + return { + service, + createCalls: () => createCalls, + notifications: () => notifications, + }; +} + +test("workspace projections omit local paths and internal managed-worktree identity", () => { + const projected = projectAidenRemoteWorkspace(workspace({ + folderPath: "/private/repository/worktree", + managedWorktree: { + repositoryPath: "/private/repository", + worktreePath: "/private/repository/worktree", + branch: "feature/mobile", + worktreeGitDir: "/private/repository/.git/worktrees/mobile", + ownershipToken: "secret-token", + createdFromHead: "abc123", + }, + })); + const serialized = JSON.stringify(projected); + assert.equal(projected.hasFolder, true); + assert.equal(projected.isManagedWorktree, true); + assert.equal(projected.branchName, "feature/mobile"); + assert.equal(projected.repositoryName, "repository"); + assert.equal(serialized.includes("/private/"), false); + assert.equal(serialized.includes("secret-token"), false); + assert.match(projected.revision, /^rev_[A-Za-z0-9_-]{43}$/u); +}); + +test("workspace creates are exact, idempotent per device, and notify Electron once", async () => { + const app = fixture([]); + const key = "workspace-create-key-0001"; + const first = await app.service.create("device-1", key, { + mode: "folderless", + name: "Remote Project", + }); + const replay = await app.service.create("device-1", key, { + mode: "folderless", + name: "Remote Project", + }); + assert.deepEqual(replay, first); + assert.equal(app.createCalls(), 1); + assert.equal(app.notifications(), 1); + + await assert.rejects( + app.service.create("device-1", key, { mode: "scratch" }), + (error: unknown) => (error as { code?: string }).code === "idempotency_conflict", + ); + await app.service.create("device-2", key, { mode: "scratch" }); + assert.equal(app.createCalls(), 2); +}); + +test("workspace idempotency is persisted before mutation and replays after restart", async () => { + let persisted: AidenIdempotencySnapshot | undefined; + const states: string[] = []; + const first = fixture([], { + persistIdempotency: async (snapshot) => { + persisted = structuredClone(snapshot); + states.push(snapshot.entries[0]?.state ?? "missing"); + }, + }); + const key = "durable-create-key-0001"; + const created = await first.service.create("device-1", key, { + mode: "folderless", + name: "Durable", + }); + assert.deepEqual(states, ["in_flight", "fulfilled"]); + assert.equal(first.createCalls(), 1); + assert.ok(persisted); + + const restarted = fixture([], { + idempotency: new AidenIdempotencyLedger(persisted), + }); + const replay = await restarted.service.create("device-1", key, { + mode: "folderless", + name: "Durable", + }); + assert.deepEqual(replay, created); + assert.equal(restarted.createCalls(), 0); + assert.equal(restarted.notifications(), 0); +}); + +test("selected-folder creation consumes only a browser-issued path and never accepts raw paths", async () => { + const app = fixture([]); + const created = await app.service.create("device-1", "selected-folder-key-01", { + mode: "selected-folder", + selection: `sel_${"a".repeat(43)}`, + name: "Chosen", + }); + assert.equal(created.name, "Chosen"); + assert.equal(created.hasFolder, true); + assert.equal(JSON.stringify(created).includes("/approved/"), false); + await assert.rejects( + app.service.create("device-1", "selected-folder-key-02", { + mode: "selected-folder", + selection: `sel_${"b".repeat(43)}`, + folderPath: "/private/escape", + }), + (error: unknown) => (error as { code?: string }).code === "invalid_request", + ); +}); + +test("workspace update and removal require the exact current revision", async () => { + const app = fixture(); + const current = await app.service.get("workspace-1"); + await assert.rejects( + app.service.update("workspace-1", "rev_stale", { + confirmedForeground: true, + name: "Changed", + }), + (error: unknown) => + (error as { code?: string }).code === "revision_conflict" && + (error as { details?: { currentRevision?: string } }).details?.currentRevision === current.revision, + ); + const updated = await app.service.update("workspace-1", current.revision, { + confirmedForeground: true, + permission: "full", + }); + assert.equal(updated.permission, "full"); + assert.notEqual(updated.revision, current.revision); + await app.service.remove("workspace-1", updated.revision); + assert.equal(app.notifications(), 2); + await assert.rejects( + app.service.get("workspace-1"), + (error: unknown) => (error as { code?: string }).code === "not_found", + ); +}); + +test("managed worktrees cannot be unregistered through generic workspace CRUD", async () => { + const app = fixture([workspace({ + managedWorktree: { + repositoryPath: "/repo", + worktreePath: "/repo/worktree", + branch: "feature", + createdFromHead: "abc123", + }, + })]); + const current = await app.service.get("workspace-1"); + await assert.rejects( + app.service.remove("workspace-1", current.revision), + (error: unknown) => (error as { code?: string }).code === "workspace_unavailable", + ); +}); diff --git a/main/services/aiden-remote-workspaces.ts b/main/services/aiden-remote-workspaces.ts new file mode 100644 index 00000000..07375872 --- /dev/null +++ b/main/services/aiden-remote-workspaces.ts @@ -0,0 +1,403 @@ +import { createHash } from "node:crypto"; +import { AidenRemoteServiceError } from "./aiden-remote-errors.js"; +import { + AidenIdempotencyLedger, + type AidenIdempotencySnapshot, + AidenOperationContractError, + assertRevision, +} from "./aiden-remote-operation-contract.js"; +import type { AidenRemoteWorkspaceBrowserService } from "./aiden-remote-workspace-browser.js"; +import type { createWorkspaceApplicationService } from "./workspace-application-service.js"; +import type { Workspace, WorkspacePermission } from "./types.js"; + +const WORKSPACE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/u; +const IDEMPOTENCY_KEY_PATTERN = /^[\x21-\x7e]{16,128}$/u; +const PERMISSIONS: readonly WorkspacePermission[] = ["full", "ask", "none"]; + +export interface AidenRemoteWorkspaceProjection { + id: string; + name: string; + permission: WorkspacePermission; + hasFolder: boolean; + isManagedWorktree: boolean; + branchName?: string; + repositoryName?: string; + createdAt: string; + updatedAt: string; + revision: string; +} + +type WorkspaceApplicationService = ReturnType; + +function ownRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function exactKeys( + record: Record, + required: readonly string[], + optional: readonly string[] = [], +): boolean { + const allowed = new Set([...required, ...optional]); + return ( + required.every((key) => Object.prototype.hasOwnProperty.call(record, key)) && + Object.keys(record).every((key) => allowed.has(key)) + ); +} + +function boundedString(value: unknown, maximum: number): value is string { + return typeof value === "string" && value.length > 0 && [...value].length <= maximum; +} + +function safeWorkspaceId(value: string): string { + if (!WORKSPACE_ID_PATTERN.test(value)) { + throw new AidenRemoteServiceError( + "invalid_request", + "The workspace identifier is invalid.", + 400, + ); + } + return value; +} + +export function workspaceRevision(workspace: Workspace): string { + const value = JSON.stringify({ + id: workspace.id, + name: workspace.name, + permission: workspace.permission, + hasFolder: Boolean(workspace.folderPath), + managedBranch: workspace.managedWorktree?.branch ?? null, + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + }); + return `rev_${createHash("sha256").update(value).digest("base64url")}`; +} + +function displayNameFromPath(value: string | undefined): string | undefined { + if (!value) return undefined; + const components = value.split(/[\\/]/u).filter(Boolean); + return components[components.length - 1]?.slice(0, 120); +} + +export function projectAidenRemoteWorkspace( + workspace: Workspace, +): AidenRemoteWorkspaceProjection { + return { + id: workspace.id, + name: workspace.name, + permission: workspace.permission, + hasFolder: Boolean(workspace.folderPath), + isManagedWorktree: Boolean(workspace.managedWorktree), + ...(workspace.managedWorktree?.branch + ? { branchName: workspace.managedWorktree.branch.slice(0, 255) } + : {}), + ...(displayNameFromPath(workspace.managedWorktree?.repositoryPath) + ? { repositoryName: displayNameFromPath(workspace.managedWorktree?.repositoryPath)! } + : {}), + createdAt: new Date(workspace.createdAt).toISOString(), + updatedAt: new Date(workspace.updatedAt).toISOString(), + revision: workspaceRevision(workspace), + }; +} + +function parseCreate(value: unknown): + | { mode: "folderless"; name: string } + | { mode: "scratch" } + | { mode: "selected-folder"; selection: string; name?: string } { + const record = ownRecord(value); + if (!record || typeof record.mode !== "string") { + throw new AidenRemoteServiceError("invalid_request", "The workspace creation request is invalid.", 400); + } + if (record.mode === "folderless") { + if (!exactKeys(record, ["mode", "name"]) || !boundedString(record.name, 120)) { + throw new AidenRemoteServiceError("invalid_request", "A folderless workspace requires a valid name.", 400); + } + return { mode: "folderless", name: record.name.trim() }; + } + if (record.mode === "scratch") { + if (!exactKeys(record, ["mode"])) { + throw new AidenRemoteServiceError("invalid_request", "The scratch workspace request is invalid.", 400); + } + return { mode: "scratch" }; + } + if (record.mode === "selected-folder") { + if ( + !exactKeys(record, ["mode", "selection"], ["name"]) || + typeof record.selection !== "string" || + !/^sel_[A-Za-z0-9_-]{43}$/u.test(record.selection) || + (record.name !== undefined && !boundedString(record.name, 120)) + ) { + throw new AidenRemoteServiceError("invalid_request", "The selected-folder workspace request is invalid.", 400); + } + return { + mode: "selected-folder", + selection: record.selection, + ...(typeof record.name === "string" ? { name: record.name.trim() } : {}), + }; + } + throw new AidenRemoteServiceError("invalid_request", "The workspace creation mode is invalid.", 400); +} + +function parsePatch(value: unknown): { + name?: string; + permission?: WorkspacePermission; +} { + const record = ownRecord(value); + if ( + !record || + !exactKeys(record, ["confirmedForeground"], ["name", "permission"]) || + record.confirmedForeground !== true || + (record.name === undefined && record.permission === undefined) || + (record.name !== undefined && !boundedString(record.name, 120)) || + (record.permission !== undefined && !PERMISSIONS.includes(record.permission as WorkspacePermission)) + ) { + throw new AidenRemoteServiceError( + "permission_confirmation_required", + "Workspace changes require an explicit foreground confirmation.", + 409, + ); + } + return { + ...(typeof record.name === "string" ? { name: record.name.trim() } : {}), + ...(record.permission !== undefined + ? { permission: record.permission as WorkspacePermission } + : {}), + }; +} + +function mapOperationError(error: unknown, currentRevision?: string): never { + if (!(error instanceof AidenOperationContractError)) throw error; + const status = error.code === "revision_conflict" + ? 409 + : error.code === "idempotency_capacity" + ? 429 + : 409; + throw new AidenRemoteServiceError( + error.code, + error.code === "revision_conflict" + ? "The workspace changed. Refresh it before trying again." + : "This workspace request cannot be safely repeated.", + status, + error.code === "idempotency_capacity", + currentRevision ? { currentRevision } : undefined, + ); +} + +function requireWorkspaceRevision(expected: string, workspace: Workspace): void { + const currentRevision = workspaceRevision(workspace); + try { + assertRevision(expected, currentRevision); + } catch (error) { + mapOperationError(error, currentRevision); + } +} + +export class AidenRemoteWorkspaceService { + private readonly idempotency: AidenIdempotencyLedger; + + constructor( + private readonly options: { + application: Pick< + WorkspaceApplicationService, + "list" | "get" | "create" | "createScratch" | "createFromFolder" | "update" | "remove" + >; + browser: Pick< + AidenRemoteWorkspaceBrowserService, + "consumeSelection" | "revalidateConsumedSelection" + >; + idempotency?: AidenIdempotencyLedger; + persistIdempotency?: (snapshot: AidenIdempotencySnapshot) => Promise; + notifyChanged?: () => void; + }, + ) { + this.idempotency = options.idempotency ?? new AidenIdempotencyLedger(); + } + + private async executeIdempotent( + scope: { deviceId: string; route: string; resourceId: string; key: string }, + input: unknown, + action: () => Promise, + ): Promise { + if (!this.options.persistIdempotency) { + return this.idempotency.execute(scope, input, action); + } + let release!: () => void; + let reject!: (error: unknown) => void; + const durableAdmission = new Promise((resolve, rejectPromise) => { + release = resolve; + reject = rejectPromise; + }); + const pending = this.idempotency.execute(scope, input, async () => { + await durableAdmission; + return action(); + }); + try { + await this.options.persistIdempotency(this.idempotency.snapshot()); + release(); + } catch (error) { + reject(error); + await pending.catch(() => undefined); + throw new AidenRemoteServiceError( + "internal_error", + "Aiden could not durably prepare this workspace request.", + 500, + ); + } + let result: T; + let actionError: unknown; + let actionFailed = false; + try { + result = await pending; + } catch (error) { + actionFailed = true; + actionError = error; + } + try { + await this.options.persistIdempotency(this.idempotency.snapshot()); + } catch { + throw new AidenRemoteServiceError( + "idempotency_in_flight", + "The workspace change may have completed, but Aiden could not durably record its outcome.", + 409, + ); + } + if (actionFailed) throw actionError; + return result!; + } + + async list(): Promise<{ workspaces: AidenRemoteWorkspaceProjection[] }> { + return { + workspaces: (await this.options.application.list()).map(projectAidenRemoteWorkspace), + }; + } + + async get(workspaceId: string): Promise { + const workspace = await this.options.application.get(safeWorkspaceId(workspaceId)); + if (!workspace) { + throw new AidenRemoteServiceError("not_found", "This Aiden workspace no longer exists.", 404); + } + return projectAidenRemoteWorkspace(workspace); + } + + async create( + deviceId: string, + idempotencyKey: string, + input: unknown, + ): Promise { + if (!IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) { + throw new AidenRemoteServiceError("invalid_request", "Idempotency-Key is invalid.", 400); + } + const parsed = parseCreate(input); + try { + return await this.executeIdempotent( + { + deviceId, + route: "POST /workspaces", + resourceId: "workspace-registry", + key: idempotencyKey, + }, + parsed, + async () => { + let workspace: Workspace; + if (parsed.mode === "folderless") { + workspace = await this.options.application.create({ + name: parsed.name, + permission: "ask", + }); + } else if (parsed.mode === "scratch") { + workspace = await this.options.application.createScratch(); + } else { + const selection = await this.options.browser.consumeSelection( + deviceId, + parsed.selection, + ); + try { + workspace = await this.options.application.createFromFolder( + selection.canonicalPath, + parsed.name, + { + assertCurrent: async (identity) => { + const current = await this.options.browser.revalidateConsumedSelection( + deviceId, + selection, + ); + if ( + identity.canonicalPath !== current.canonicalPath || + identity.filesystemDevice !== current.filesystemDevice || + identity.filesystemInode !== current.filesystemInode + ) { + throw new AidenRemoteServiceError( + "filesystem_identity_changed", + "The selected folder changed before Aiden could register it.", + 409, + ); + } + }, + }, + ); + } catch (error) { + if (error instanceof Error && /already registered/u.test(error.message)) { + throw new AidenRemoteServiceError( + "already_exists", + "That folder is already registered as an Aiden workspace.", + 409, + ); + } + throw error; + } + } + this.options.notifyChanged?.(); + return projectAidenRemoteWorkspace(workspace); + }, + ); + } catch (error) { + if (error instanceof AidenRemoteServiceError) throw error; + mapOperationError(error); + } + } + + async update( + workspaceId: string, + expectedRevision: string, + input: unknown, + ): Promise { + const id = safeWorkspaceId(workspaceId); + const current = await this.options.application.get(id); + if (!current) { + throw new AidenRemoteServiceError("not_found", "This Aiden workspace no longer exists.", 404); + } + requireWorkspaceRevision(expectedRevision, current); + const patch = parsePatch(input); + const updated = await this.options.application.update(id, patch, { + assertCurrent: (workspace) => requireWorkspaceRevision(expectedRevision, workspace), + }); + this.options.notifyChanged?.(); + return projectAidenRemoteWorkspace(updated); + } + + async remove(workspaceId: string, expectedRevision: string): Promise { + const id = safeWorkspaceId(workspaceId); + const current = await this.options.application.get(id); + if (!current) { + throw new AidenRemoteServiceError("not_found", "This Aiden workspace no longer exists.", 404); + } + requireWorkspaceRevision(expectedRevision, current); + try { + await this.options.application.remove(id, { + assertCurrent: (workspace) => requireWorkspaceRevision(expectedRevision, workspace), + }); + } catch (error) { + if (error instanceof Error && /managed worktree|Delete worktree/u.test(error.message)) { + throw new AidenRemoteServiceError( + "workspace_unavailable", + "Managed worktrees must be removed through Aiden's worktree workflow.", + 409, + ); + } + throw error; + } + this.options.notifyChanged?.(); + } +} diff --git a/main/services/chat-application-service-main.ts b/main/services/chat-application-service-main.ts new file mode 100644 index 00000000..1fd759de --- /dev/null +++ b/main/services/chat-application-service-main.ts @@ -0,0 +1,22 @@ +import { logger } from "../platform.js"; +import { chatStore } from "./chat-store.js"; +import { configStore } from "./config-store.js"; +import { llmClient } from "./llm-client.js"; +import { piCompactionSessionStore } from "./pi-compaction-session-store.js"; +import { piRuntimeEffectStore } from "./pi-runtime-effect-store.js"; +import { subagentRunStore } from "./subagents/subagent-run-store.js"; +import { workspaceMutationGate } from "./workspace-mutation-gate.js"; +import { workspaceOperationRegistry } from "./workspace-operation-registry.js"; +import { createChatApplicationService } from "./chat-application-service.js"; + +export const chatApplicationService = createChatApplicationService({ + chatStore, + configStore, + llmClient, + workspaceMutationGate, + workspaceOperationRegistry, + subagentRunStore, + piRuntimeEffectStore, + piCompactionSessionStore, + logError: (area, message, error) => logger.error(area, message, error), +}); diff --git a/main/services/chat-application-service.test.ts b/main/services/chat-application-service.test.ts new file mode 100644 index 00000000..0ba6c6fc --- /dev/null +++ b/main/services/chat-application-service.test.ts @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Chat, Workspace } from "./types.js"; +import { + createChatApplicationService, + type ChatApplicationDependencies, + type ChatApplicationOwner, +} from "./chat-application-service.js"; +import { WorkspaceOperationRegistry } from "./workspace-operation-registry.js"; + +function chat(id = "chat-1", workspaceId = "workspace-1"): Chat { + return { + id, + title: "Chat", + workspaceId, + createdAt: 1, + updatedAt: 1, + messages: [], + }; +} + +function owner(): ChatApplicationOwner & { invalidate(): void } { + let destroyed = false; + const listeners = new Set<() => void>(); + return { + documentId: "renderer:document-1", + isDestroyed: () => destroyed, + onInvalidated: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + invalidate: () => { + destroyed = true; + for (const listener of [...listeners]) listener(); + }, + }; +} + +function fixture(overrides: Partial = {}) { + const workspace: Workspace = { + id: "workspace-1", + name: "Workspace", + permission: "ask", + createdAt: 1, + updatedAt: 1, + }; + let assertCreateCurrent: (() => void) | undefined; + let finishDeletionCalls = 0; + const deps = { + chatStore: { + list: async () => [chat()], + get: async (id: string) => chat(id), + create: async (input: { assertCurrent?: () => void; workspaceId?: string }) => { + assertCreateCurrent = input.assertCurrent; + input.assertCurrent?.(); + return chat("created", input.workspaceId); + }, + rename: async () => chat(), + moveEmptyChatToWorkspace: async (id: string, workspaceId: string) => + chat(id, workspaceId), + remove: async () => undefined, + }, + configStore: { getWorkspace: async (id: string) => id === workspace.id ? workspace : null }, + llmClient: { + isChatOwnedByInactiveRenderer: () => false, + waitForChatIdle: async () => true, + requiresAppendReconciliation: () => false, + markAppendReconciliationRequired: () => undefined, + clearAppendReconciliationRequired: () => undefined, + beginChatWorkspaceChange: () => () => undefined, + beginChatDeletion: () => () => { finishDeletionCalls += 1; }, + cancelChat: async () => undefined, + }, + workspaceMutationGate: { + admit: () => ({ signal: new AbortController().signal, release: () => undefined }), + }, + workspaceOperationRegistry: new WorkspaceOperationRegistry(), + subagentRunStore: { + deleteChat: async () => undefined, + completeChatDeletion: async () => undefined, + pendingChatDeletions: async () => [], + }, + piRuntimeEffectStore: { deleteChat: async () => undefined }, + piCompactionSessionStore: { deleteChat: async () => undefined }, + logError: () => undefined, + ...overrides, + } as unknown as ChatApplicationDependencies; + return { + service: createChatApplicationService(deps), + assertCreateCurrent: () => assertCreateCurrent?.(), + finishDeletionCalls: () => finishDeletionCalls, + }; +} + +test("shared chat creation preserves workspace and renderer-owner commit gates", async () => { + const application = fixture(); + const requestOwner = owner(); + const created = await application.service.create( + { workspaceId: "workspace-1", title: "Chat" }, + requestOwner, + ); + assert.equal(created?.id, "created"); + requestOwner.invalidate(); + assert.throws(application.assertCreateCurrent, /renderer document is no longer active/u); + + await assert.rejects( + application.service.create( + { workspaceId: "missing", title: "Chat" }, + owner(), + ), + /selected workspace is no longer available/u, + ); +}); + +test("shared chat reads retain inactive-renderer reconciliation semantics", async () => { + let checks = 0; + const application = fixture({ + llmClient: { + isChatOwnedByInactiveRenderer: () => ++checks <= 2, + waitForChatIdle: async () => false, + requiresAppendReconciliation: () => false, + markAppendReconciliationRequired: () => undefined, + clearAppendReconciliationRequired: () => undefined, + beginChatWorkspaceChange: () => () => undefined, + beginChatDeletion: () => () => undefined, + cancelChat: async () => undefined, + } as ChatApplicationDependencies["llmClient"], + }); + assert.deepEqual((await application.service.get("chat-1")).reconciliation, { + chatId: "chat-1", + workspaceId: "workspace-1", + }); +}); + +test("shared chat deletion keeps admission closed while a durable delete is pending", async () => { + const events: string[] = []; + const application = fixture({ + chatStore: { + list: async () => [], + get: async () => chat(), + create: async () => chat(), + rename: async () => chat(), + moveEmptyChatToWorkspace: async () => chat(), + remove: async () => { throw new Error("disk failed"); }, + } as ChatApplicationDependencies["chatStore"], + subagentRunStore: { + deleteChat: async () => { events.push("private-delete"); }, + completeChatDeletion: async () => { events.push("complete"); }, + pendingChatDeletions: async () => ["chat-1"], + } as ChatApplicationDependencies["subagentRunStore"], + piRuntimeEffectStore: { deleteChat: async () => { events.push("effects-delete"); } }, + piCompactionSessionStore: { deleteChat: async () => { events.push("compaction-delete"); } }, + }); + + await assert.rejects(application.service.remove("chat-1"), /disk failed/u); + assert.deepEqual(events, ["private-delete", "effects-delete", "compaction-delete"]); + assert.equal(application.finishDeletionCalls(), 0); +}); + +test("chat deletion checks a remote revision before cancellation or private-history changes", async () => { + const effects: string[] = []; + const application = fixture({ + llmClient: { + isChatOwnedByInactiveRenderer: () => false, + waitForChatIdle: async () => true, + requiresAppendReconciliation: () => false, + markAppendReconciliationRequired: () => undefined, + clearAppendReconciliationRequired: () => undefined, + beginChatWorkspaceChange: () => () => undefined, + beginChatDeletion: () => () => undefined, + cancelChat: async () => { effects.push("cancel"); }, + } as ChatApplicationDependencies["llmClient"], + subagentRunStore: { + deleteChat: async () => { effects.push("subagents"); }, + completeChatDeletion: async () => undefined, + pendingChatDeletions: async () => [], + }, + }); + await assert.rejects( + application.service.remove("chat-1", { + assertCurrent: () => { throw new Error("stale revision"); }, + }), + /stale revision/u, + ); + assert.deepEqual(effects, []); +}); diff --git a/main/services/chat-application-service.ts b/main/services/chat-application-service.ts new file mode 100644 index 00000000..8c2d9532 --- /dev/null +++ b/main/services/chat-application-service.ts @@ -0,0 +1,217 @@ +import { ASSISTANT_WORKSPACE_ID } from "../../renderer/shared/assistant.js"; +import { appendReconciliationFailureMessage } from "../../renderer/shared/chat-message-contract.js"; +import { persistedChatWorkspaceId } from "../../renderer/shared/chat-workspace.js"; +import type { ParsedPublicChatCreate } from "../handlers/chat-create-params.js"; +import type { chatStore } from "./chat-store.js"; +import type { configStore } from "./config-store.js"; +import { isChatCreateReconciliationRequiredError } from "./chat-store-core.js"; +import type { llmClient } from "./llm-client.js"; +import type { Chat } from "./types.js"; +import type { piCompactionSessionStore } from "./pi-compaction-session-store.js"; +import type { piRuntimeEffectStore } from "./pi-runtime-effect-store.js"; +import type { subagentRunStore } from "./subagents/subagent-run-store.js"; +import { chatForRenderer } from "./visible-chat-projection.js"; +import type { workspaceMutationGate } from "./workspace-mutation-gate.js"; +import { + admitOwnedWorkspaceOperation, + type workspaceOperationRegistry, + type WorkspaceOperationDocumentOwner, +} from "./workspace-operation-registry.js"; + +export interface ChatApplicationOwner extends WorkspaceOperationDocumentOwner { + documentId: string; +} + +export interface ChatApplicationMutationOptions { + assertCurrent?: (chat: Chat) => void | Promise; +} + +export interface ChatApplicationDependencies { + chatStore: Pick< + typeof chatStore, + "list" | "get" | "create" | "rename" | "moveEmptyChatToWorkspace" | "remove" + >; + configStore: Pick; + llmClient: Pick< + typeof llmClient, + | "isChatOwnedByInactiveRenderer" + | "waitForChatIdle" + | "requiresAppendReconciliation" + | "markAppendReconciliationRequired" + | "clearAppendReconciliationRequired" + | "beginChatWorkspaceChange" + | "beginChatDeletion" + | "cancelChat" + >; + workspaceMutationGate: Pick; + workspaceOperationRegistry: typeof workspaceOperationRegistry; + subagentRunStore: Pick< + typeof subagentRunStore, + "deleteChat" | "completeChatDeletion" | "pendingChatDeletions" + >; + piRuntimeEffectStore: Pick; + piCompactionSessionStore: Pick; + logError(area: string, message: string, error: unknown): void; +} + +export function createChatApplicationService(deps: ChatApplicationDependencies) { + const markReconciliationRequired = (owner: ChatApplicationOwner): never => { + deps.llmClient.markAppendReconciliationRequired(owner.documentId); + owner.onInvalidated(() => { + deps.llmClient.clearAppendReconciliationRequired(owner.documentId); + }); + throw new Error(appendReconciliationFailureMessage("blocked")); + }; + + return { + list(workspaceId?: string) { + return deps.chatStore.list(workspaceId); + }, + + async get(chatId: string) { + let reconciliationRequired = false; + if (deps.llmClient.isChatOwnedByInactiveRenderer(chatId)) { + reconciliationRequired = !(await deps.llmClient.waitForChatIdle(chatId)); + } + const chat = await deps.chatStore.get(chatId); + reconciliationRequired ||= deps.llmClient.isChatOwnedByInactiveRenderer(chatId); + return { + chat: chatForRenderer(chat), + reconciliation: reconciliationRequired + ? { + chatId, + workspaceId: persistedChatWorkspaceId(chat?.workspaceId), + } + : null, + }; + }, + + waitUntilIdle(chatId: string) { + return deps.llmClient.waitForChatIdle(chatId); + }, + + async create(input: ParsedPublicChatCreate, owner: ChatApplicationOwner) { + if (deps.llmClient.requiresAppendReconciliation(owner.documentId)) { + throw new Error(appendReconciliationFailureMessage("blocked")); + } + if (input.workspaceId === ASSISTANT_WORKSPACE_ID) { + throw new Error("Aiden Assistant chats require the Assistant chat creation path."); + } + const mutationAdmission = deps.workspaceMutationGate.admit(input.workspaceId); + let workspaceOperation: + ReturnType | undefined; + try { + workspaceOperation = admitOwnedWorkspaceOperation( + deps.workspaceOperationRegistry, + owner, + input.workspaceId, + ); + if (!(await deps.configStore.getWorkspace(input.workspaceId))) { + throw new Error("The selected workspace is no longer available."); + } + const assertCurrent = () => { + if (owner.isDestroyed()) { + throw new Error("The renderer document is no longer active."); + } + if (mutationAdmission.signal.aborted || workspaceOperation?.signal.aborted) { + throw new Error("The workspace changed before the chat was created."); + } + if (deps.llmClient.requiresAppendReconciliation(owner.documentId)) { + throw new Error(appendReconciliationFailureMessage("blocked")); + } + }; + try { + return chatForRenderer(await deps.chatStore.create({ ...input, assertCurrent })); + } catch (error) { + if (isChatCreateReconciliationRequiredError(error)) { + return markReconciliationRequired(owner); + } + throw error; + } + } finally { + workspaceOperation?.release(); + mutationAdmission.release(); + } + }, + + rename(chatId: string, title: string, options: ChatApplicationMutationOptions = {}) { + return deps.chatStore.rename(chatId, title, async (chat) => options.assertCurrent?.(chat)); + }, + + async moveEmptyToWorkspace( + chatId: string, + workspaceId: string, + options: ChatApplicationMutationOptions = {}, + ) { + const finishMove = deps.llmClient.beginChatWorkspaceChange(chatId); + if (!finishMove) { + throw new Error("Finish or stop the current response before changing workspaces."); + } + try { + if (!(await deps.configStore.getWorkspace(workspaceId))) { + throw new Error(`Workspace ${workspaceId} not found.`); + } + return chatForRenderer( + await deps.chatStore.moveEmptyChatToWorkspace( + chatId, + workspaceId, + async (chat) => options.assertCurrent?.(chat), + ), + ); + } finally { + finishMove(); + } + }, + + async remove( + chatId: string, + options: ChatApplicationMutationOptions = {}, + ): Promise { + const finishDeletion = deps.llmClient.beginChatDeletion(chatId); + let releaseAdmission = false; + try { + const current = await deps.chatStore.get(chatId); + if (!current) throw new Error(`Chat ${chatId} not found`); + await options.assertCurrent?.(current); + await deps.llmClient.cancelChat(chatId); + try { + await deps.subagentRunStore.deleteChat(chatId); + } catch (error) { + deps.logError("subagents", "Could not delete private subagent history.", error); + throw new Error("Aiden could not delete this chat's subagent history."); + } + try { + await deps.piRuntimeEffectStore.deleteChat(chatId); + } catch (error) { + deps.logError("pi", "Could not delete private Pi effect history.", error); + throw new Error("Aiden could not delete this chat's tool-effect history."); + } + try { + await deps.piCompactionSessionStore.deleteChat(chatId); + } catch (error) { + deps.logError("pi", "Could not delete the private compaction journal.", error); + throw new Error("Aiden could not delete this chat's compaction history."); + } + await deps.chatStore.remove(chatId, async (chat) => { + if (!chat) throw new Error(`Chat ${chatId} not found`); + await options.assertCurrent?.(chat); + }); + await deps.subagentRunStore.completeChatDeletion(chatId); + releaseAdmission = true; + } finally { + if (!releaseAdmission) { + try { + releaseAdmission = !(await deps.subagentRunStore.pendingChatDeletions()).includes(chatId); + } catch (error) { + deps.logError( + "subagents", + "Could not inspect pending chat deletion state.", + error, + ); + } + } + if (releaseAdmission) finishDeletion(); + } + }, + }; +} diff --git a/main/services/chat-generation-owner.test.ts b/main/services/chat-generation-owner.test.ts new file mode 100644 index 00000000..4ee29a64 --- /dev/null +++ b/main/services/chat-generation-owner.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createRemoteChatGenerationOwner } from "./chat-generation-owner.js"; + +test("remote generation ownership survives delivery disconnects until explicit invalidation", () => { + const delivered: Array<[string, unknown]> = []; + let transportAvailable = true; + const remote = createRemoteChatGenerationOwner({ + deviceId: "paired-device-secret-shaped-id", + streamId: "stream-1", + publish: (channel, payload) => { + if (!transportAvailable) throw new Error("subscriber disconnected"); + delivered.push([channel, payload]); + }, + }); + + assert.equal(remote.owner.id, 0); + assert.equal(remote.owner.documentId.includes("paired-device-secret-shaped-id"), false); + remote.owner.send("chat:delta", { delta: "hello" }); + assert.deepEqual(delivered, [["chat:delta", { delta: "hello" }]]); + + transportAvailable = false; + assert.throws(() => remote.owner.send("chat:delta", { delta: "offline" }), /disconnected/u); + assert.equal(remote.owner.isDestroyed(), false); + + let invalidations = 0; + const remove = remote.owner.onInvalidated(() => { invalidations += 1; }); + remote.invalidate(); + remote.invalidate(); + remove(); + assert.equal(invalidations, 1); + assert.equal(remote.owner.isDestroyed(), true); + assert.throws( + () => remote.owner.send("chat:done", { streamId: "stream-1" }), + /no longer active/u, + ); +}); + +test("remote generation owners reject unbounded identities", () => { + assert.throws( + () => createRemoteChatGenerationOwner({ deviceId: "", streamId: "stream", publish: () => undefined }), + /device identity/u, + ); + assert.throws( + () => createRemoteChatGenerationOwner({ + deviceId: "device", + streamId: "s".repeat(129), + publish: () => undefined, + }), + /stream identity/u, + ); +}); diff --git a/main/services/chat-generation-owner.ts b/main/services/chat-generation-owner.ts index 098530ef..c92e9b6c 100644 --- a/main/services/chat-generation-owner.ts +++ b/main/services/chat-generation-owner.ts @@ -1,6 +1,96 @@ -import { rendererDocumentOwner, type RendererDocumentOwner } from "./renderer-document-owner.js"; +import { createHash } from "node:crypto"; +import type { NotificationChannel } from "../../renderer/preload-channels.js"; +import { rendererDocumentOwner } from "./renderer-document-owner.js"; -export type ChatGenerationOwner = RendererDocumentOwner; +/** + * Delivery and lifecycle authority for one generation. Renderer documents, + * background services, and paired remote devices implement this same narrow + * boundary without gaining each other's capabilities. + */ +export interface ChatGenerationOwner { + /** Nonzero values identify renderer WebContents; headless owners use zero. */ + id: number; + /** Stable turn-admission identity, independent of a network connection. */ + documentId: string; + isDestroyed(): boolean; + send(channel: NotificationChannel, payload: unknown): void; + onInvalidated(listener: () => void): () => void; +} + +export interface RemoteChatGenerationOwnerController { + owner: ChatGenerationOwner; + /** Explicit revocation/terminal cleanup; transport disconnect is not revocation. */ + invalidate(): void; +} + +function boundedRemoteIdentity(value: unknown, label: string): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 128 || + Buffer.byteLength(value, "utf8") > 512 + ) { + throw new Error(`Invalid remote ${label}.`); + } + return value; +} + +function identityDigest(value: string): string { + return createHash("sha256").update(value).digest("base64url"); +} + +/** + * Create a paired-device generation owner whose authority survives socket and + * SSE subscriber disconnects. The supplied publisher must target a durable + * stream journal; only explicit invalidation revokes generation ownership. + */ +export function createRemoteChatGenerationOwner(input: { + deviceId: string; + streamId: string; + publish(channel: NotificationChannel, payload: unknown): void; +}): RemoteChatGenerationOwnerController { + const deviceId = boundedRemoteIdentity(input.deviceId, "device identity"); + const streamId = boundedRemoteIdentity(input.streamId, "stream identity"); + let invalidated = false; + const listeners = new Set<() => void>(); + const owner: ChatGenerationOwner = { + id: 0, + documentId: `remote:${identityDigest(deviceId)}:${identityDigest(streamId)}`, + isDestroyed: () => invalidated, + send: (channel, payload) => { + if (invalidated) throw new Error("The remote generation owner is no longer active."); + input.publish(channel, payload); + }, + onInvalidated: (listener) => { + if (invalidated) { + listener(); + return () => undefined; + } + listeners.add(listener); + let active = true; + return () => { + if (!active) return; + active = false; + listeners.delete(listener); + }; + }, + }; + return { + owner, + invalidate: () => { + if (invalidated) return; + invalidated = true; + for (const listener of [...listeners]) { + listeners.delete(listener); + try { + listener(); + } catch { + // Revocation must notify every listener even if one cleanup fails. + } + } + }, + }; +} /** Bind a generation, its stream, cancellation, and approvals to one renderer document. */ export function chatGenerationOwner(event: Electron.IpcMainInvokeEvent): ChatGenerationOwner { diff --git a/main/services/chat-store-core.ts b/main/services/chat-store-core.ts index 396c941f..dcef6b34 100644 --- a/main/services/chat-store-core.ts +++ b/main/services/chat-store-core.ts @@ -747,13 +747,19 @@ export function createChatStore( }); }, - async rename(id: string, title: string): Promise { + async rename( + id: string, + title: string, + assertCurrent: (chat: Chat) => void | Promise = () => undefined, + ): Promise { return serialized(async () => { const chat = await readChat(id); if (!chat) throw new Error(`Chat ${id} not found`); + await assertCurrent(chat); chat.title = title.trim() || chat.title; chat.updatedAt = Date.now(); await writeChatAndMeta(chat); + return chat; }); }, @@ -779,10 +785,12 @@ export function createChatStore( async moveEmptyChatToWorkspace( id: string, workspaceId: string, + assertCurrent: (chat: Chat) => void | Promise = () => undefined, ): Promise { return serialized(async () => { const chat = await readChat(id); if (!chat) throw new Error(`Chat ${id} not found`); + await assertCurrent(chat); if (chat.messages.length > 0) { throw new Error("Only a new chat can change workspaces."); } @@ -815,8 +823,13 @@ export function createChatStore( }); }, - async remove(id: string): Promise { + async remove( + id: string, + assertCurrent?: (chat: Chat | null) => void | Promise, + ): Promise { return serialized(async () => { + const chat = await readChat(id); + if (assertCurrent) await assertCurrent(chat); const payload = await chatPath(id); await removeCrashLeftStages(path.dirname(payload)); let removedPayload = false; diff --git a/main/services/chat-title.ts b/main/services/chat-title.ts index 1b585401..6348ef4a 100644 --- a/main/services/chat-title.ts +++ b/main/services/chat-title.ts @@ -286,6 +286,10 @@ async function generateFoundationModelsRename(chatId: string): Promise { + const llmClientSource = readFileSync(new URL("./llm-client.ts", import.meta.url), "utf8"); + const startSource = llmClientSource.slice(llmClientSource.indexOf("export const llmClient")); + const helperStart = startSource.indexOf("const initializationTerminalState = { attempted: false }"); + const firstCleanup = startSource.indexOf("releaseGenerationSkillReservation(initialization)"); + assert.ok(helperStart >= 0, "terminal persistence helper must be part of generation start"); + assert.ok( + helperStart < firstCleanup, + "terminal persistence must be established before an initialization cleanup can run", + ); + assert.match(llmClientSource, /import \{ persistGenerationInitializationTerminal \}/u); + assert.match(startSource, /await persistGenerationInitializationTerminal\(\{/u); + assert.match(startSource, /append: \(message, meta\) => chatStore\.appendMessage\(params\.chatId, message, meta\)/u); + assert.match(startSource, /initializing\.get\(streamId\) === initialization/u); + assert.match(startSource, /active\.get\(streamId\)\?\.owner === owner/u); + + const cancellationPersists = startSource.match( + /await persistInitializationTerminal\(\s*"cancelled",\s*(?:initialization|activeGeneration)\.cancellationOrigin,?\s*\)/gu, + ); + const failurePersists = startSource.match( + /await persistInitializationTerminal\("failed"\)/gu, + ); + assert.equal(cancellationPersists?.length, 3); + assert.equal(failurePersists?.length, 3); + + for (const status of ["cancelled", "failed"] as const) { + const persistIndex = startSource.indexOf( + status === "cancelled" + ? 'await persistInitializationTerminal(\n "cancelled"' + : 'await persistInitializationTerminal("failed")', + ); + assert.ok(persistIndex >= 0); + const cleanupIndex = startSource.indexOf( + "releaseGenerationSkillReservation(initialization)", + persistIndex, + ); + assert.ok( + cleanupIndex > persistIndex, + `${status} outcome must become durable before generation ownership is released`, + ); + } +}); + test("historical subagent reads require an exact persisted assistant-message reference", () => { const exactReference = { role: "assistant", diff --git a/main/services/coding-tools.test.ts b/main/services/coding-tools.test.ts index 14bceb02..395dc8ec 100644 --- a/main/services/coding-tools.test.ts +++ b/main/services/coding-tools.test.ts @@ -5,7 +5,13 @@ import os from "node:os"; import * as path from "node:path"; import { promisify } from "node:util"; import test from "node:test"; -import { buildCodingTools, buildSubagentCodingTools, summarizeToolCall } from "./coding-tools.js"; +import { + buildCodingTools, + buildSubagentCodingTools, + DISCLOSURE_APPROVAL_TOOL_NAMES, + summarizeToolCall, +} from "./coding-tools.js"; +import { createShareImageTool } from "./share-image-tool.js"; const execFileAsync = promisify(execFile); @@ -69,6 +75,77 @@ test("approval summaries describe the consequence of mutating tools", () => { ); assert.equal(summarizeToolCall("edit_file", { path: "src/app.ts" }), "Edit file: src/app.ts"); assert.equal(summarizeToolCall("run_command", { command: "npm test" }), "Run command: npm test"); + assert.equal( + summarizeToolCall("share_image", { path: "/Users/person/Picture.png" }), + "Share image in chat: /Users/person/Picture.png", + ); + assert.equal(DISCLOSURE_APPROVAL_TOOL_NAMES.has("share_image"), true); +}); + +test("share_image admits verified PNG bytes from absolute paths without exposing the path", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-share-image-")); + try { + const imagePath = path.join(root, "Preview.png"); + await fs.writeFile( + imagePath, + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL2aQAAAABJRU5ErkJggg==", + "base64", + ), + ); + const shared: import("./types.js").Attachment[] = []; + const tool = createShareImageTool({ workspaceRoot: root, share: (attachment) => shared.push(attachment) }); + const result = await tool.execute("share-1", { path: imagePath }); + assert.equal(shared.length, 1); + assert.equal(shared[0]?.name, "Preview.png"); + assert.equal(shared[0]?.mimeType, "image/png"); + assert.match(shared[0]?.id ?? "", /^shared_/u); + const resultText = result.content[0]; + assert.equal(resultText?.type, "text"); + assert.doesNotMatch(resultText?.type === "text" ? resultText.text : "", new RegExp(root, "u")); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("share_image rejects files whose bytes are not a complete PNG or JPEG", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-share-image-invalid-")); + try { + await fs.writeFile(path.join(root, "fake.png"), "not an image", "utf8"); + const tool = createShareImageTool({ workspaceRoot: root, share: () => assert.fail("must not share") }); + await assert.rejects( + tool.execute("share-invalid", { path: "fake.png" }), + /Only complete PNG and JPEG images/u, + ); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("share_image does not disclose bytes after generation cancellation", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-share-image-cancelled-")); + try { + const imagePath = path.join(root, "Cancelled.png"); + await fs.writeFile( + imagePath, + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL2aQAAAABJRU5ErkJggg==", + "base64", + ), + ); + const controller = new AbortController(); + controller.abort(new Error("cancelled")); + const tool = createShareImageTool({ + workspaceRoot: root, + share: () => assert.fail("cancelled image must not be shared"), + }); + await assert.rejects( + tool.execute("share-cancelled", { path: imagePath }, controller.signal), + /cancelled/u, + ); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } }); test("parent coding tools retain hidden-metadata reads and JavaScript regex semantics", async () => { diff --git a/main/services/coding-tools.ts b/main/services/coding-tools.ts index 1d4edb9b..1edf2e48 100644 --- a/main/services/coding-tools.ts +++ b/main/services/coding-tools.ts @@ -40,6 +40,8 @@ let re2Constructor: typeof import("re2-wasm").RE2 | undefined; /** Tools whose effects mutate the folder or system — gated behind approval in "ask" mode. */ export const APPROVAL_TOOL_NAMES = new Set(["write_file", "edit_file", "run_command"]); +/** Sharing a local file is an outbound disclosure and always needs attended approval. */ +export const DISCLOSURE_APPROVAL_TOOL_NAMES = new Set(["share_image"]); function textResult(text: string): AgentToolResult { return { content: [{ type: "text", text }], details: null }; @@ -836,6 +838,8 @@ export function summarizeToolCall(toolName: string, args: unknown): string { return `Edit file: ${String(a.path ?? "?")}`; case "run_command": return `Run command: ${String(a.command ?? "?")}`; + case "share_image": + return `Share image in chat: ${String(a.path ?? "?")}`; default: return toolName; } diff --git a/main/services/concentrate-provider.test.ts b/main/services/concentrate-provider.test.ts new file mode 100644 index 00000000..7e447cf6 --- /dev/null +++ b/main/services/concentrate-provider.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createModels, type Model } from "@earendil-works/pi-ai"; +import { + CONCENTRATE_BASE_URL, + concentrateProvider, + parseConcentrateModels, + registerAidenBuiltinProviders, +} from "./concentrate-provider.js"; + +const modelRow = { + id: "gpt-5.6-terra", + display_name: "GPT 5.6 Terra", + max_input_tokens: 1_050_000, + max_tokens: 128_000, + capabilities: { + image_input: { supported: true }, + thinking: { supported: true }, + effort: { + supported: true, + low: { supported: true }, + medium: { supported: true }, + high: { supported: true }, + xhigh: { supported: true }, + max: { supported: true }, + }, + }, +}; + +test("Concentrate catalog parsing keeps executable identities and reviewed capabilities", () => { + const models = parseConcentrateModels({ + data: [ + { ...modelRow, id: "text-only", display_name: "Text only", capabilities: {} }, + modelRow, + { ...modelRow, display_name: "duplicate is ignored" }, + { ...modelRow, id: "x".repeat(257) }, + ], + }); + + assert.deepEqual( + models.map((model) => model.id), + ["gpt-5.6-terra", "text-only"], + ); + assert.deepEqual(models[0].input, ["text", "image"]); + assert.equal(models[0].api, "openai-responses"); + assert.equal(models[0].provider, "concentrate"); + assert.equal(models[0].baseUrl, CONCENTRATE_BASE_URL); + assert.equal(models[0].reasoning, true); + assert.deepEqual(models[0].thinkingLevelMap, { + off: null, + minimal: null, + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + max: "max", + }); + assert.equal(models[0].contextWindow, 1_050_000); + assert.equal(models[0].maxTokens, 128_000); + assert.deepEqual(models[0].cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); + assert.deepEqual(models[1].input, ["text"]); + assert.equal(models[1].reasoning, false); +}); + +test("Concentrate is an Aiden built-in with bounded dynamic refresh and no secret projection", async () => { + let request: Request | undefined; + const provider = concentrateProvider(async (input, init) => { + request = new Request(input, init); + return Response.json({ data: [modelRow] }); + }); + const writes: Model<"openai-responses">[][] = []; + await provider.refreshModels?.({ + allowNetwork: true, + force: true, + credential: { type: "api_key", key: "sk-cn-test-secret" }, + store: { + read: async () => undefined, + write: async (catalog) => { + writes.push(catalog.models as Model<"openai-responses">[]); + }, + delete: async () => undefined, + }, + }); + + assert.equal(request?.url, `${CONCENTRATE_BASE_URL}/models`); + assert.equal(request?.headers.get("authorization"), "Bearer sk-cn-test-secret"); + assert.equal(provider.name, "Concentrate"); + assert.equal(provider.baseUrl, CONCENTRATE_BASE_URL); + assert.equal(provider.auth.apiKey?.name, "Concentrate API key"); + assert.equal(provider.getModels()[0]?.id, "gpt-5.6-terra"); + assert.equal(JSON.stringify(writes).includes("sk-cn-test-secret"), false); + + const models = registerAidenBuiltinProviders(createModels()); + assert.equal(models.getProvider("concentrate")?.name, "Concentrate"); +}); + +test("Concentrate rejects empty, malformed, and oversized catalogs", async () => { + assert.throws(() => parseConcentrateModels({ data: [] }), /no usable chat models/u); + assert.throws(() => parseConcentrateModels({ object: "list" }), /invalid model catalog/u); + + const provider = concentrateProvider(async () => + Response.json({ data: [modelRow] }, { headers: { "content-length": "1048577" } }), + ); + assert.ok(provider.refreshModels); + await assert.rejects( + provider.refreshModels({ + allowNetwork: true, + store: { + read: async () => undefined, + write: async () => undefined, + delete: async () => undefined, + }, + }), + /oversized model catalog/u, + ); +}); diff --git a/main/services/concentrate-provider.ts b/main/services/concentrate-provider.ts new file mode 100644 index 00000000..f4c4d0ff --- /dev/null +++ b/main/services/concentrate-provider.ts @@ -0,0 +1,183 @@ +import { + createProvider, + envApiKeyAuth, + type Model, + type MutableModels, + type ThinkingLevelMap, +} from "@earendil-works/pi-ai"; +import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy"; + +export const CONCENTRATE_PROVIDER_ID = "concentrate"; +export const CONCENTRATE_PROVIDER_NAME = "Concentrate"; +export const CONCENTRATE_BASE_URL = "https://api.concentrate.ai/v1"; + +const CONCENTRATE_MODELS_URL = `${CONCENTRATE_BASE_URL}/models`; +const CONCENTRATE_DEFAULT_MODEL = "gpt-5.6-terra"; +const MAX_CATALOG_BYTES = 1_048_576; +const MAX_CATALOG_MODELS = 512; +const MAX_MODEL_ID_LENGTH = 256; +const MAX_MODEL_NAME_LENGTH = 160; +const MAX_MODEL_TOKEN_LIMIT = 2_000_000; + +type Fetch = typeof fetch; + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function boundedPositiveInteger(value: unknown, fallback: number): number { + return typeof value === "number" && + Number.isSafeInteger(value) && + value > 0 && + value <= MAX_MODEL_TOKEN_LIMIT + ? value + : fallback; +} + +function supported(value: unknown): boolean { + return record(value)?.supported === true; +} + +function thinkingLevelMap(capabilities: Record): ThinkingLevelMap | undefined { + const thinking = record(capabilities.thinking); + const effort = record(capabilities.effort); + const reasoning = thinking?.supported === true || effort?.supported === true; + if (!reasoning) return undefined; + + const level = (name: "low" | "medium" | "high" | "xhigh" | "max") => + supported(effort?.[name]) ? name : null; + return { + off: null, + minimal: null, + low: level("low"), + medium: level("medium"), + high: level("high"), + xhigh: level("xhigh"), + max: level("max"), + }; +} + +/** + * Convert Concentrate's public model projection into Pi's bounded runtime + * contract. The catalog does not publish pricing, so Aiden leaves the rates at + * zero and reports these as unpriced hosted runs instead of inventing a cost. + */ +export function parseConcentrateModels(value: unknown): Model<"openai-responses">[] { + const data = record(value)?.data; + if (!Array.isArray(data)) throw new Error("Concentrate returned an invalid model catalog."); + + const models: Model<"openai-responses">[] = []; + const seen = new Set(); + for (const entry of data.slice(0, MAX_CATALOG_MODELS)) { + const model = record(entry); + const id = typeof model?.id === "string" ? model.id.trim() : ""; + if (!id || id.length > MAX_MODEL_ID_LENGTH || seen.has(id)) continue; + + const rawName = typeof model?.display_name === "string" ? model.display_name.trim() : ""; + const name = rawName && rawName.length <= MAX_MODEL_NAME_LENGTH ? rawName : id; + const capabilities = record(model?.capabilities) ?? {}; + const levels = thinkingLevelMap(capabilities); + seen.add(id); + models.push({ + id, + name, + api: "openai-responses", + provider: CONCENTRATE_PROVIDER_ID, + baseUrl: CONCENTRATE_BASE_URL, + reasoning: levels !== undefined, + ...(levels ? { thinkingLevelMap: levels } : {}), + input: supported(capabilities.image_input) ? ["text", "image"] : ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: boundedPositiveInteger(model?.max_input_tokens, 128_000), + maxTokens: boundedPositiveInteger(model?.max_tokens, 16_384), + compat: { + supportsDeveloperRole: true, + sessionAffinityFormat: "openai-nosession", + supportsLongCacheRetention: false, + supportsToolSearch: false, + }, + }); + } + + if (models.length === 0) throw new Error("Concentrate returned no usable chat models."); + const preferred = models.findIndex((model) => model.id === CONCENTRATE_DEFAULT_MODEL); + if (preferred > 0) models.unshift(...models.splice(preferred, 1)); + return models; +} + +async function boundedJson(response: Response): Promise { + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > MAX_CATALOG_BYTES) { + await response.body?.cancel().catch(() => undefined); + throw new Error("Concentrate returned an oversized model catalog."); + } + if (!response.body) throw new Error("Concentrate returned an empty model catalog."); + + const chunks: Uint8Array[] = []; + const reader = response.body.getReader(); + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_CATALOG_BYTES) { + await reader.cancel().catch(() => undefined); + throw new Error("Concentrate returned an oversized model catalog."); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; + } catch { + throw new Error("Concentrate returned an invalid model catalog."); + } +} + +export function concentrateProvider(fetchImpl: Fetch = fetch) { + return createProvider({ + id: CONCENTRATE_PROVIDER_ID, + name: CONCENTRATE_PROVIDER_NAME, + baseUrl: CONCENTRATE_BASE_URL, + auth: { + apiKey: envApiKeyAuth("Concentrate API key", ["CONCENTRATE_API_KEY"]), + }, + models: [], + fetchModels: async ({ credential, signal }) => { + const key = credential?.type === "api_key" ? credential.key?.trim() : undefined; + const response = await fetchImpl(CONCENTRATE_MODELS_URL, { + method: "GET", + headers: { + accept: "application/json", + ...(key ? { authorization: `Bearer ${key}` } : {}), + }, + redirect: "error", + signal, + }); + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + throw new Error(`Concentrate model refresh failed (${response.status}).`); + } + return parseConcentrateModels(await boundedJson(response)); + }, + api: openAIResponsesApi(), + }); +} + +/** Register Aiden-owned built-ins after Pi's pinned catalog is constructed. */ +export function registerAidenBuiltinProviders(models: MutableModels): MutableModels { + models.setProvider(concentrateProvider()); + return models; +} diff --git a/main/services/config-store-core.test.ts b/main/services/config-store-core.test.ts index f0225fa4..f3d11f88 100644 --- a/main/services/config-store-core.test.ts +++ b/main/services/config-store-core.test.ts @@ -232,6 +232,63 @@ test("removing a provider clears its cache entry and its key", async (t) => { assert.equal(h.secrets.keys[provider.id], undefined); }); +test("model visibility updates are atomic and provider-scoped", async (t) => { + const h = await harness(t); + await Promise.all([ + h.store.setModelVisibility("google", "gemini-pro", true), + h.store.setModelVisibility("anthropic", "claude-sonnet", true), + h.store.setModelVisibility("google", "gemini-flash", true), + ]); + + assert.deepEqual((await h.store.getSettings()).hiddenModelsByProvider, { + anthropic: ["claude-sonnet"], + google: ["gemini-flash", "gemini-pro"], + }); + + await h.store.setModelVisibility("google", "gemini-pro", false); + assert.deepEqual((await h.store.getSettings()).hiddenModelsByProvider, { + anthropic: ["claude-sonnet"], + google: ["gemini-flash"], + }); + await h.store.showAllProviderModels("google"); + assert.deepEqual((await h.store.getSettings()).hiddenModelsByProvider, { + anthropic: ["claude-sonnet"], + }); +}); + +test("removing a provider clears its model visibility preferences", async (t) => { + const h = await harness(t); + await h.store.saveProvider(provider); + await h.store.setModelVisibility(provider.id, provider.models[0], true); + await h.store.setModelVisibility("other", "other-model", true); + + await h.store.removeProvider(provider.id); + + assert.deepEqual((await h.store.getSettings()).hiddenModelsByProvider, { + other: ["other-model"], + }); +}); + +test("admitted provider removal finishes cache and visibility cleanup after renderer navigation", async (t) => { + const h = await harness(t); + await h.store.saveProvider(provider); + await h.store.setModelVisibility(provider.id, provider.models[0], true); + h.secrets.keys[provider.id] = "ciphertext"; + let validityChecks = 0; + + await h.store.removeProvider(provider.id, () => { + validityChecks += 1; + return validityChecks <= 5; + }); + + assert.equal(validityChecks, 5); + assert.deepEqual(await h.store.listProviders(), []); + assert.equal((await h.store.getSettings()).hiddenModelsByProvider?.[provider.id], undefined); + assert.equal(h.secrets.keys[provider.id], undefined); + const cache = await readJson<{ byProvider: Record }>(h.cacheFile); + assert.equal(cache.byProvider[provider.id], undefined); +}); + // ── Store placement ────────────────────────────────────────────────────────── test("MCP servers and skills are portable; workspaces and settings are not", async (t) => { @@ -428,7 +485,10 @@ test("released onboarding local identity migration re-homes cache and remembered defaultModel: "qwen3-8b", }, ], - settings: { lastProviderId: releasedId }, + settings: { + lastProviderId: releasedId, + hiddenModelsByProvider: { [releasedId]: ["qwen3-8b"] }, + }, seeded: true, }); @@ -439,6 +499,9 @@ test("released onboarding local identity migration re-homes cache and remembered assert.deepEqual(listed.models, provider.models); assert.deepEqual(listed.modelMetadata, provider.modelMetadata); assert.equal((await h.store.getSettings()).lastProviderId, "custom:lmstudio"); + assert.deepEqual((await h.store.getSettings()).hiddenModelsByProvider, { + "custom:lmstudio": ["qwen3-8b"], + }); const cache = await readJson<{ byProvider: Record }>(h.cacheFile); assert.equal(cache.byProvider[releasedId], undefined); assert.deepEqual(cache.byProvider["custom:lmstudio"], { @@ -2052,6 +2115,7 @@ test("future nested settings versions survive unrelated writes", async (t) => { const googleThinkingByModel = { "future-google": "ultra" }; const codexThinkingByModel = { "future-codex": "ultra" }; const anthropicThinkingByModel = { "future-anthropic": "ultra" }; + const providerThinkingByModel = { "future-provider": { "future-model": "ultra" } }; await fs.writeFile( h.settingsFile, JSON.stringify({ @@ -2066,6 +2130,7 @@ test("future nested settings versions survive unrelated writes", async (t) => { googleThinkingByModel, codexThinkingByModel, anthropicThinkingByModel, + providerThinkingByModel, }, }), "utf-8", @@ -2080,6 +2145,7 @@ test("future nested settings versions survive unrelated writes", async (t) => { assert.deepEqual(saved.googleThinkingByModel, googleThinkingByModel); assert.deepEqual(saved.codexThinkingByModel, codexThinkingByModel); assert.deepEqual(saved.anthropicThinkingByModel, anthropicThinkingByModel); + assert.deepEqual(saved.providerThinkingByModel, providerThinkingByModel); assert.equal(saved.voiceProvider, "future-voice"); assert.equal(saved.chatTitleProviderId, "future-title-policy"); assert.equal(saved.scheduledDefaultMode, "future-mode"); @@ -2091,6 +2157,7 @@ test("future nested settings versions survive unrelated writes", async (t) => { assert.equal(runtime.googleThinkingByModel, undefined); assert.equal(runtime.codexThinkingByModel, undefined); assert.equal(runtime.anthropicThinkingByModel, undefined); + assert.equal(runtime.providerThinkingByModel, undefined); assert.equal(runtime.voiceProvider, undefined); assert.equal(runtime.chatTitleProviderId, undefined); assert.equal(runtime.scheduledDefaultMode, undefined); @@ -2100,6 +2167,7 @@ test("future nested settings versions survive unrelated writes", async (t) => { await h.store.setGoogleThinkingLevel("known-google", "high"); await h.store.setCodexThinkingLevel("known-codex", "xhigh"); await h.store.setAnthropicThinkingLevel("known-anthropic", "max"); + await h.store.setProviderThinkingLevel("opencode-go", "ox-alpha-free", "high"); const edited = (await readJson<{ settings: Record }>(h.settingsFile)).settings; assert.equal((edited.assistant as Record).futureMode, "ambient"); @@ -2109,10 +2177,15 @@ test("future nested settings versions survive unrelated writes", async (t) => { (edited.anthropicThinkingByModel as Record)["future-anthropic"], "ultra", ); + assert.deepEqual( + (edited.providerThinkingByModel as Record)["future-provider"], + { "future-model": "ultra" }, + ); const editedRuntime = await h.store.getSettings(); assert.equal(editedRuntime.googleThinkingByModel?.["known-google"], "high"); assert.equal(editedRuntime.codexThinkingByModel?.["known-codex"], "xhigh"); assert.equal(editedRuntime.anthropicThinkingByModel?.["known-anthropic"], "max"); + assert.equal(editedRuntime.providerThinkingByModel?.["opencode-go"]?.["ox-alpha-free"], "high"); }); test("editing MCP servers and skills preserves unknown future fields", async (t) => { diff --git a/main/services/config-store-core.ts b/main/services/config-store-core.ts index 350233fb..7b012377 100644 --- a/main/services/config-store-core.ts +++ b/main/services/config-store-core.ts @@ -46,7 +46,16 @@ import { mergeAnthropicThinkingPreference, type AnthropicThinkingLevel, } from "../../renderer/shared/anthropic-thinking.js"; +import { + mergeProviderThinkingPreference, +} from "../../renderer/shared/provider-thinking.js"; +import type { GenerationThinkingLevel } from "../../renderer/shared/generation-thinking.js"; import { migrateLegacyPiProviderId } from "../../renderer/shared/google-provider.js"; +import { + remapHiddenModelProvider, + withModelVisibility, + withoutProviderVisibility, +} from "../../renderer/shared/model-visibility.js"; import { ASSISTANT_WORKSPACE_ID } from "../../renderer/shared/assistant.js"; import type { AppSettings, @@ -321,6 +330,7 @@ export function createConfigStore( const cacheBefore = (await modelCache.load()).byProvider; const currentSettings = runtimeSettingsFrom((await settingsStore.load()).settings); let lastProviderId = currentSettings.lastProviderId; + let hiddenModelsByProvider = currentSettings.hiddenModelsByProvider; let migrated: StoredProvider[] = []; let providersChanged = false; let aliasRoutesForMigration: Record = {}; @@ -373,11 +383,6 @@ export function createConfigStore( if (!seeded) { await localStore.update((config) => void (config.seeded = true)); } - if (lastProviderId !== currentSettings.lastProviderId) { - await settingsStore.update( - (config) => void (config.settings.lastProviderId = lastProviderId), - ); - } const activeProviderIds = new Set(config.providers.map((provider) => provider.id)); const cacheAliasEntries = providerAliasRoutes(aliasRoutesForMigration).filter( ([legacyId, targetId]) => @@ -386,6 +391,23 @@ export function createConfigStore( activeProviderIds.has(targetId), ); secretMigrationAliases = cacheAliasEntries; + for (const [legacyId, targetId] of cacheAliasEntries) { + hiddenModelsByProvider = remapHiddenModelProvider( + hiddenModelsByProvider, + legacyId, + targetId, + ); + } + if ( + lastProviderId !== currentSettings.lastProviderId || + JSON.stringify(hiddenModelsByProvider) !== + JSON.stringify(currentSettings.hiddenModelsByProvider) + ) { + await settingsStore.update((config) => { + config.settings.lastProviderId = lastProviderId; + config.settings.hiddenModelsByProvider = hiddenModelsByProvider; + }); + } secretMigrationTargets = [ ...new Map( cacheAliasEntries.flatMap(([, targetId]) => { @@ -622,8 +644,17 @@ export function createConfigStore( await mutatePortable((config) => { config.providers = config.providers.filter((p) => p.id !== id); }, isCurrent); - await modelCache.update((draft) => void delete draft.byProvider[id], isCurrent); - await secrets.deleteKey(id, isCurrent); + // Once portable deletion commits, finish every dependent cleanup even if + // the requesting renderer navigates away. Otherwise recreating this ID + // can revive stale cache or visibility state. + await modelCache.update((draft) => void delete draft.byProvider[id]); + await mutateSettings((config) => { + config.settings.hiddenModelsByProvider = withoutProviderVisibility( + config.settings.hiddenModelsByProvider, + id, + ); + }); + await secrets.deleteKey(id); }, /** Resolve a historical provider identity without ever falling through to a new Pi provider. */ @@ -666,6 +697,36 @@ export function createConfigStore( return runtimeSettingsFrom(saved); }, + /** Atomically update one presentation-only model visibility preference. */ + async setModelVisibility( + providerId: string, + modelId: string, + hidden: boolean, + ): Promise { + const saved = await mutateSettings((config) => { + config.settings.hiddenModelsByProvider = withModelVisibility( + config.settings.hiddenModelsByProvider, + providerId, + modelId, + hidden, + ); + return structuredClone(config.settings); + }); + return runtimeSettingsFrom(saved); + }, + + /** Atomically restore every model for one provider to picker visibility. */ + async showAllProviderModels(providerId: string): Promise { + const saved = await mutateSettings((config) => { + config.settings.hiddenModelsByProvider = withoutProviderVisibility( + config.settings.hiddenModelsByProvider, + providerId, + ); + return structuredClone(config.settings); + }); + return runtimeSettingsFrom(saved); + }, + /** Atomically merge one validated native-Google preference into current settings. */ async setGoogleThinkingLevel( modelId: string, @@ -711,6 +772,24 @@ export function createConfigStore( return runtimeSettingsFrom(saved); }, + /** Atomically persist a Pi-native thinking preference for any other provider. */ + async setProviderThinkingLevel( + providerId: string, + modelId: string, + level: GenerationThinkingLevel, + ): Promise { + const saved = await mutateSettings((config) => { + config.settings.providerThinkingByModel = mergeProviderThinkingPreference( + config.settings.providerThinkingByModel, + providerId, + modelId, + level, + ); + return structuredClone(config.settings); + }); + return runtimeSettingsFrom(saved); + }, + // ── MCP servers ────────────────────────────────────────────────────── async listMcpServers(): Promise { return (await readPortable()).mcpServers; diff --git a/main/services/generation-initialization-terminal.test.ts b/main/services/generation-initialization-terminal.test.ts new file mode 100644 index 00000000..9c57272a --- /dev/null +++ b/main/services/generation-initialization-terminal.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + persistGenerationInitializationTerminal, + type GenerationInitializationTerminalMessage, + type GenerationInitializationTerminalMeta, +} from "./generation-initialization-terminal.js"; + +test("initialization terminal persistence is ordered, exact, and once-only", async () => { + const events: string[] = []; + const messages: GenerationInitializationTerminalMessage[] = []; + const metas: GenerationInitializationTerminalMeta[] = []; + const state = { attempted: false }; + const append = async (message: GenerationInitializationTerminalMessage, meta: GenerationInitializationTerminalMeta) => { + events.push("append-start"); + await Promise.resolve(); + messages.push(message); + metas.push(meta); + events.push("append-durable"); + }; + const common = { + state, + hasAuthoritativeChat: true, + workspaceId: "workspace-1", + streamId: "stream-1", + providerId: "provider-1", + model: "model-1", + isCurrent: () => true, + append, + onUnknownOutcome: () => assert.fail("unexpected unknown persistence outcome"), + }; + assert.equal(await persistGenerationInitializationTerminal({ + ...common, + status: "cancelled", + cancellationOrigin: "user_stop", + }), "persisted"); + events.push("ownership-release"); + assert.deepEqual(events, ["append-start", "append-durable", "ownership-release"]); + assert.equal(messages.length, 1); + assert.equal(messages[0]?.timeline.status, "cancelled"); + assert.equal(messages[0]?.timeline.cancellationOrigin, "user_stop"); + assert.equal(metas[0]?.expectedWorkspaceId, "workspace-1"); + assert.equal(await persistGenerationInitializationTerminal({ + ...common, + status: "failed", + }), "skipped"); + assert.equal(messages.length, 1); +}); + +test("initialization terminal persistence is authority gated and unknown outcomes never retry", async () => { + let appends = 0; + assert.equal(await persistGenerationInitializationTerminal({ + state: { attempted: false }, + hasAuthoritativeChat: true, + workspaceId: undefined, + streamId: "stream-2", + providerId: "provider-1", + model: "model-1", + status: "failed", + isCurrent: () => true, + append: async () => { appends += 1; }, + onUnknownOutcome: () => assert.fail("unexpected unknown persistence outcome"), + }), "skipped"); + const state = { attempted: false }; + let unknowns = 0; + const options = { + state, + hasAuthoritativeChat: true, + workspaceId: "workspace-1", + streamId: "stream-3", + providerId: "provider-1", + model: "model-1", + status: "failed" as const, + isCurrent: () => true, + append: async () => { appends += 1; throw new Error("unknown append outcome"); }, + onUnknownOutcome: () => { unknowns += 1; }, + }; + assert.equal(await persistGenerationInitializationTerminal(options), "unknown"); + assert.equal(await persistGenerationInitializationTerminal(options), "skipped"); + assert.equal(appends, 1); + assert.equal(unknowns, 1); +}); diff --git a/main/services/generation-initialization-terminal.ts b/main/services/generation-initialization-terminal.ts new file mode 100644 index 00000000..d9afbae9 --- /dev/null +++ b/main/services/generation-initialization-terminal.ts @@ -0,0 +1,68 @@ +import type { GenerationCancellationOrigin } from "../../renderer/shared/generation-timeline.js"; +import { GenerationTimelineProjector } from "./generation-timeline.js"; + +export interface GenerationInitializationTerminalState { + attempted: boolean; +} + +export interface GenerationInitializationTerminalMessage { + role: "assistant"; + content: ""; + model: string; + timeline: ReturnType; +} + +export interface GenerationInitializationTerminalMeta { + providerId: string; + model: string; + expectedWorkspaceId: string; + isCurrent: () => boolean; +} + +export async function persistGenerationInitializationTerminal(options: { + state: GenerationInitializationTerminalState; + hasAuthoritativeChat: boolean; + workspaceId?: string; + streamId: string; + providerId: string; + model: string; + status: "failed" | "cancelled"; + cancellationOrigin?: GenerationCancellationOrigin; + isCurrent: () => boolean; + append: ( + message: GenerationInitializationTerminalMessage, + meta: GenerationInitializationTerminalMeta, + ) => Promise; + onUnknownOutcome: (error: unknown) => void; +}): Promise<"persisted" | "skipped" | "unknown"> { + if ( + options.state.attempted || + !options.hasAuthoritativeChat || + options.workspaceId === undefined || + !options.isCurrent() + ) { + return "skipped"; + } + options.state.attempted = true; + const timeline = new GenerationTimelineProjector(options.streamId, () => {}).finish( + options.status, + options.cancellationOrigin, + ); + try { + await options.append( + { role: "assistant", content: "", model: options.model, timeline }, + { + providerId: options.providerId, + model: options.model, + expectedWorkspaceId: options.workspaceId, + isCurrent: options.isCurrent, + }, + ); + return "persisted"; + } catch (error) { + // A thrown append has an unknown durable outcome. Never retry it and risk + // duplicating the terminal assistant message. + options.onUnknownOutcome(error); + return "unknown"; + } +} diff --git a/main/services/generation-runtime.test.ts b/main/services/generation-runtime.test.ts index 5a54588a..d4d8c5f1 100644 --- a/main/services/generation-runtime.test.ts +++ b/main/services/generation-runtime.test.ts @@ -58,8 +58,24 @@ test("model thinking preserves native normalization and honors generic Pi reason { reasoning: true, thinkingLevelMap: { low: "low" } }, "high", ), + "high", + ); + assert.equal( + resolveGenerationThinkingLevel( + "bedrock", + { reasoning: true, thinkingLevelMap: { off: null, low: "low", medium: null } }, + undefined, + ), "off", ); + assert.equal( + resolveGenerationThinkingLevel( + "bedrock", + { reasoning: true, thinkingLevelMap: { off: null, low: "low", medium: null } }, + "high", + ), + "high", + ); assert.equal( resolveGenerationThinkingLevel( "openai-codex", diff --git a/main/services/generation-runtime.ts b/main/services/generation-runtime.ts index 19d820b9..a921eef8 100644 --- a/main/services/generation-runtime.ts +++ b/main/services/generation-runtime.ts @@ -30,6 +30,8 @@ import { isLocalProviderDeployment, type ProviderDeploymentFields, } from "../../renderer/shared/provider-deployment.js"; +import { normalizeProviderThinkingLevel } from "../../renderer/shared/provider-thinking.js"; +import { piThinkingLevelsForModel } from "./pi-model-metadata.js"; /** * Pi's current compatibility transports require a non-empty constructor value @@ -77,15 +79,13 @@ export function resolveGenerationThinkingLevel( ? requested : normalizeAnthropicThinkingLevel(levels, undefined); } - if (!model.reasoning || !requested || requested === "off") return "off"; - if ( - model.thinkingLevelMap && - (!(requested in model.thinkingLevelMap) || - model.thinkingLevelMap[requested] === null) - ) { - return "off"; - } - return requested; + const levels = piThinkingLevelsForModel(model) ?? []; + if (levels.length === 0) return "off"; + // Custom/local callers do not expose Aiden's generic thinking control and + // intentionally omit a request. Preserve their prior cost/latency behavior; + // built-in UI surfaces pass the normalized saved/default level explicitly. + if (requested === undefined) return "off"; + return normalizeProviderThinkingLevel(levels, requested); } /** The connection-bound runtime model is the sole request-time image authority. */ diff --git a/main/services/generation-timeline.test.ts b/main/services/generation-timeline.test.ts index 12681b37..6281ec95 100644 --- a/main/services/generation-timeline.test.ts +++ b/main/services/generation-timeline.test.ts @@ -195,6 +195,10 @@ test("safe tool descriptors retain only relative targets", () => { label: "Run command", detail: undefined, }); + assert.deepEqual(safeToolDescriptor("share_image", { path: "/Users/person/Result.png" }), { + label: "Share image", + target: undefined, + }); assert.deepEqual(safeToolDescriptor("read_file", { path: "/tmp/private.txt" }), { label: "Read file", target: undefined, diff --git a/main/services/generation-timeline.ts b/main/services/generation-timeline.ts index 987a7b36..df21f024 100644 --- a/main/services/generation-timeline.ts +++ b/main/services/generation-timeline.ts @@ -130,6 +130,8 @@ export function safeToolDescriptor(toolName: string, args: unknown): SafeToolDes return { label: "Edit file", target: path }; case "run_command": return { label: "Run command", detail: safeDetail(values.description) }; + case "share_image": + return { label: "Share image", target: path }; case "web_search": return { label: "Web search", detail: safeDetail(values.query) }; case "schedule_task": diff --git a/main/services/llm-client.ts b/main/services/llm-client.ts index 7db170db..7e64ba1e 100644 --- a/main/services/llm-client.ts +++ b/main/services/llm-client.ts @@ -17,7 +17,11 @@ import type { AssistantMessage } from "@earendil-works/pi-ai"; import { access } from "node:fs/promises"; import { ipcMain, logger } from "../platform.js"; import { buildAgentTools } from "./tools.js"; -import { APPROVAL_TOOL_NAMES, summarizeToolCall } from "./coding-tools.js"; +import { + APPROVAL_TOOL_NAMES, + DISCLOSURE_APPROVAL_TOOL_NAMES, + summarizeToolCall, +} from "./coding-tools.js"; import { gitInfo } from "./git.js"; import { configStore } from "./config-store.js"; import { secrets } from "./secrets.js"; @@ -45,7 +49,13 @@ import { usageStore } from "./usage-store.js"; import { storedPiAssistantMessage } from "./pi-message-storage.js"; import { chatForRenderer } from "./visible-chat-projection.js"; import { cancelWorkspaceGenerationsAndSettle } from "./workspace-mutation-gate.js"; -import type { ApprovalDecision, Chat, ChatStartParams, WorkspacePermission } from "./types.js"; +import type { + ApprovalDecision, + Attachment, + Chat, + ChatStartParams, + WorkspacePermission, +} from "./types.js"; import type { UsageRequestSource } from "./usage-store-core.js"; import type { ProviderFailureV1 } from "../../renderer/shared/provider-failure.js"; import { compactionFailureLogMetadata } from "./provider-failure.js"; @@ -81,6 +91,7 @@ import { piRuntimeEffectStore } from "./pi-runtime-effect-store.js"; import { createComputerUseController } from "./computer-use/runtime.js"; import { computerUseStatus } from "./computer-use/status.js"; import { GenerationTimelineProjector } from "./generation-timeline.js"; +import { persistGenerationInitializationTerminal } from "./generation-initialization-terminal.js"; import type { GenerationCancellationOrigin } from "../../renderer/shared/generation-timeline.js"; import { assertGenerationContextCapacity, @@ -88,6 +99,10 @@ import { } from "./generation-context.js"; import { buildGeminiWorkspaceSnapshot, GeminiContextCache } from "./gemini-context-cache.js"; import { attachClaimCheck } from "../../renderer/shared/claim-check.js"; +import { + MAX_ATTACHMENT_INLINE_BYTES, + MAX_ATTACHMENTS_PER_MESSAGE, +} from "../../renderer/shared/attachment-contract.js"; import { listWorkspaceFiles } from "./workspace-files.js"; import { assertManagedWorktreeAdmission } from "./managed-worktree-admission.js"; import { OPENAI_CODEX_PROVIDER_ID } from "./codex-provider.js"; @@ -415,6 +430,7 @@ async function prepareGeneration( ownerDocumentId: string, options: GenerationExecutionOptions, ) { + const sharedImages: Attachment[] = []; const runtime = await resolveModelRuntime(params.providerId, params.model, signal); const attendedAssistant = params.mode === "assistant"; const assistantPersonaMode = @@ -452,9 +468,9 @@ async function prepareGeneration( ? settings.googleThinkingByModel?.[params.model] : params.providerId === OPENAI_CODEX_PROVIDER_ID ? settings.codexThinkingByModel?.[params.model] - : params.providerId === ANTHROPIC_PROVIDER_ID + : params.providerId === ANTHROPIC_PROVIDER_ID ? settings.anthropicThinkingByModel?.[params.model] - : undefined; + : settings.providerThinkingByModel?.[params.providerId]?.[params.model]; const thinkingLevel = resolveGenerationThinkingLevel( params.providerId, model, @@ -674,6 +690,19 @@ async function prepareGeneration( subagentDelegationEnabled, ) : undefined, + shareImage: folderPath + ? (attachment) => { + if (sharedImages.length >= MAX_ATTACHMENTS_PER_MESSAGE) { + throw new Error("This response already contains the maximum number of images."); + } + if (sharedImages.some((item) => item.data === attachment.data)) return; + const nextBytes = sharedImages.reduce((sum, item) => sum + item.size, 0) + attachment.size; + if (nextBytes > MAX_ATTACHMENT_INLINE_BYTES) { + throw new Error("The images shared in this response exceed the 16 MB limit."); + } + sharedImages.push(attachment); + } + : undefined, }) ).filter((tool) => !options.excludeToolNames?.has(tool.name)); let googleWorkspaceSnapshot: string | undefined; @@ -711,6 +740,7 @@ async function prepareGeneration( workspaceId: workspace?.id, subagentSupervisor, showLocalModelReasoning: settings.showLocalModelReasoning, + sharedImages, // The Aiden system prompt reads its approval posture from settings, which // are already loaded here; re-reading them at the prompt site would be a // second disk round trip inside the generation's hot path. @@ -802,8 +832,34 @@ export const llmClient = { }); if (initialization.controller.signal.aborted) initialization.removeOwnerInvalidation(); let setup: Awaited>; - let authoritativeChat!: Chat; + let authoritativeChat: Chat | undefined; let authoritativeMode: ChatStartParams["mode"]; + const initializationTerminalState = { attempted: false }; + const persistInitializationTerminal = async ( + status: "failed" | "cancelled", + cancellationOrigin?: GenerationCancellationOrigin, + ): Promise => { + await persistGenerationInitializationTerminal({ + state: initializationTerminalState, + hasAuthoritativeChat: authoritativeChat !== undefined, + workspaceId: initialization.workspaceId, + streamId, + providerId: params.providerId, + model: params.model, + status, + cancellationOrigin, + isCurrent: () => + initializing.get(streamId) === initialization || + (active.get(streamId)?.chatId === params.chatId && + active.get(streamId)?.owner === owner), + append: (message, meta) => chatStore.appendMessage(params.chatId, message, meta), + onUnknownOutcome: (terminalError) => logger.error( + "pi", + `Could not persist the initialization outcome for stream ${streamId}`, + terminalError, + ), + }); + }; try { const chat = await chatStore.get(params.chatId); if (!chat) { @@ -855,7 +911,16 @@ export const llmClient = { ); } catch (error) { if (initialization.cancelRequested || initialization.controller.signal.aborted) { - sendGeneration(streamId, "chat:done", { streamId, content: "" }); + await persistInitializationTerminal( + "cancelled", + initialization.cancellationOrigin, + ); + sendGeneration(streamId, "chat:done", { + streamId, + content: "", + cancelled: true, + cancellationOrigin: initialization.cancellationOrigin, + }); releaseGenerationSkillReservation(initialization); initializing.delete(streamId); initialization.removeOwnerInvalidation(); @@ -863,6 +928,7 @@ export const llmClient = { broadcastChatSettled(streamId, params.chatId, initialization.workspaceId, params.workspaceId); return false; } + await persistInitializationTerminal("failed"); releaseGenerationSkillReservation(initialization); initializing.delete(streamId); initialization.removeOwnerInvalidation(); @@ -870,6 +936,10 @@ export const llmClient = { broadcastChatSettled(streamId, params.chatId, initialization.workspaceId, params.workspaceId); throw error; } + const generationChat = authoritativeChat; + if (!generationChat) { + throw new Error("This chat is no longer available."); + } const { runtime, permission, @@ -885,6 +955,7 @@ export const llmClient = { assistantSettingsPermission, subagentSupervisor, showLocalModelReasoning, + sharedImages, } = setup; const attendedAssistant = authoritativeMode === "assistant"; initialization.computerUse = computerUse; @@ -948,7 +1019,8 @@ export const llmClient = { finalTimeline.steps.length === 0 && finalTimeline.status !== "cancelled" && !subagents && - !providerFailure + !providerFailure && + sharedImages.length === 0 ) { return { chat: undefined, error: undefined, messageId: undefined }; } @@ -970,6 +1042,7 @@ export const llmClient = { ? finalTimeline : undefined, subagents, + attachments: sharedImages.length > 0 ? sharedImages : undefined, }, { providerId: params.providerId, @@ -1121,12 +1194,12 @@ export const llmClient = { }; const promptJournal = piSession; - const currentUser = [...authoritativeChat.messages] + const currentUser = [...generationChat.messages] .reverse() .find((message) => message.role === "user"); const priorVisibleMessages = currentUser - ? authoritativeChat.messages.filter((message) => message.id !== currentUser.id) - : authoritativeChat.messages; + ? generationChat.messages.filter((message) => message.id !== currentUser.id) + : generationChat.messages; const contentOverrides = new Map(); if (currentUser) { if ( @@ -1314,8 +1387,9 @@ export const llmClient = { const scheduleApproval = createScheduleApproval || editScheduleApproval; const workspaceApproval = permission === "ask" && APPROVAL_TOOL_NAMES.has(context.toolCall.name); + const disclosureApproval = DISCLOSURE_APPROVAL_TOOL_NAMES.has(context.toolCall.name); attendedScheduleApproval = scheduleApproval && attendedAssistant; - if (!scheduleApproval && !workspaceApproval) { + if (!scheduleApproval && !workspaceApproval && !disclosureApproval) { timeline.toolRunning(context.toolCall.id); return undefined; } @@ -1575,7 +1649,16 @@ export const llmClient = { endLoadMonitor(initialization, streamId, false); await computerUse?.close().catch(() => {}); if (initialization.cancelRequested || initialization.controller.signal.aborted) { - sendGeneration(streamId, "chat:done", { streamId, content: "" }); + await persistInitializationTerminal( + "cancelled", + initialization.cancellationOrigin, + ); + sendGeneration(streamId, "chat:done", { + streamId, + content: "", + cancelled: true, + cancellationOrigin: initialization.cancellationOrigin, + }); releaseGenerationSkillReservation(initialization); initializing.delete(streamId); initialization.removeOwnerInvalidation(); @@ -1583,6 +1666,7 @@ export const llmClient = { broadcastChatSettled(streamId, params.chatId, initialization.workspaceId, params.workspaceId); return false; } + await persistInitializationTerminal("failed"); releaseGenerationSkillReservation(initialization); initializing.delete(streamId); initialization.removeOwnerInvalidation(); @@ -1592,6 +1676,7 @@ export const llmClient = { } const agent = candidate; if (!agent || !piSession) { + await persistInitializationTerminal("failed"); endLoadMonitor(initialization, streamId, false); releaseGenerationSkillReservation(initialization); initializing.delete(streamId); @@ -1767,6 +1852,10 @@ export const llmClient = { active.set(streamId, activeGeneration); initializing.delete(streamId); if (initialization.cancelRequested || activeGeneration.cancelRequested) { + await persistInitializationTerminal( + "cancelled", + activeGeneration.cancellationOrigin, + ); await piTurnLease?.rollback().catch((error) => { logger.error( "pi", @@ -1777,7 +1866,12 @@ export const llmClient = { resetGenerationAgent(agent, streamId); endLoadMonitor(activeGeneration, streamId, false); await computerUse?.close().catch(() => {}); - sendGeneration(streamId, "chat:done", { streamId, content: "" }); + sendGeneration(streamId, "chat:done", { + streamId, + content: "", + cancelled: true, + cancellationOrigin: activeGeneration.cancellationOrigin, + }); releaseGenerationSkillReservation(activeGeneration); active.delete(streamId); activeGeneration.removeOwnerInvalidation(); diff --git a/main/services/models.test.ts b/main/services/models.test.ts index 72a70803..ef48844e 100644 --- a/main/services/models.test.ts +++ b/main/services/models.test.ts @@ -11,7 +11,14 @@ import { resolveProviderRuntimeLimits, resolveRuntimeLimits, } from "./models-catalog-core.js"; -import { discoverOllamaModels, normalizeProviderBaseUrl, testConnection } from "./models.js"; +import { + discoverOllamaModels, + MAX_DISCOVERED_MODELS, + MAX_MODEL_DISCOVERY_RESPONSE_BYTES, + normalizeProviderBaseUrl, + assertOnboardingTailnetBaseUrl, + testConnection, +} from "./models.js"; import { canonicalGoogleProvider } from "./google-provider.js"; const lmStudioProvider = { @@ -386,6 +393,142 @@ test("keyless Anthropic discovery omits x-api-key while retaining its protocol v ); }); +test("hosted onboarding discovery sends provider-native credential headers", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + const requests: Array<{ url: string; headers: HeadersInit | undefined }> = []; + globalThis.fetch = (async (input, init) => { + requests.push({ url: String(input), headers: init?.headers }); + return new Response(JSON.stringify({ data: [{ id: "supported-model" }] }), { + status: 200, + }); + }) as typeof fetch; + + await testConnection( + { + ...lmStudioProvider, + id: "openai", + baseUrl: "https://api.openai.com/v1", + }, + "openai-secret", + ); + await testConnection( + { + ...lmStudioProvider, + id: "anthropic", + kind: "anthropic", + baseUrl: "https://api.anthropic.com/v1", + }, + "anthropic-secret", + ); + + assert.deepEqual(requests, [ + { + url: "https://api.openai.com/v1/models", + headers: { Authorization: "Bearer openai-secret" }, + }, + { + url: "https://api.anthropic.com/v1/models", + headers: { + "x-api-key": "anthropic-secret", + "anthropic-version": "2023-06-01", + }, + }, + ]); +}); + +test("generic discovery ignores malformed and blank model identifiers", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + data: [ + { id: {} }, + { id: 42 }, + { id: " " }, + { id: " valid-model " }, + ], + }), + { status: 200 }, + )) as typeof fetch; + + const result = await testConnection( + { ...lmStudioProvider, id: "custom:hosted", baseUrl: "https://provider.example/v1" }, + null, + ); + assert.deepEqual(result.models, ["valid-model"]); + assert.equal(result.modelCount, 1); +}); + +test("credential-bearing discovery rejects redirects and never exposes upstream error bodies", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + globalThis.fetch = (async (_input, init) => { + assert.equal(init?.redirect, "error"); + return new Response('{"secret":"upstream-account-detail"}', { status: 401 }); + }) as typeof fetch; + + await assert.rejects( + testConnection( + { + ...lmStudioProvider, + id: "custom:hosted", + baseUrl: "https://provider.example/v1", + needsKey: true, + }, + "candidate-key", + ), + (error: unknown) => { + assert.match(String(error), /rejected those credentials/u); + assert.doesNotMatch(String(error), /upstream-account-detail|candidate-key/u); + return true; + }, + ); +}); + +test("model discovery bounds response bytes, model count, and identifier length", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + let oversized = true; + globalThis.fetch = (async () => { + if (oversized) { + return new Response("{}", { + status: 200, + headers: { "content-length": String(MAX_MODEL_DISCOVERY_RESPONSE_BYTES + 1) }, + }); + } + return new Response( + JSON.stringify({ + data: [ + ...Array.from({ length: MAX_DISCOVERED_MODELS + 25 }, (_, index) => ({ + id: `model-${index}`, + })), + { id: "x".repeat(300) }, + ], + }), + { status: 200 }, + ); + }) as typeof fetch; + + await assert.rejects(testConnection(lmStudioProvider, null), /catalog is too large/u); + oversized = false; + const result = await testConnection(lmStudioProvider, null); + assert.equal(result.models.length, MAX_DISCOVERED_MODELS); + assert.equal( + result.models.some((id) => id.length > 256), + false, + ); +}); + test("normalizes safe provider URLs and rejects credentials or request decorations", () => { assert.equal( normalizeProviderBaseUrl(" https://tailnet.example.ts.net/v1/// "), @@ -400,6 +543,45 @@ test("normalizes safe provider URLs and rejects credentials or request decoratio /query string/u, ); assert.throws(() => normalizeProviderBaseUrl("ftp://example.test/v1"), /HTTP or HTTPS/u); + for (const target of [ + "http://169.254.169.254/latest", + "http://169.254.170.2/credentials", + "http://metadata.google.internal/v1", + "http://[fe80::1]/v1", + "http://[::ffff:169.254.169.254]/v1", + ]) { + assert.throws(() => normalizeProviderBaseUrl(target), /metadata service/u); + } + assert.doesNotThrow(() => assertOnboardingTailnetBaseUrl("https://model.tailnet.ts.net/v1")); + assert.doesNotThrow(() => assertOnboardingTailnetBaseUrl("http://100.64.20.5:11434/v1")); + assert.throws( + () => assertOnboardingTailnetBaseUrl("http://foo.100.100.100.200.nip.io/v1"), + /Tailscale/u, + ); +}); + +test("transport failures never echo credential material", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + globalThis.fetch = (async (_input, init) => { + const authorization = new Headers(init?.headers).get("authorization") ?? "missing"; + throw new Error(`transport rejected ${authorization}`); + }) as typeof fetch; + const canary = "CANARY_PROVIDER_SECRET"; + await assert.rejects( + testConnection( + { ...lmStudioProvider, id: "custom:hosted", baseUrl: "https://provider.example/v1" }, + canary, + ), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal(error.message, "Couldn't reach the provider model endpoint."); + assert.doesNotMatch(error.message, new RegExp(canary, "u")); + return true; + }, + ); }); test("models.dev lookups retain unknown flags for unmatched model ids", () => { diff --git a/main/services/models.ts b/main/services/models.ts index e5330ed6..de74af65 100644 --- a/main/services/models.ts +++ b/main/services/models.ts @@ -68,6 +68,9 @@ export interface ConnectionTestResult extends DiscoveredModels { /** Keep a settings request responsive when a local or private server is offline. */ export const MODEL_DISCOVERY_TIMEOUT_MS = 10_000; const MAX_GOOGLE_MODEL_PAGES = 10; +export const MAX_MODEL_DISCOVERY_RESPONSE_BYTES = 1_048_576; +export const MAX_DISCOVERED_MODELS = 2_000; +export const MAX_DISCOVERED_MODEL_ID_LENGTH = 256; class ModelDiscoveryHttpError extends Error { constructor( @@ -78,6 +81,47 @@ class ModelDiscoveryHttpError extends Error { } } +function httpFailureMessage(status: number): string { + if (status === 401) return "The provider rejected those credentials."; + if (status === 403) return "The provider denied access to its model catalog."; + if (status === 429) return "The provider is rate limiting connection checks. Try again later."; + if (status === 404 || status === 405) return "The model catalog endpoint is not supported."; + if (status >= 500) return "The provider is temporarily unavailable."; + return `The provider could not list models (HTTP ${status}).`; +} + +async function boundedResponseText(response: Response): Promise { + const declared = Number(response.headers.get("content-length")); + if (Number.isFinite(declared) && declared > MAX_MODEL_DISCOVERY_RESPONSE_BYTES) { + throw new Error("The provider's model catalog is too large."); + } + if (!response.body) return ""; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let bytes = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > MAX_MODEL_DISCOVERY_RESPONSE_BYTES) { + await reader.cancel().catch(() => undefined); + throw new Error("The provider's model catalog is too large."); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(body); +} + /** * Validate connection URLs before they are persisted or used for discovery. * Credentials belong in the encrypted key store, never in a URL. @@ -96,6 +140,21 @@ export function normalizeProviderBaseUrl(value: string): string { if (!url.hostname) { throw new Error("Provider URL must include a host."); } + const hostname = url.hostname.toLowerCase().replace(/\.$/u, ""); + const literalMetadataTarget = + /^169\.254\./u.test(hostname) || + /^\[::ffff:(?:169\.254\.|a9fe:)/u.test(hostname) || + hostname === "100.100.100.200" || + hostname === "0.0.0.0" || + hostname === "[::]" || + /^\[fe[89ab][0-9a-f]:/u.test(hostname); + if ( + literalMetadataTarget || + hostname === "metadata.google.internal" || + hostname === "metadata" + ) { + throw new Error("Provider URL cannot target a host-local metadata service."); + } if (url.username || url.password) { throw new Error("Put credentials in the API key field, not the URL."); } @@ -106,6 +165,22 @@ export function normalizeProviderBaseUrl(value: string): string { return url.toString().replace(/\/$/u, ""); } +/** First-run Tailnet setup is intentionally narrower than arbitrary custom endpoints. */ +export function assertOnboardingTailnetBaseUrl(value: string): void { + const url = new URL(value); + const hostname = url.hostname.toLowerCase().replace(/\.$/u, ""); + const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(hostname); + const cgnat = + ipv4 !== null && + Number(ipv4[1]) === 100 && + Number(ipv4[2]) >= 64 && + Number(ipv4[2]) <= 127; + const tailscaleIpv6 = /^\[fd7a:115c:a1e0:/u.test(hostname); + if (!hostname.endsWith(".ts.net") && !cgnat && !tailscaleIpv6) { + throw new Error("Use a Tailscale .ts.net name or Tailnet IP address for this connection."); + } +} + function headersFor(provider: StoredProvider, apiKey: string | null): Record { if (provider.kind === "anthropic") { return { @@ -144,22 +219,35 @@ async function fetchJson( method: init.method ?? "GET", body: init.body, headers: init.body ? { ...headers, "content-type": "application/json" } : headers, - redirect: init.redirect, + redirect: init.redirect ?? "error", signal, }); if (!response.ok) { - const body = await response.text().catch(() => ""); - throw new ModelDiscoveryHttpError( - `Failed to list models: ${response.status} ${response.statusText}${body ? ` — ${body.slice(0, 200)}` : ""}`, - response.status, - ); + await response.body?.cancel().catch(() => undefined); + throw new ModelDiscoveryHttpError(httpFailureMessage(response.status), response.status); + } + const text = await boundedResponseText(response); + try { + return JSON.parse(text) as unknown; + } catch { + throw new Error("The provider returned an invalid model catalog."); } - return response.json() as Promise; } catch (error) { - if (signal.aborted) { + if (controller?.signal.aborted) { throw new Error(`Connection timed out after ${timeoutMs / 1000} seconds.`); } - throw error; + if (init.signal?.aborted) throw new Error("Connection check cancelled."); + if (error instanceof ModelDiscoveryHttpError) throw error; + if ( + error instanceof Error && + (error.message === "The provider's model catalog is too large." || + error.message === "The provider returned an invalid model catalog.") + ) { + throw error; + } + // Header/fetch errors can echo credential material. Renderer-facing + // callers receive only app-owned transport copy. + throw new Error("Couldn't reach the provider model endpoint."); } finally { if (timeout) clearTimeout(timeout); } @@ -294,9 +382,11 @@ function genericMetadata(entry: GenericModelEntry): ProviderModelMetadata { function normalizeDiscovery(entries: GenericModelEntry[]): DiscoveredModels { const metadataEntries: Array<[string, ProviderModelMetadata]> = []; - for (const entry of entries) { - const id = entry.id ?? entry.key ?? entry.name; - if (!id) continue; + for (const entry of entries.slice(0, MAX_DISCOVERED_MODELS)) { + const candidate = entry.id ?? entry.key ?? entry.name; + if (typeof candidate !== "string") continue; + const id = candidate.trim(); + if (!id || id.length > MAX_DISCOVERED_MODEL_ID_LENGTH) continue; metadataEntries.push([id, genericMetadata(entry)]); } const metadata = Object.fromEntries(metadataEntries); @@ -320,9 +410,9 @@ function parseLmStudioResponse(value: unknown): DiscoveredModels | null { .filter((value): value is Record => Boolean(value)); const metadataEntries: Array<[string, ProviderModelMetadata]> = []; let recommendedModel: string | undefined; - for (const entry of entries) { + for (const entry of entries.slice(0, MAX_DISCOVERED_MODELS)) { const key = typeof entry.key === "string" ? entry.key : undefined; - if (!key) continue; + if (!key || key.length > MAX_DISCOVERED_MODEL_ID_LENGTH) continue; const capabilities = capabilityFlags(entry.capabilities); const type = entry.type === "embedding" ? "embedding" : entry.type === "llm" ? "llm" : undefined; @@ -413,8 +503,12 @@ export async function discoverOllamaModels( const tagsResponse = object(tagsValue); if (!tagsResponse || !Array.isArray(tagsResponse.models)) return null; const tags = tagsResponse.models + .slice(0, MAX_DISCOVERED_MODELS) .map((value) => object(value) as OllamaTag | null) - .filter((value): value is OllamaTag => Boolean(value?.model ?? value?.name)); + .filter((value): value is OllamaTag => { + const id = value?.model ?? value?.name; + return Boolean(id && id.length <= MAX_DISCOVERED_MODEL_ID_LENGTH); + }); const rows = await mapWithConcurrency(tags, 4, async (tag) => { const id = tag.model ?? tag.name!; diff --git a/main/services/onboarding-provider-validation.test.ts b/main/services/onboarding-provider-validation.test.ts new file mode 100644 index 00000000..73457187 --- /dev/null +++ b/main/services/onboarding-provider-validation.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + normalizeOnboardingValidationProvider, + validateOnboardingProviderCredential, +} from "./onboarding-provider-validation.js"; +import type { StoredProvider } from "./types.js"; + +const provider: StoredProvider = { + id: "openai", + kind: "openai", + label: "OpenAI", + baseUrl: "https://api.openai.com/v1", + models: ["model-a", "model-b"], + needsKey: true, +}; + +test("Anthropic onboarding validation uses the versioned models endpoint", () => { + assert.equal( + normalizeOnboardingValidationProvider({ + ...provider, + id: "anthropic", + kind: "anthropic", + baseUrl: "https://api.anthropic.com", + }).baseUrl, + "https://api.anthropic.com/v1", + ); + assert.equal( + normalizeOnboardingValidationProvider({ + ...provider, + id: "anthropic", + kind: "anthropic", + baseUrl: "https://gateway.example/anthropic", + }).baseUrl, + "https://gateway.example/anthropic", + ); +}); + +test("onboarding validation commits only after the catalog proves a supported model", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + globalThis.fetch = (async () => + new Response(JSON.stringify({ data: [{ id: "model-b" }, { id: "unknown-model" }] }), { + status: 200, + })) as typeof fetch; + const committed: string[] = []; + + const usable = await validateOnboardingProviderCredential({ + provider, + apiKey: "validated-key", + installedModelIds: provider.models, + isCurrent: () => true, + commit: async (apiKey) => { + committed.push(apiKey); + }, + }); + + assert.deepEqual(usable, ["model-b", "unknown-model"]); + assert.deepEqual(committed, ["validated-key"]); +}); + +test("failed, unsupported, or stale validation never reaches the credential commit", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + let writes = 0; + const validate = (isCurrent = () => true) => + validateOnboardingProviderCredential({ + provider, + apiKey: "candidate-key", + installedModelIds: provider.models, + isCurrent, + commit: async () => { + writes += 1; + }, + }); + + globalThis.fetch = (async () => new Response("unauthorized", { status: 401 })) as typeof fetch; + await assert.rejects(validate(), /rejected those credentials/u); + + globalThis.fetch = (async () => + new Response(JSON.stringify({ data: [{ id: "unsupported-model" }] }), { + status: 200, + })) as typeof fetch; + await assert.rejects(validate(), /no supported chat models/u); + + globalThis.fetch = (async () => + new Response(JSON.stringify({ data: [{ id: "model-a" }] }), { status: 200 })) as typeof fetch; + await assert.rejects(validate(() => false), /no longer active/u); + assert.equal(writes, 0); +}); diff --git a/main/services/onboarding-provider-validation.ts b/main/services/onboarding-provider-validation.ts new file mode 100644 index 00000000..ea77cd88 --- /dev/null +++ b/main/services/onboarding-provider-validation.ts @@ -0,0 +1,44 @@ +import { testConnection } from "./models.js"; +import type { StoredProvider } from "./types.js"; + +interface OnboardingProviderValidationInput { + provider: StoredProvider; + apiKey: string; + installedModelIds: readonly string[]; + isCurrent: () => boolean; + commit: (apiKey: string) => Promise; +} + +/** Pi currently reports Anthropic's API origin without its versioned REST path. */ +export function normalizeOnboardingValidationProvider( + provider: StoredProvider, +): StoredProvider { + if ( + provider.id === "anthropic" && + /^https:\/\/api\.anthropic\.com\/?$/u.test(provider.baseUrl) + ) { + return { ...provider, baseUrl: "https://api.anthropic.com/v1" }; + } + return provider; +} + +/** Validate a non-generation catalog before replacing the stored credential. */ +export async function validateOnboardingProviderCredential({ + provider, + apiKey, + installedModelIds, + isCurrent, + commit, +}: OnboardingProviderValidationInput): Promise { + const result = await testConnection(normalizeOnboardingValidationProvider(provider), apiKey); + const accessible = new Set(result.models); + if (!installedModelIds.some((modelId) => accessible.has(modelId))) { + throw new Error("Credentials were accepted, but no supported chat models are available."); + } + if (!isCurrent()) throw new Error("The onboarding window is no longer active."); + await commit(apiKey); + // Return the authenticated service's complete bounded list. The caller + // refreshes Pi after commit and intersects against that new executable + // catalog, allowing models released after Aiden was packaged to appear. + return [...accessible]; +} diff --git a/main/services/onboarding-state-core.test.ts b/main/services/onboarding-state-core.test.ts new file mode 100644 index 00000000..d1871990 --- /dev/null +++ b/main/services/onboarding-state-core.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assertOnboardingCanComplete, + legacyOnboardingOutcome, + onboardingEvidenceProvider, + onboardingProgressState, + onboardingProviderReady, + reconcileOnboardingOutcome, +} from "./onboarding-state-core.js"; +import type { Provider } from "./types.js"; + +function provider(overrides: Partial = {}): Provider { + return { + id: "openai", + kind: "openai", + label: "OpenAI", + baseUrl: "https://api.openai.com/v1", + models: ["gpt-test"], + defaultModel: "gpt-test", + needsKey: true, + hasKey: true, + ...overrides, + }; +} + +test("provider readiness requires a chat model and the provider class's credential", () => { + assert.equal(onboardingProviderReady(provider()), true); + assert.equal(onboardingProviderReady(provider({ models: [] })), false); + assert.equal(onboardingProviderReady(provider({ hasKey: false })), false); + assert.equal( + onboardingProviderReady(provider({ needsKey: false, hasKey: false, models: ["local"] })), + true, + ); +}); + +test("an ambient or stored credential cannot skip validation after relaunch", () => { + const ambient = provider({ id: "groq", label: "Groq", models: ["static-model"] }); + assert.equal( + onboardingEvidenceProvider([ambient], { + version: 2, + outcome: "incomplete", + lastSatisfiedStep: "profile", + }), + undefined, + ); + assert.equal( + onboardingEvidenceProvider([ambient], { + version: 2, + outcome: "incomplete", + lastSatisfiedStep: "provider", + selectedProviderId: "groq", + })?.id, + "groq", + ); +}); + +test("legacy completion becomes deferred instead of lying about an unusable setup", () => { + assert.equal(legacyOnboardingOutcome(false, false), "incomplete"); + assert.equal(legacyOnboardingOutcome(true, false), "deferred"); + assert.equal(legacyOnboardingOutcome(true, true), "completed"); +}); + +test("progress is versioned and completion is readiness-gated", () => { + const ready = { + profileReady: true, + providerReady: true, + selectedProviderId: "openai", + }; + assert.deepEqual(onboardingProgressState("incomplete", ready), { + version: 2, + outcome: "incomplete", + lastSatisfiedStep: "provider", + selectedProviderId: "openai", + }); + assert.doesNotThrow(() => assertOnboardingCanComplete(ready)); + assert.throws( + () => assertOnboardingCanComplete({ ...ready, providerReady: false }), + /usable model provider/u, + ); +}); + +test("completed state reopens when authoritative setup readiness is lost", () => { + assert.equal( + reconcileOnboardingOutcome("completed", { profileReady: true, providerReady: false }), + "incomplete", + ); + assert.equal( + reconcileOnboardingOutcome("completed", { profileReady: true, providerReady: true }), + "completed", + ); + assert.equal( + reconcileOnboardingOutcome("deferred", { profileReady: true, providerReady: true }), + "deferred", + ); +}); diff --git a/main/services/onboarding-state-core.ts b/main/services/onboarding-state-core.ts new file mode 100644 index 00000000..ae635a76 --- /dev/null +++ b/main/services/onboarding-state-core.ts @@ -0,0 +1,64 @@ +import type { Provider } from "./types.js"; +import { + ONBOARDING_STATE_VERSION, + type OnboardingOutcome, + type OnboardingState, +} from "../../renderer/shared/onboarding.js"; + +export interface OnboardingReadiness { + profileReady: boolean; + providerReady: boolean; + selectedProviderId?: string; +} + +export function onboardingProviderReady(provider: Provider): boolean { + return provider.models.length > 0 && (provider.needsKey ? provider.hasKey === true : true); +} + +/** Structural auth alone (including ambient env keys) is not first-run evidence. */ +export function onboardingEvidenceProvider( + providers: readonly Provider[], + state: OnboardingState | null, +): Provider | undefined { + const providerStepSatisfied = + state?.lastSatisfiedStep === "provider" || state?.lastSatisfiedStep === "tour"; + if (!providerStepSatisfied || !state.selectedProviderId) return undefined; + return providers.find( + (provider) => provider.id === state.selectedProviderId && onboardingProviderReady(provider), + ); +} + +export function legacyOnboardingOutcome( + legacyComplete: boolean, + setupReady: boolean, +): OnboardingOutcome { + if (!legacyComplete) return "incomplete"; + return setupReady ? "completed" : "deferred"; +} + +export function onboardingProgressState( + outcome: OnboardingOutcome, + ready: OnboardingReadiness, +): OnboardingState { + return { + version: ONBOARDING_STATE_VERSION, + outcome, + lastSatisfiedStep: !ready.profileReady ? "none" : ready.providerReady ? "provider" : "profile", + ...(ready.selectedProviderId ? { selectedProviderId: ready.selectedProviderId } : {}), + }; +} + +export function assertOnboardingCanComplete(ready: OnboardingReadiness): void { + if (!ready.profileReady || !ready.providerReady) { + throw new Error("Finish your profile and connect a usable model provider first."); + } +} + +export function reconcileOnboardingOutcome( + outcome: OnboardingOutcome, + ready: OnboardingReadiness, +): OnboardingOutcome { + return outcome === "completed" && (!ready.profileReady || !ready.providerReady) + ? "incomplete" + : outcome; +} diff --git a/main/services/onboarding-state.ts b/main/services/onboarding-state.ts new file mode 100644 index 00000000..190f4275 --- /dev/null +++ b/main/services/onboarding-state.ts @@ -0,0 +1,175 @@ +import { configStore } from "./config-store.js"; +import { listConfiguredProviders } from "./provider-list-main.js"; +import { + ONBOARDING_STATE_VERSION, + parseOnboardingState, + type OnboardingOutcome, + type OnboardingSnapshot, + type OnboardingState, +} from "../../renderer/shared/onboarding.js"; +import { + assertOnboardingCanComplete, + legacyOnboardingOutcome, + onboardingEvidenceProvider, + onboardingProgressState, + onboardingProviderReady, + reconcileOnboardingOutcome, +} from "./onboarding-state-core.js"; + +async function readiness(): Promise<{ + profileReady: boolean; + providerReady: boolean; + selectedProviderId?: string; +}> { + const [providers, settings] = await Promise.all([ + listConfiguredProviders(), + configStore.getSettings(), + ]); + const progress = parseOnboardingState(settings.onboarding); + // Static Pi catalogs and ambient credentials are not validation evidence. + // A provider becomes onboarding-ready only after the renderer completed one + // of the guarded validation/discovery flows and main recorded that exact ID. + const selected = onboardingEvidenceProvider(providers, progress); + return { + profileReady: + progress?.lastSatisfiedStep !== undefined && + progress.lastSatisfiedStep !== "none" && + typeof settings.profileName === "string" && + settings.profileName.trim().length > 0, + providerReady: selected !== undefined, + ...(selected ? { selectedProviderId: selected.id } : {}), + }; +} + +async function persist( + state: OnboardingState, + isCurrent: () => boolean = () => true, +): Promise { + if (!isCurrent()) throw new Error("The onboarding window is no longer active."); + const settings = await configStore.setSettings({ onboarding: state }); + const saved = parseOnboardingState(settings.onboarding); + if (!saved) throw new Error("Aiden couldn't save onboarding progress."); + return saved; +} + +export async function getOnboardingSnapshot( + legacyComplete = false, + isCurrent: () => boolean = () => true, +): Promise { + const [settings, ready] = await Promise.all([configStore.getSettings(), readiness()]); + let state = parseOnboardingState(settings.onboarding); + if (!state) { + // Preserve existing installs without trusting the legacy renderer marker as + // proof of readiness. An unusable legacy setup becomes deferred, not complete. + const migratedReady = { + ...ready, + profileReady: + legacyComplete && + typeof settings.profileName === "string" && + settings.profileName.trim().length > 0, + }; + state = await persist( + onboardingProgressState( + legacyOnboardingOutcome( + legacyComplete, + migratedReady.profileReady && migratedReady.providerReady, + ), + migratedReady, + ), + isCurrent, + ); + return { + ...state, + profileReady: migratedReady.profileReady, + providerReady: migratedReady.providerReady, + }; + } + const reconciledOutcome = reconcileOnboardingOutcome(state.outcome, ready); + if (reconciledOutcome !== state.outcome) { + state = await persist(onboardingProgressState(reconciledOutcome, ready), isCurrent); + } + return { ...state, profileReady: ready.profileReady, providerReady: ready.providerReady }; +} + +export async function setOnboardingProgress( + step: "profile" | "provider", + selectedProviderId?: string, + isCurrent: () => boolean = () => true, +): Promise { + const settings = await configStore.getSettings(); + const current = parseOnboardingState(settings.onboarding); + const profileConfigured = + typeof settings.profileName === "string" && settings.profileName.trim().length > 0; + if (!profileConfigured) throw new Error("Choose a profile name before continuing."); + if (step === "provider" && (!current || current.lastSatisfiedStep === "none")) { + throw new Error("Finish the profile step before connecting a model provider."); + } + const ready = await readiness(); + if (step === "provider") { + if (!selectedProviderId) throw new Error("A ready provider must be selected."); + const providers = await listConfiguredProviders(); + if ( + !providers.some( + (provider) => provider.id === selectedProviderId && onboardingProviderReady(provider), + ) + ) { + throw new Error("The selected model provider is not ready."); + } + } + const state = await persist({ + version: ONBOARDING_STATE_VERSION, + outcome: "incomplete", + lastSatisfiedStep: + step === "provider" || + current?.lastSatisfiedStep === "provider" || + current?.lastSatisfiedStep === "tour" + ? "provider" + : "profile", + ...((selectedProviderId ?? current?.selectedProviderId) + ? { selectedProviderId: selectedProviderId ?? current?.selectedProviderId } + : {}), + }, isCurrent); + return { + ...state, + profileReady: true, + providerReady: step === "provider" ? true : ready.providerReady, + }; +} + +export async function setOnboardingOutcome( + outcome: Exclude, + selectedProviderId?: string, + isCurrent: () => boolean = () => true, +): Promise { + if (outcome === "incomplete") { + const ready = await readiness(); + const state = await persist(onboardingProgressState("incomplete", ready), isCurrent); + return { ...state, profileReady: ready.profileReady, providerReady: ready.providerReady }; + } + + const ready = await readiness(); + if (outcome === "completed") assertOnboardingCanComplete(ready); + if ( + outcome === "completed" && + selectedProviderId && + selectedProviderId !== ready.selectedProviderId + ) { + const providers = await listConfiguredProviders(); + if ( + !providers.some( + (provider) => provider.id === selectedProviderId && onboardingProviderReady(provider), + ) + ) { + throw new Error("The selected model provider is not ready."); + } + } + const state = await persist( + { + ...onboardingProgressState(outcome, ready), + ...(selectedProviderId ? { selectedProviderId } : {}), + ...(outcome === "completed" ? { lastSatisfiedStep: "tour" as const } : {}), + }, + isCurrent, + ); + return { ...state, profileReady: ready.profileReady, providerReady: ready.providerReady }; +} diff --git a/main/services/pi-catalog-refresh.ts b/main/services/pi-catalog-refresh.ts new file mode 100644 index 00000000..e9173aa2 --- /dev/null +++ b/main/services/pi-catalog-refresh.ts @@ -0,0 +1,138 @@ +import type { + CredentialStore, + Models, + ProviderModelsStore, + Provider, +} from "@earendil-works/pi-ai"; +import { + isPiRemoteCatalogCacheFresh, + isPiRemoteCatalogProvider, +} from "./pi-remote-catalog.js"; + +export interface RefreshPiCatalogsOptions { + models: Models; + credentials: CredentialStore; + providerModelsStore: (providerId: string) => ProviderModelsStore; + providerIds?: readonly string[]; + force?: boolean; + signal?: AbortSignal; +} + +export interface RefreshPiCatalogsResult { + aborted: boolean; + errors: ReadonlyMap; +} + +export interface ProjectedPiCatalogRefreshError { + providerId: string; + message: string; +} + +export function projectPiCatalogRefreshErrors( + errors: ReadonlyMap, +): ProjectedPiCatalogRefreshError[] { + return [...errors.keys()].slice(0, 32).map((providerId) => ({ + providerId: providerId.replace(/[^a-zA-Z0-9._:-]/gu, "").slice(0, 128) || "provider", + // Never project upstream bodies or nested authentication failures across IPC. + message: "Catalog refresh failed. Cached models were kept.", + })); +} + +/** Return only stale Aiden pi.dev overlays; provider-owned catalogs keep their own refresh policy. */ +export async function staleCatalogProviderIds( + providers: readonly Provider[], + providerModelsStore: (providerId: string) => ProviderModelsStore, +): Promise { + const results = await Promise.all(providers.map(async (provider) => { + if (!provider.refreshModels || !isPiRemoteCatalogProvider(provider)) return undefined; + try { + const entry = await providerModelsStore(provider.id).read(); + return isPiRemoteCatalogCacheFresh(provider, entry) ? undefined : provider.id; + } catch { + return provider.id; + } + })); + return results.filter((providerId): providerId is string => providerId !== undefined); +} + +function abortError(): Error { + const error = new Error("Model catalog refresh was cancelled."); + error.name = "AbortError"; + return error; +} + +async function raceWithAbort(promise: Promise, signal?: AbortSignal): Promise { + if (!signal) return promise; + if (signal.aborted) throw abortError(); + return new Promise((resolve, reject) => { + const abort = () => reject(abortError()); + signal.addEventListener("abort", abort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener("abort", abort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", abort); + reject(error); + }, + ); + }); +} + +/** + * Backports provider-scoped catalog refresh to Aiden's pinned Pi runtime. + * Pi 0.80 refreshes every dynamic provider; setup needs isolation so one + * unrelated provider cannot make a newly configured provider appear broken. + */ +export async function refreshPiCatalogs({ + models, + credentials, + providerModelsStore, + providerIds, + force = true, + signal, +}: RefreshPiCatalogsOptions): Promise { + if (providerIds === undefined) { + const task = models.refresh({ force, signal }); + try { + return await raceWithAbort(task, signal); + } catch (error) { + if (signal?.aborted) return { aborted: true, errors: new Map() }; + throw error; + } + } + + const errors = new Map(); + await Promise.all( + [...new Set(providerIds)].map(async (providerId) => { + const provider = models.getProvider(providerId); + if (!provider?.refreshModels) return; + try { + const store = providerModelsStore(providerId); + if (!force && isPiRemoteCatalogProvider(provider)) { + const entry = await raceWithAbort(store.read(), signal); + if (isPiRemoteCatalogCacheFresh(provider, entry)) return; + } + // checkAuth is explicitly non-refreshing for OAuth. Catalog refresh + // must never outlive its timeout while secretly rotating credentials. + const auth = await raceWithAbort(models.checkAuth(providerId), signal); + if (!auth || signal?.aborted) return; + const credential = await raceWithAbort(credentials.read(providerId), signal); + await raceWithAbort(provider.refreshModels({ + credential, + store, + allowNetwork: true, + force, + signal, + }), signal); + } catch (error) { + errors.set( + providerId, + error instanceof Error ? error : new Error("Unknown model catalog refresh error."), + ); + } + }), + ); + return { aborted: signal?.aborted ?? false, errors }; +} diff --git a/main/services/pi-model-metadata.ts b/main/services/pi-model-metadata.ts new file mode 100644 index 00000000..d560fa7b --- /dev/null +++ b/main/services/pi-model-metadata.ts @@ -0,0 +1,60 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import { + anthropicThinkingCanDisable, + anthropicThinkingLevelsForModel, +} from "../../renderer/shared/anthropic-thinking.js"; +import { + googleThinkingCanDisable, + googleThinkingLevelsForModel, +} from "../../renderer/shared/google-thinking.js"; +import { isGenerationThinkingLevel } from "../../renderer/shared/generation-thinking.js"; +import type { ProviderModelMetadata } from "./types.js"; + +const PI_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; + +/** + * Pi treats an absent ordinary-level mapping as native support, an explicit + * null as unsupported, and requires an explicit mapping for xhigh/max. + * Keep Aiden's UI and runtime on that same contract while the Pi package is pinned. + */ +export function piThinkingLevelsForModel( + model: Pick, "reasoning" | "thinkingLevelMap">, +): ProviderModelMetadata["thinkingLevels"] { + if (!model.reasoning) return ["off"]; + return PI_THINKING_LEVELS.filter((level) => { + const mapped = model.thinkingLevelMap?.[level]; + if (mapped === null) return false; + if (level === "xhigh" || level === "max") return mapped !== undefined; + return true; + }).filter(isGenerationThinkingLevel); +} + +/** Project Pi's executable model contract into renderer and remote-client metadata. */ +export function piModelMetadataFor(providerId: string, model: Model): ProviderModelMetadata { + const thinking = + providerId === "anthropic" + ? { + thinkingLevels: anthropicThinkingLevelsForModel(model), + thinkingCanDisable: anthropicThinkingCanDisable(model), + } + : providerId === "google" + ? { + thinkingLevels: googleThinkingLevelsForModel(model), + thinkingCanDisable: googleThinkingCanDisable(model), + } + : model.reasoning + ? { + thinkingLevels: piThinkingLevelsForModel(model), + thinkingCanDisable: model.thinkingLevelMap?.off !== null, + } + : {}; + return { + source: "provider", + name: model.name, + type: "llm", + vision: model.input.includes("image"), + reasoning: model.reasoning, + ...thinking, + contextLength: model.contextWindow, + }; +} diff --git a/main/services/pi-models-store.ts b/main/services/pi-models-store.ts index 209c1481..0877b81e 100644 --- a/main/services/pi-models-store.ts +++ b/main/services/pi-models-store.ts @@ -1,21 +1,91 @@ -import type { ModelsStore, ModelsStoreEntry } from "@earendil-works/pi-ai"; +import type { + ModelsStore, + ModelsStoreEntry, + ProviderModelsStore, +} from "@earendil-works/pi-ai"; import { DataStore } from "./data-store.js"; +import { parsePiRemoteCatalog } from "./pi-remote-catalog.js"; + +const MAX_STORE_BYTES = 32 * 1024 * 1024; +const MAX_STORED_PROVIDERS = 256; + +interface PersistedModelsStoreEntry extends ModelsStoreEntry { + lastModified?: number; + etag?: string; +} interface PiModelsDocument { version: 1; - entries: Record; + entries: Record; } -const store = new DataStore("pi-provider-models.json", { - version: 1, - entries: {}, -}); +function object(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} function validProviderId(providerId: string): boolean { return /^[a-z0-9][a-z0-9._:-]{0,127}$/iu.test(providerId); } +function timestamp(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 + ? value + : undefined; +} + +export function normalizePiModelsDocument(value: unknown): PiModelsDocument { + const document = object(value); + const source = object(document?.entries) ?? {}; + const entries: Record = {}; + for (const [providerId, rawEntry] of Object.entries(source).slice(0, MAX_STORED_PROVIDERS)) { + if (!validProviderId(providerId)) continue; + const entry = object(rawEntry); + if (!entry || !Array.isArray(entry.models)) continue; + let models; + try { + models = parsePiRemoteCatalog(providerId, entry.models, { allowEmptyBaseUrl: true }); + } catch { + continue; + } + const checkedAt = timestamp(entry.checkedAt); + const lastModified = timestamp(entry.lastModified); + const etag = + typeof entry.etag === "string" && entry.etag.length <= 512 && !/[\r\n]/u.test(entry.etag) + ? entry.etag + : undefined; + entries[providerId] = { + models, + ...(checkedAt === undefined ? {} : { checkedAt }), + ...(lastModified === undefined ? {} : { lastModified }), + ...(etag === undefined ? {} : { etag }), + }; + } + return { version: 1, entries }; +} + +function safePiModelsDocument(value: unknown): boolean { + try { + return JSON.stringify(value) === JSON.stringify(normalizePiModelsDocument(value)); + } catch { + return false; + } +} + +const store = new DataStore( + "pi-provider-models.json", + { version: 1, entries: {} }, + undefined, + { + maxBytes: MAX_STORE_BYTES, + fileMode: 0o600, + normalize: normalizePiModelsDocument, + isSafe: safePiModelsDocument, + }, +); + function clone(entry: ModelsStoreEntry): ModelsStoreEntry { return structuredClone(entry); } @@ -47,3 +117,12 @@ export const piModelsStore: ModelsStore = { }); }, }; + +/** Provider-scoped view used for explicit single-provider refreshes on pinned Pi. */ +export function piProviderModelsStore(providerId: string): ProviderModelsStore { + return { + read: () => piModelsStore.read(providerId), + write: (entry) => piModelsStore.write(providerId, entry), + delete: () => piModelsStore.delete(providerId), + }; +} diff --git a/main/services/pi-provider-compatibility.ts b/main/services/pi-provider-compatibility.ts new file mode 100644 index 00000000..7047c539 --- /dev/null +++ b/main/services/pi-provider-compatibility.ts @@ -0,0 +1,30 @@ +import type { Api, Model, Provider, ProviderStreams } from "@earendil-works/pi-ai"; +import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy"; + +/** Add reviewed transports to an older pinned Pi provider without rebuilding its auth contract. */ +export function withProviderStreamOverrides( + provider: Provider, + overrides: Partial>, +): Provider { + const streamsFor = (model: Model) => overrides[model.api]; + return { + ...provider, + stream: (model, context, options) => + streamsFor(model)?.stream(model, context, options) ?? provider.stream(model, context, options), + streamSimple: (model, context, options) => + streamsFor(model)?.streamSimple(model, context, options) ?? + provider.streamSimple(model, context, options), + }; +} + +/** Pi 0.80 predates OpenCode Go's Responses-backed models published by pi.dev. */ +export function withAidenPiCompatibility(provider: Provider): Provider { + if (provider.id !== "opencode-go") return provider; + return withProviderStreamOverrides(provider, { + "openai-responses": openAIResponsesApi(), + }); +} + +export function additionalAidenPiApis(providerId: string): readonly string[] { + return providerId === "opencode-go" ? ["openai-responses"] : []; +} diff --git a/main/services/pi-remote-catalog.test.ts b/main/services/pi-remote-catalog.test.ts new file mode 100644 index 00000000..70d75945 --- /dev/null +++ b/main/services/pi-remote-catalog.test.ts @@ -0,0 +1,580 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + InMemoryCredentialStore, + type Api, + type Credential, + type CredentialStore, + type Model, + type ModelsStoreEntry, + type ProviderModelsStore, +} from "@earendil-works/pi-ai"; +import { builtinModels, builtinProviders } from "@earendil-works/pi-ai/providers/all"; + +import { + projectPiCatalogRefreshErrors, + refreshPiCatalogs, + staleCatalogProviderIds, +} from "./pi-catalog-refresh.js"; +import { + AIDEN_PI_CATALOG_USER_AGENT, + parsePiRemoteCatalog, + PI_REMOTE_CATALOG_REFRESH_INTERVAL_MS, + withPiRemoteCatalog, +} from "./pi-remote-catalog.js"; +import { normalizePiModelsDocument } from "./pi-models-store.js"; +import { concentrateProvider } from "./concentrate-provider.js"; +import { + additionalAidenPiApis, + withAidenPiCompatibility, + withProviderStreamOverrides, +} from "./pi-provider-compatibility.js"; +import { piModelMetadataFor } from "./pi-model-metadata.js"; + +function opencodeGoProvider() { + const provider = builtinProviders().find((entry) => entry.id === "opencode-go"); + assert.ok(provider, "pinned Pi must expose OpenCode Go"); + return provider; +} + +function oxAlphaModel(): Model { + const template = opencodeGoProvider().getModels()[0]; + assert.ok(template); + return { + ...template, + id: "ox-alpha-free", + name: "Ox Alpha Free (Unlimited)", + provider: "opencode-go", + reasoning: true, + input: ["text", "image"], + thinkingLevelMap: { + off: null, + minimal: null, + low: "low", + medium: null, + high: "high", + xhigh: null, + max: "max", + }, + contextWindow: 1_000_000, + maxTokens: 131_072, + }; +} + +function memoryProviderStore(initial?: ModelsStoreEntry): ProviderModelsStore & { + snapshot(): ModelsStoreEntry | undefined; +} { + let entry = initial === undefined ? undefined : structuredClone(initial); + return { + read: async () => (entry === undefined ? undefined : structuredClone(entry)), + write: async (next) => { + entry = structuredClone(next); + }, + delete: async () => { + entry = undefined; + }, + snapshot: () => (entry === undefined ? undefined : structuredClone(entry)), + }; +} + +test("remote catalog parser accepts Pi's keyed response and pins provider identity", () => { + const model = oxAlphaModel(); + const parsed = parsePiRemoteCatalog("opencode-go", { + [model.id]: { ...model, provider: "attacker-controlled" }, + }); + assert.equal(parsed.length, 1); + assert.equal(parsed[0]?.id, "ox-alpha-free"); + assert.equal(parsed[0]?.provider, "opencode-go"); + assert.throws(() => parsePiRemoteCatalog("opencode-go", { models: "wrong" })); +}); + +test("Ox Alpha publishes its native low, high, and max thinking contract", () => { + assert.deepEqual(piModelMetadataFor("opencode-go", oxAlphaModel()), { + source: "provider", + name: "Ox Alpha Free (Unlimited)", + type: "llm", + vision: true, + reasoning: true, + thinkingLevels: ["low", "high", "max"], + thinkingCanDisable: false, + contextLength: 1_000_000, + }); +}); + +test("provider stream overrides dispatch a newly cataloged API without altering other APIs", () => { + const original = opencodeGoProvider(); + const marker = {}; + let selected = ""; + const provider = withProviderStreamOverrides(original, { + "openai-responses": { + stream: (() => { selected = "responses"; return marker; }) as never, + streamSimple: (() => { selected = "responses-simple"; return marker; }) as never, + }, + }); + const responses = { ...oxAlphaModel(), api: "openai-responses" as const }; + assert.equal(provider.stream(responses, {} as never, {}), marker); + assert.equal(selected, "responses"); + assert.equal(provider.streamSimple(responses, {} as never, {}), marker); + assert.equal(selected, "responses-simple"); +}); + +test("OpenCode Go overlay publishes ox-alpha without sending provider credentials", async () => { + const store = memoryProviderStore(); + const requests: Array<{ url: string; headers: Headers }> = []; + const provider = withPiRemoteCatalog(opencodeGoProvider(), { + fetchImpl: async (input, init) => { + requests.push({ url: String(input), headers: new Headers(init?.headers) }); + return new Response(JSON.stringify({ "ox-alpha-free": oxAlphaModel() }), { + status: 200, + headers: { + etag: '"catalog-v1"', + "last-modified": "Thu, 20 Aug 2026 16:00:00 GMT", + }, + }); + }, + now: () => Date.parse("2026-08-20T16:01:00Z"), + }); + + await provider.refreshModels?.({ + credential: { type: "api_key", key: "must-not-leak" }, + store, + allowNetwork: true, + force: true, + signal: new AbortController().signal, + }); + + assert.equal(requests.length, 1); + assert.equal(requests[0]?.url, "https://pi.dev/api/models/providers/opencode-go"); + assert.equal(requests[0]?.headers.get("authorization"), null); + assert.equal(requests[0]?.headers.get("x-api-key"), null); + assert.equal(requests[0]?.headers.get("cookie"), null); + assert.equal(requests[0]?.headers.get("user-agent"), AIDEN_PI_CATALOG_USER_AGENT); + assert.ok(provider.getModels().some((model) => model.id === "ox-alpha-free")); + assert.ok(store.snapshot()?.models.some((model) => model.id === "ox-alpha-free")); +}); + +test("cached overlays hydrate offline and honor freshness unless force refreshed", async () => { + const checkedAt = Date.parse("2026-08-20T16:01:00Z"); + const store = memoryProviderStore({ + models: [oxAlphaModel()], + checkedAt, + lastModified: Date.parse("2026-08-20T16:00:00Z"), + } as ModelsStoreEntry); + let fetches = 0; + const provider = withPiRemoteCatalog(opencodeGoProvider(), { + fetchImpl: async () => { + fetches += 1; + return new Response(null, { status: 304 }); + }, + now: () => checkedAt + PI_REMOTE_CATALOG_REFRESH_INTERVAL_MS - 1, + }); + + await provider.refreshModels?.({ store, allowNetwork: false }); + assert.ok(provider.getModels().some((model) => model.id === "ox-alpha-free")); + + await provider.refreshModels?.({ store, allowNetwork: true }); + assert.equal(fetches, 0); + + await provider.refreshModels?.({ store, allowNetwork: true, force: true }); + assert.equal(fetches, 1); + assert.ok(provider.getModels().some((model) => model.id === "ox-alpha-free")); +}); + +test("fresh empty and negative catalog results do not refetch on every launch", async () => { + const checkedAt = Date.parse("2026-08-22T16:00:00Z"); + for (const firstResponse of [ + () => Response.json({}, { headers: { "last-modified": "Sat, 22 Aug 2026 15:59:00 GMT" } }), + () => new Response(null, { status: 404 }), + () => new Response(null, { status: 501 }), + ]) { + const store = memoryProviderStore(); + let fetches = 0; + const provider = withPiRemoteCatalog(opencodeGoProvider(), { + fetchImpl: async () => { + fetches += 1; + return firstResponse(); + }, + now: () => checkedAt, + }); + await provider.refreshModels!({ store, allowNetwork: true }); + await provider.refreshModels!({ store, allowNetwork: true }); + assert.equal(fetches, 1); + assert.deepEqual(store.snapshot()?.models, []); + } +}); + +test("conditional refresh sends only the safe ETag validator and keeps a 304 overlay", async () => { + const store = memoryProviderStore({ + models: [oxAlphaModel()], + checkedAt: Date.parse("2026-08-20T16:01:00Z"), + lastModified: Date.parse("2026-08-20T16:00:00Z"), + etag: '"catalog-v1"', + } as ModelsStoreEntry); + let headers = new Headers(); + const provider = withPiRemoteCatalog(opencodeGoProvider(), { + fetchImpl: async (_input, init) => { + headers = new Headers(init?.headers); + return new Response(null, { status: 304 }); + }, + }); + await provider.refreshModels!({ store, allowNetwork: true, force: true }); + assert.equal(headers.get("if-none-match"), '"catalog-v1"'); + assert.equal(headers.get("if-modified-since"), null); + assert.ok(provider.getModels().some((model) => model.id === "ox-alpha-free")); +}); + +test("configured provider refresh is isolated and publishes before its caller continues", async () => { + const credentials: CredentialStore = new InMemoryCredentialStore(); + const stores = new Map>(); + const storeFor = (providerId: string) => { + let store = stores.get(providerId); + if (!store) { + store = memoryProviderStore(); + stores.set(providerId, store); + } + return store; + }; + const models = builtinModels({ credentials }); + const original = models.getProvider("opencode-go"); + assert.ok(original); + const requested: string[] = []; + const compatible = withAidenPiCompatibility(original); + models.setProvider( + withPiRemoteCatalog(compatible, { + supportedApis: additionalAidenPiApis(original.id), + fetchImpl: async (input) => { + requested.push(String(input)); + return Response.json( + { + "ox-alpha-free": oxAlphaModel(), + "gpt-5.6-luna": { + ...oxAlphaModel(), + id: "gpt-5.6-luna", + name: "GPT-5.6 Luna", + api: "openai-responses", + }, + }, + { headers: { "last-modified": "Thu, 20 Aug 2026 16:00:00 GMT" } }, + ); + }, + }), + ); + await credentials.modify( + "opencode-go", + async () => ({ type: "api_key", key: "configured" }) satisfies Credential, + ); + const result = await refreshPiCatalogs({ + models, + credentials, + providerModelsStore: storeFor, + providerIds: ["opencode-go"], + }); + + assert.equal(result.aborted, false); + assert.equal(result.errors.size, 0); + assert.deepEqual(requested, ["https://pi.dev/api/models/providers/opencode-go"]); + assert.ok(models.getModel("opencode-go", "ox-alpha-free")); + assert.ok(models.getModel("opencode-go", "gpt-5.6-luna")); +}); + +test("catalog parser preserves OpenRouter's documented unknown-price sentinel", () => { + const model = oxAlphaModel(); + const parsed = parsePiRemoteCatalog("openrouter", [{ + ...model, + provider: "openrouter", + cost: { input: -1_000_000, output: -1_000_000, cacheRead: 0, cacheWrite: 0 }, + }]); + assert.equal(parsed[0]?.cost.input, -1_000_000); + assert.throws(() => parsePiRemoteCatalog("openrouter", [{ + ...model, + cost: { ...model.cost, input: -1 }, + }])); +}); + +test("catalog parser rejects duplicate ids, unsafe origins, credential headers, and unsupported APIs", () => { + const model = oxAlphaModel(); + assert.throws(() => parsePiRemoteCatalog("opencode-go", [model, model])); + assert.throws(() => parsePiRemoteCatalog("opencode-go", [{ ...model, baseUrl: "http://example.test/v1" }])); + assert.throws(() => parsePiRemoteCatalog("opencode-go", [{ ...model, headers: { Authorization: "secret" } }])); + assert.throws(() => parsePiRemoteCatalog("opencode-go", [{ ...model, headers: { "Key": "value" } }])); + assert.throws(() => parsePiRemoteCatalog("opencode-go", [{ + ...model, + compat: { nested: { constructor: "poison" } }, + }])); + assert.throws(() => parsePiRemoteCatalog("opencode-go", [{ ...model, api: "unknown-api" }], { + allowedApis: new Set(["openai-responses"]), + })); + assert.throws(() => parsePiRemoteCatalog("opencode-go", [{ ...model, baseUrl: "https://evil.example/v1" }], { + allowedOrigins: new Set([new URL(model.baseUrl).origin]), + })); +}); + +test("last-known-good catalog survives 404, 501, server errors, and malformed payloads", async () => { + const checkedAt = Date.parse("2026-08-20T16:01:00Z"); + for (const response of [ + () => new Response(null, { status: 404 }), + () => new Response(null, { status: 501 }), + () => new Response(null, { status: 500 }), + () => Response.json({ models: "invalid" }, { status: 200 }), + ]) { + const store = memoryProviderStore({ + models: [oxAlphaModel()], + checkedAt, + lastModified: Date.parse("2026-08-20T16:00:00Z"), + etag: '"old"', + } as ModelsStoreEntry); + const provider = withPiRemoteCatalog(opencodeGoProvider(), { + fetchImpl: async () => response(), + now: () => checkedAt + PI_REMOTE_CATALOG_REFRESH_INTERVAL_MS, + }); + await provider.refreshModels?.({ store, allowNetwork: false }); + await provider.refreshModels?.({ store, allowNetwork: true, force: true }).catch(() => undefined); + assert.ok(provider.getModels().some((entry) => entry.id === "ox-alpha-free")); + assert.ok(store.snapshot()?.models.some((entry) => entry.id === "ox-alpha-free")); + } +}); + +test("minimum-version and oversized responses fail closed without replacing the cache", async () => { + const store = memoryProviderStore({ + models: [oxAlphaModel()], + checkedAt: 1, + lastModified: Date.parse("2026-08-20T16:00:00Z"), + } as ModelsStoreEntry); + for (const response of [ + () => Response.json({ "ox-alpha-free": oxAlphaModel() }, { + headers: { "x-pi-model-catalog-minimum-version": "999.0.0" }, + }), + () => new Response("x".repeat(5 * 1024 * 1024 + 1), { + headers: { "content-type": "application/json" }, + }), + () => Response.json({ "ox-alpha-free": oxAlphaModel() }, { + headers: { "last-modified": "Thu, 01 Jan 2026 00:00:00 GMT" }, + }), + ]) { + const provider = withPiRemoteCatalog(opencodeGoProvider(), { + fetchImpl: async () => response(), + }); + await assert.rejects(provider.refreshModels!({ store, allowNetwork: true, force: true })); + assert.ok(store.snapshot()?.models.some((entry) => entry.id === "ox-alpha-free")); + } +}); + +test("an older valid catalog cannot roll back a newer cached generation", async () => { + const cached = oxAlphaModel(); + const store = memoryProviderStore({ + models: [cached], + checkedAt: Date.parse("2026-08-20T16:01:00Z"), + lastModified: Date.parse("2026-08-20T16:00:00Z"), + } as ModelsStoreEntry); + const provider = withPiRemoteCatalog(opencodeGoProvider(), { + fetchImpl: async () => Response.json({ + replacement: { ...cached, id: "replacement", name: "Older replacement" }, + }, { headers: { "last-modified": "Wed, 19 Aug 2026 16:00:00 GMT" } }), + }); + + await provider.refreshModels?.({ store, allowNetwork: false }); + await assert.rejects( + provider.refreshModels!({ store, allowNetwork: true, force: true }), + /older than the cached generation/u, + ); + assert.deepEqual(store.snapshot()?.models.map((model) => model.id), [cached.id]); + assert.deepEqual(provider.getModels().filter((model) => model.id === cached.id).map((model) => model.id), [cached.id]); +}); + +test("a future generation cannot poison the cache and a current catalog recovers it", async () => { + const now = Date.parse("2026-08-22T18:00:00Z"); + const poisoned = { ...oxAlphaModel(), id: "future-poison", name: "Future poison" }; + const store = memoryProviderStore({ + models: [poisoned], + checkedAt: now, + lastModified: Date.parse("9999-12-31T23:59:59Z"), + etag: '"future"', + } as ModelsStoreEntry); + const recovered = { ...oxAlphaModel(), id: "recovered", name: "Recovered" }; + const provider = withPiRemoteCatalog(opencodeGoProvider(), { + now: () => now, + fetchImpl: async () => Response.json({ recovered }, { + headers: { "last-modified": "Sat, 22 Aug 2026 17:59:00 GMT" }, + }), + }); + + await provider.refreshModels!({ store, allowNetwork: false }); + assert.equal(provider.getModels().some((model) => model.id === poisoned.id), false); + await provider.refreshModels!({ store, allowNetwork: true, force: true }); + assert.deepEqual(store.snapshot()?.models.map((model) => model.id), [recovered.id]); + assert.ok(provider.getModels().some((model) => model.id === recovered.id)); + + const futureResponse = withPiRemoteCatalog(opencodeGoProvider(), { + now: () => now, + fetchImpl: async () => Response.json({ poisoned }, { + headers: { "last-modified": "Fri, 31 Dec 9999 23:59:59 GMT" }, + }), + }); + await assert.rejects( + futureResponse.refreshModels!({ store: memoryProviderStore(), allowNetwork: true, force: true }), + /invalid future generation timestamp/u, + ); +}); + +test("clock rollback retains the last-known-good catalog and its downgrade fence", async () => { + const acceptedAt = Date.parse("2026-08-22T18:00:00Z"); + const cached = { ...oxAlphaModel(), id: "newer-cached", name: "Newer cached" }; + const store = memoryProviderStore({ + models: [cached], + checkedAt: acceptedAt, + lastModified: Date.parse("2026-08-22T17:59:00Z"), + } as ModelsStoreEntry); + const older = { ...oxAlphaModel(), id: "older-remote", name: "Older remote" }; + const provider = withPiRemoteCatalog(opencodeGoProvider(), { + now: () => Date.parse("2026-08-22T17:00:00Z"), + fetchImpl: async () => Response.json({ older }, { + headers: { "last-modified": "Sat, 01 Aug 2026 12:00:00 GMT" }, + }), + }); + + await provider.refreshModels!({ store, allowNetwork: false }); + assert.ok(provider.getModels().some((model) => model.id === cached.id)); + await assert.rejects( + provider.refreshModels!({ store, allowNetwork: true, force: true }), + /older than the cached generation/u, + ); + assert.deepEqual(store.snapshot()?.models.map((model) => model.id), [cached.id]); +}); + +test("clock-rollback revalidation retains its acceptance boundary across restart", async () => { + const acceptedAt = Date.parse("2026-08-22T18:00:00Z"); + const rolledBackNow = Date.parse("2026-08-22T17:00:00Z"); + const cached = { ...oxAlphaModel(), id: "rollback-cached", name: "Rollback cached" }; + for (const response of [ + () => new Response(null, { status: 304 }), + () => new Response(null, { status: 404 }), + () => new Response(null, { status: 501 }), + ]) { + const store = memoryProviderStore({ + models: [cached], + checkedAt: acceptedAt, + lastModified: Date.parse("2026-08-22T17:59:00Z"), + etag: '"accepted"', + } as ModelsStoreEntry); + const provider = withPiRemoteCatalog(opencodeGoProvider(), { + now: () => rolledBackNow, + fetchImpl: async () => response(), + }); + await provider.refreshModels!({ store, allowNetwork: true, force: true }); + assert.equal(store.snapshot()?.checkedAt, acceptedAt); + + const restarted = withPiRemoteCatalog(opencodeGoProvider(), { now: () => rolledBackNow }); + await restarted.refreshModels!({ store, allowNetwork: false }); + assert.ok(restarted.getModels().some((model) => model.id === cached.id)); + } +}); + +test("renderer catalog errors contain only bounded app-owned copy", () => { + const secret = "sk-upstream-token-canary"; + const projected = projectPiCatalogRefreshErrors(new Map([ + ["radius\n', + '', + '', + ]) { + assert.throws( + () => decodeProviderArtworkSource({ + name: "icon.svg", + dataBase64: Buffer.from(source).toString("base64"), + }), + /cannot contain scripts or external resources/u, + ); + } +}); + +test("provider artwork rejects malformed base64 and oversized PNG dimensions before decoding", () => { + assert.throws( + () => decodeProviderArtworkSource({ name: "icon.svg", dataBase64: "not base64!" }), + /valid/u, + ); + const pngHeader = Buffer.alloc(24); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(pngHeader); + Buffer.from("IHDR", "ascii").copy(pngHeader, 12); + pngHeader.writeUInt32BE(8_193, 16); + pngHeader.writeUInt32BE(64, 20); + assert.throws( + () => decodeProviderArtworkSource({ name: "icon.png", dataBase64: pngHeader.toString("base64") }), + /dimensions/u, + ); +}); diff --git a/main/services/provider-artwork-core.ts b/main/services/provider-artwork-core.ts new file mode 100644 index 00000000..3a6ef8df --- /dev/null +++ b/main/services/provider-artwork-core.ts @@ -0,0 +1,50 @@ +export const PROVIDER_ARTWORK_MAX_SOURCE_BYTES = 512 * 1024; + +export function decodeProviderArtworkSource(value: unknown): { + bytes: Buffer; + kind: "png" | "svg"; + safeSvg?: string; + pixelSize?: { width: number; height: number }; +} { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Choose a PNG or SVG provider icon."); + } + const record = value as Record; + const name = typeof record.name === "string" ? record.name.toLowerCase() : ""; + const dataBase64 = typeof record.dataBase64 === "string" ? record.dataBase64 : ""; + if (!dataBase64 || dataBase64.length > Math.ceil(PROVIDER_ARTWORK_MAX_SOURCE_BYTES / 3) * 4 + 4) { + throw new Error("Provider artwork must be 512 KB or smaller."); + } + if (dataBase64.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(dataBase64)) { + throw new Error("Choose a valid .png or .svg file."); + } + const bytes = Buffer.from(dataBase64, "base64"); + if (bytes.length === 0 || bytes.length > PROVIDER_ARTWORK_MAX_SOURCE_BYTES) { + throw new Error("Provider artwork must be 512 KB or smaller."); + } + if ( + name.endsWith(".png") && + bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + ) { + if (bytes.length < 24 || bytes.subarray(12, 16).toString("ascii") !== "IHDR") { + throw new Error("The PNG file is invalid."); + } + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + if (width === 0 || height === 0 || width > 8_192 || height > 8_192) { + throw new Error("Provider artwork dimensions are invalid."); + } + return { bytes, kind: "png", pixelSize: { width, height } }; + } + if (!name.endsWith(".svg")) throw new Error("Choose a valid .png or .svg file."); + const source = bytes.toString("utf8").trim(); + if (source.includes("\uFFFD") || !/)/iu.test(source)) { + throw new Error("The SVG file is invalid."); + } + if ( + / 8_192 || source.pixelSize.height > 8_192) + ) { + throw new Error("Provider artwork dimensions are invalid."); + } + const image = source.kind === "png" + ? nativeImage.createFromBuffer(source.bytes, { scaleFactor: 1 }) + : nativeImage.createFromDataURL( + `data:image/svg+xml;base64,${Buffer.from(source.safeSvg!, "utf8").toString("base64")}`, + ); + if (image.isEmpty()) throw new Error("Aiden could not decode that provider icon."); + const size = image.getSize(); + if (size.width <= 0 || size.height <= 0 || size.width > 8_192 || size.height > 8_192) { + throw new Error("Provider artwork dimensions are invalid."); + } + const scale = Math.min(1, TARGET_EDGE / Math.max(size.width, size.height)); + const normalized = scale < 1 + ? image.resize({ + width: Math.max(1, Math.round(size.width * scale)), + height: Math.max(1, Math.round(size.height * scale)), + quality: "best", + }) + : image; + const png = normalized.toPNG(); + if (png.length === 0 || png.length > PROVIDER_ARTWORK_MAX_PNG_BYTES) { + throw new Error("The normalized provider icon is too complex. Choose a simpler image."); + } + return { mimeType: "image/png", dataBase64: png.toString("base64") }; +} diff --git a/main/services/provider-auth-flow-core.test.ts b/main/services/provider-auth-flow-core.test.ts index b40b4ffe..f9b2739c 100644 --- a/main/services/provider-auth-flow-core.test.ts +++ b/main/services/provider-auth-flow-core.test.ts @@ -106,15 +106,18 @@ function makeCoordinator(options: { diagnostics?: unknown[]; createId?: () => string; cleanupTimeout?: number; + openExternal?: (url: string) => Promise; }) { const opened = options.opened ?? []; const diagnostics = options.diagnostics ?? []; const providerBackend = backend(options.login); return new ProviderAuthFlowCoordinator({ backendFor: () => providerBackend, - openExternal: async (url) => { - opened.push(url); - }, + openExternal: + options.openExternal ?? + (async (url) => { + opened.push(url); + }), diagnostic: (event) => diagnostics.push(event), flowTimeoutMs: options.timeout, authCleanupTimeoutMs: options.cleanupTimeout, @@ -207,6 +210,63 @@ test("browser flow forwards only prompts/events and opens the validated auth URL assert.equal(messages(owner, "providers:auth:error").length, 0); }); +test("a committed credential reports catalog refresh failure as a nonfatal warning", async () => { + const owner = new FakeOwner(1); + const providerBackend: ProviderAuthBackend = { + snapshot: async () => snapshot(false), + authenticate: async () => ({ token: "main-process-only" }), + commitCredential: async () => ({ warning: "Credentials saved; retry catalog refresh." }), + logout: async () => undefined, + }; + const coordinator = new ProviderAuthFlowCoordinator({ + backendFor: () => providerBackend, + openExternal: async () => undefined, + createId: ids(PROMPT_A), + }); + + coordinator.start(owner, request()); + await waitForMessages(owner, "providers:auth:done"); + assert.deepEqual(messages(owner, "providers:auth:done"), [{ + flowId: FLOW_A, + providerId: PROVIDER_ID, + cancelled: false, + warning: "Credentials saved; retry catalog refresh.", + }]); + assert.equal(messages(owner, "providers:auth:error").length, 0); +}); + +test("browser launch failure keeps a safe manual sign-in link available", async () => { + const owner = new FakeOwner(1); + const finish = deferred(); + const coordinator = makeCoordinator({ + openExternal: async () => { + throw new Error("private operating system detail"); + }, + login: async (interaction) => { + interaction.notify({ + type: "auth_url", + url: "https://auth.openai.com/oauth/authorize?state=temporary", + }); + return finish.promise; + }, + }); + + assert.deepEqual(coordinator.start(owner, request()), { started: true }); + await waitForMessages(owner, "providers:auth:event", 2); + const events = messages(owner, "providers:auth:event"); + assert.equal(events[0]?.type, "auth_url"); + assert.deepEqual(events[1], { + flowId: FLOW_A, + providerId: PROVIDER_ID, + type: "browser_open_failed", + url: "https://auth.openai.com/oauth/authorize?state=temporary", + message: "Aiden couldn't open the browser automatically. Use the sign-in link below.", + }); + assert.doesNotMatch(JSON.stringify(events), /private operating system detail/u); + finish.resolve({}); + await waitForMessages(owner, "providers:auth:done"); +}); + test("Pi-native API-key setup preserves provider-owned multi-field prompts", async () => { const owner = new FakeOwner(1); const seen: string[] = []; @@ -315,6 +375,27 @@ test("non-HTTPS authorization URLs are blocked before opening or crossing IPC", ); }); +test("Codex authorization rejects an unexpected HTTPS host before opening or crossing IPC", async () => { + const opened: string[] = []; + const owner = new FakeOwner(1); + const coordinator = makeCoordinator({ + opened, + login: async (interaction) => { + interaction.notify({ type: "auth_url", url: "https://login.example/looks-safe" }); + return {}; + }, + }); + + coordinator.start(owner, request()); + await waitForMessages(owner, "providers:auth:error"); + assert.deepEqual(opened, []); + assert.equal(messages(owner, "providers:auth:event").length, 0); + assert.equal( + messages(owner, "providers:auth:error")[0].code, + "sign_in_failed", + ); +}); + test("a different renderer cannot answer or cancel an owned flow", async () => { const owner = new FakeOwner(1); const attacker = new FakeOwner(2); diff --git a/main/services/provider-auth-flow-core.ts b/main/services/provider-auth-flow-core.ts index ebf87fbd..e1529362 100644 --- a/main/services/provider-auth-flow-core.ts +++ b/main/services/provider-auth-flow-core.ts @@ -68,6 +68,13 @@ export type ProviderAuthEventDto = intervalSeconds?: number; expiresInSeconds?: number; } + | { + flowId: string; + providerId: string; + type: "browser_open_failed"; + url: string; + message: string; + } | { flowId: string; providerId: string; @@ -79,6 +86,7 @@ export interface ProviderAuthDoneDto { flowId: string; providerId: string; cancelled: boolean; + warning?: string; } export type ProviderAuthErrorCode = @@ -118,7 +126,7 @@ export interface ProviderAuthOwner { export interface ProviderAuthBackend { snapshot(): Promise; authenticate(interaction: AuthInteraction): Promise; - commitCredential(credential: unknown): Promise; + commitCredential(credential: unknown): Promise; logout(): Promise; } @@ -281,7 +289,7 @@ function finiteNonNegative(value: number | undefined): number | undefined { return value !== undefined && Number.isFinite(value) && value >= 0 ? value : undefined; } -function externalHttpsUrl(value: string): string { +function externalHttpsUrl(value: string, providerId?: string): string { if (value.length === 0 || value.length > MAX_EXTERNAL_URL_LENGTH) { throw new Error("Provider authentication supplied an invalid external URL."); } @@ -289,6 +297,9 @@ function externalHttpsUrl(value: string): string { if (url.protocol !== "https:" || url.username || url.password || !url.hostname) { throw new Error("Provider authentication supplied an invalid external URL."); } + if (providerId === OPENAI_CODEX_PROVIDER_ID && url.hostname !== "auth.openai.com") { + throw new Error("ChatGPT authentication supplied an unexpected external host."); + } return url.toString(); } @@ -686,8 +697,8 @@ export class ProviderAuthFlowCoordinator { clearTimeout(session.timeout); session.timeout = undefined; } - await session.backend.commitCredential(outcome.credential); - this.sendDone(session, false); + const committed = await session.backend.commitCredential(outcome.credential); + this.sendDone(session, false, committed?.warning); } catch (error) { if (session.abortController.signal.aborted && !session.timedOut) { this.sendDone(session, true); @@ -803,7 +814,7 @@ export class ProviderAuthFlowCoordinator { if (!this.isCurrentSession(session)) return; let dto: ProviderAuthEventDto; if (event.type === "auth_url") { - const url = externalHttpsUrl(event.url); + const url = externalHttpsUrl(event.url, session.providerId); dto = { flowId: session.flowId, providerId: session.providerId, @@ -814,9 +825,9 @@ export class ProviderAuthFlowCoordinator { ? "Complete sign-in in your browser." : boundedCopy(event.instructions, "Complete setup in your browser."), }; - this.openExternal(session.providerId, url); + this.openExternal(session, url); } else if (event.type === "device_code") { - const verificationUri = externalHttpsUrl(event.verificationUri); + const verificationUri = externalHttpsUrl(event.verificationUri, session.providerId); if (event.userCode.length === 0 || event.userCode.length > 256) { throw new Error("Provider authentication supplied an invalid device code."); } @@ -829,7 +840,7 @@ export class ProviderAuthFlowCoordinator { intervalSeconds: finiteNonNegative(event.intervalSeconds), expiresInSeconds: finiteNonNegative(event.expiresInSeconds), }; - this.openExternal(session.providerId, verificationUri); + this.openExternal(session, verificationUri); } else if (event.type === "info") { // The established Codex flow deliberately redacts provider text. For // generic Pi setup, preserve instructional links but never forward URL @@ -870,9 +881,17 @@ export class ProviderAuthFlowCoordinator { if (!this.safeSend(session, "providers:auth:event", dto)) this.abortSession(session); } - private openExternal(providerId: string, url: string): void { + private openExternal(session: AuthSession, url: string): void { void this.dependencies.openExternal(url).catch((error: unknown) => { - this.reportDiagnostic("open_external", providerId, error); + this.reportDiagnostic("open_external", session.providerId, error); + if (!this.isCurrentSession(session)) return; + this.safeSend(session, "providers:auth:event", { + flowId: session.flowId, + providerId: session.providerId, + type: "browser_open_failed", + url, + message: "Aiden couldn't open the browser automatically. Use the sign-in link below.", + } satisfies ProviderAuthEventDto); }); } @@ -928,11 +947,12 @@ export class ProviderAuthFlowCoordinator { if (session.pendingPrompt === pending) session.pendingPrompt = undefined; } - private sendDone(session: AuthSession, cancelled: boolean): void { + private sendDone(session: AuthSession, cancelled: boolean, warning?: string): void { this.safeSend(session, "providers:auth:done", { flowId: session.flowId, providerId: session.providerId, cancelled, + ...(warning ? { warning: boundedCopy(warning, "Provider catalog refresh failed.", 512) } : {}), } satisfies ProviderAuthDoneDto); } diff --git a/main/services/provider-credential-rotation-core.test.ts b/main/services/provider-credential-rotation-core.test.ts index d2a513e3..762b1964 100644 --- a/main/services/provider-credential-rotation-core.test.ts +++ b/main/services/provider-credential-rotation-core.test.ts @@ -122,6 +122,10 @@ test("an offline endpoint edit quarantines rather than rebinds the old key", () test("direct provider key writes share the durable rotation-journal bound", () => { assert.equal(normalizeProviderCredentialInput(" key "), "key"); assert.equal(normalizeProviderCredentialInput(" "), null); + assert.throws( + () => normalizeProviderCredentialInput("CANARY_SECRET\nSECOND"), + /control characters/u, + ); assert.throws( () => normalizeProviderCredentialInput("x".repeat(1_048_577)), /cannot exceed 1048576 characters/u, diff --git a/main/services/provider-credential-rotation-core.ts b/main/services/provider-credential-rotation-core.ts index 0c1763ac..5982dd78 100644 --- a/main/services/provider-credential-rotation-core.ts +++ b/main/services/provider-credential-rotation-core.ts @@ -109,6 +109,12 @@ export function assertProviderCredentialLength(value: string): void { export function normalizeProviderCredentialInput(value: unknown): string | null { if (typeof value !== "string" || !value.trim()) return null; + if ([...value].some((character) => { + const code = character.charCodeAt(0); + return code <= 31 || code === 127; + })) { + throw new Error("Provider credentials cannot contain control characters."); + } const key = value.trim(); assertProviderCredentialLength(key); return key; diff --git a/main/services/provider-list-main.ts b/main/services/provider-list-main.ts new file mode 100644 index 00000000..af04173e --- /dev/null +++ b/main/services/provider-list-main.ts @@ -0,0 +1,19 @@ +import { logger } from "../platform.js"; +import { listProvidersWithLegacyPiCredentialMigration } from "./legacy-pi-credential-migration.js"; +import { mergeCodexProvider } from "./provider-list-core.js"; +import { providerRegistry } from "./provider-registry.js"; + +/** Authoritative configured provider list shared by Electron and Remote Access. */ +export async function listConfiguredProviders() { + const customProviders = await listProvidersWithLegacyPiCredentialMigration(); + const providers = [ + ...(await providerRegistry.listBuiltinProviders()), + ...customProviders.filter((provider) => !providerRegistry.isBuiltinProvider(provider.id)), + ]; + try { + return mergeCodexProvider(providers, await providerRegistry.codex.snapshot()); + } catch { + logger.warn("providers", "ChatGPT / Codex status was unavailable while listing providers."); + return mergeCodexProvider(providers, null); + } +} diff --git a/main/services/provider-model-info-core.test.ts b/main/services/provider-model-info-core.test.ts index 5949e935..84279108 100644 --- a/main/services/provider-model-info-core.test.ts +++ b/main/services/provider-model-info-core.test.ts @@ -88,3 +88,54 @@ test("retains the existing catalog path for non-Codex providers", async () => { assert.equal(result.name, "Catalog claude-example"); assert.deepEqual(requests, [{ providerId: "anthropic", modelIds: ["claude-example"] }]); }); + +test("uses provider-owned metadata when a newly published model is absent from bundled catalogs", async () => { + const service = createProviderModelInfo({ + modelsCatalog: { + info: async (_provider, modelId) => ({ + id: modelId, + vision: false, + toolCall: false, + reasoning: false, + openWeights: false, + metadataSource: "fallback", + matched: false, + }), + infoMany: async (_provider, modelIds) => Object.fromEntries(modelIds.map((modelId) => [modelId, { + id: modelId, + metadataSource: "fallback" as const, + matched: false, + }])), + }, + legacyProvider: async (providerId) => ({ + id: providerId, + baseUrl: "https://opencode.ai/zen/v1", + modelMetadata: { + "ox-alpha-free": { + source: "provider", + name: "Ox Alpha Free (Unlimited)", + type: "llm", + vision: true, + reasoning: true, + contextLength: 1_000_000, + }, + }, + }), + codexModelInfo: () => undefined, + }); + assert.deepEqual(await service.info("opencode-go", "ox-alpha-free"), { + id: "ox-alpha-free", + name: "Ox Alpha Free (Unlimited)", + vision: true, + toolCall: false, + reasoning: true, + openWeights: false, + modelType: "llm", + parameterCount: undefined, + format: undefined, + contextLength: 1_000_000, + inputModalities: ["text", "image"], + metadataSource: "provider", + matched: true, + }); +}); diff --git a/main/services/provider-model-info-core.ts b/main/services/provider-model-info-core.ts index d7eb718b..cab50b50 100644 --- a/main/services/provider-model-info-core.ts +++ b/main/services/provider-model-info-core.ts @@ -1,6 +1,6 @@ import { OPENAI_CODEX_BASE_URL, OPENAI_CODEX_PROVIDER_ID } from "./codex-provider.js"; import type { ModelCatalogProvider } from "./models-catalog-core.js"; -import type { ModelInfo } from "./types.js"; +import type { ModelInfo, ProviderModelMetadata, StoredProvider } from "./types.js"; interface ModelCatalogReader { info(provider: ModelCatalogProvider, modelId: string): Promise; @@ -9,10 +9,39 @@ interface ModelCatalogReader { interface ProviderModelInfoDependencies { modelsCatalog: ModelCatalogReader; - legacyProvider(providerId: string): Promise; + legacyProvider( + providerId: string, + ): Promise>; codexModelInfo(modelId: string): ModelInfo | undefined; } +function providerInfo(modelId: string, metadata: ProviderModelMetadata | undefined): ModelInfo | undefined { + if (!metadata) return undefined; + return { + id: modelId, + name: metadata.name, + vision: metadata.vision ?? false, + toolCall: metadata.toolCall ?? false, + reasoning: metadata.reasoning ?? false, + openWeights: false, + modelType: metadata.type, + parameterCount: metadata.parameterCount, + format: metadata.format, + contextLength: metadata.contextLength, + inputModalities: metadata.vision ? ["text", "image"] : ["text"], + metadataSource: "provider", + matched: true, + }; +} + +function withProviderFallback( + modelId: string, + catalog: ModelInfo, + metadata: ProviderModelMetadata | undefined, +): ModelInfo { + return catalog.matched ? catalog : (providerInfo(modelId, metadata) ?? catalog); +} + const CODEX_CATALOG_PROVIDER: ModelCatalogProvider = { id: OPENAI_CODEX_PROVIDER_ID, baseUrl: OPENAI_CODEX_BASE_URL, @@ -50,10 +79,9 @@ export function createProviderModelInfo(dependencies: ProviderModelInfoDependenc return { async info(providerId: string, modelId: string): Promise { if (providerId !== OPENAI_CODEX_PROVIDER_ID) { - return dependencies.modelsCatalog.info( - await dependencies.legacyProvider(providerId), - modelId, - ); + const provider = await dependencies.legacyProvider(providerId); + const catalog = await dependencies.modelsCatalog.info(provider, modelId); + return withProviderFallback(modelId, catalog, provider.modelMetadata?.[modelId]); } const catalog = await dependencies.modelsCatalog.info(CODEX_CATALOG_PROVIDER, modelId); return mergeCodexModelInfo(modelId, dependencies.codexModelInfo(modelId), catalog); @@ -61,9 +89,17 @@ export function createProviderModelInfo(dependencies: ProviderModelInfoDependenc async infoMany(providerId: string, modelIds: string[]): Promise> { if (providerId !== OPENAI_CODEX_PROVIDER_ID) { - return dependencies.modelsCatalog.infoMany( - await dependencies.legacyProvider(providerId), - modelIds, + const provider = await dependencies.legacyProvider(providerId); + const catalog = await dependencies.modelsCatalog.infoMany(provider, modelIds); + return Object.fromEntries( + modelIds.map((modelId) => [ + modelId, + withProviderFallback( + modelId, + catalog[modelId] ?? unmatched(modelId), + provider.modelMetadata?.[modelId], + ), + ]), ); } const catalog = await dependencies.modelsCatalog.infoMany(CODEX_CATALOG_PROVIDER, modelIds); diff --git a/main/services/provider-registry.ts b/main/services/provider-registry.ts index 19d80026..658246eb 100644 --- a/main/services/provider-registry.ts +++ b/main/services/provider-registry.ts @@ -1,4 +1,4 @@ -import { builtinModels } from "@earendil-works/pi-ai/providers/all"; +import { builtinProviders } from "@earendil-works/pi-ai/providers/all"; import type { Api, AuthInteraction, @@ -8,23 +8,26 @@ import type { Model, Models, Provider as PiProvider, + ProviderModelsStore, ProviderStreams, } from "@earendil-works/pi-ai"; +import { createModels } from "@earendil-works/pi-ai"; import { CodexProviderService, OPENAI_CODEX_PROVIDER_ID } from "./codex-provider.js"; import { piCredentialStore } from "./pi-credential-store.js"; -import { piModelsStore } from "./pi-models-store.js"; +import { piModelsStore, piProviderModelsStore } from "./pi-models-store.js"; import { isCustomProviderId } from "./custom-provider-id.js"; import { secrets } from "./secrets.js"; -import type { Provider, ProviderModelMetadata, StoredProvider } from "./types.js"; -import { - anthropicThinkingCanDisable, - anthropicThinkingLevelsForModel, -} from "../../renderer/shared/anthropic-thinking.js"; -import { - googleThinkingCanDisable, - googleThinkingLevelsForModel, -} from "../../renderer/shared/google-thinking.js"; +import type { Provider, StoredProvider } from "./types.js"; import type { ProviderAuthBackend, ProviderLogoutBackend } from "./provider-auth-flow-core.js"; +import { registerAidenBuiltinProviders } from "./concentrate-provider.js"; +import { validateOnboardingProviderCredential } from "./onboarding-provider-validation.js"; +import { withPiRemoteCatalog } from "./pi-remote-catalog.js"; +import { refreshPiCatalogs, staleCatalogProviderIds } from "./pi-catalog-refresh.js"; +import { + additionalAidenPiApis, + withAidenPiCompatibility, +} from "./pi-provider-compatibility.js"; +import { piModelMetadataFor } from "./pi-model-metadata.js"; /** IDs used by Aiden before Pi became the provider authority. */ const LEGACY_API_KEY_PROVIDER_IDS: Readonly> = { @@ -35,28 +38,18 @@ const LEGACY_API_KEY_PROVIDER_IDS: Readonly> = { moonshot: "moonshotai", }; -function metadataFor(providerId: string, model: Model): ProviderModelMetadata { - const thinking = - providerId === "anthropic" - ? { - thinkingLevels: anthropicThinkingLevelsForModel(model), - thinkingCanDisable: anthropicThinkingCanDisable(model), - } - : providerId === "google" - ? { - thinkingLevels: googleThinkingLevelsForModel(model), - thinkingCanDisable: googleThinkingCanDisable(model), - } - : {}; - return { - source: "provider", - name: model.name, - type: "llm", - vision: model.input.includes("image"), - reasoning: model.reasoning, - ...thinking, - contextLength: model.contextWindow, - }; +function catalogRefreshWarning(errors: ReadonlyMap): string | undefined { + if (errors.size === 0) return undefined; + // Provider refresh errors can contain upstream response text. Keep that in + // main-process diagnostics; the renderer only needs the affected catalog + // names and a recovery action. + const affectedProviders = [...errors.keys()] + .slice(0, 3) + .map((providerId) => providerId.replace(/[^a-zA-Z0-9._-]/gu, "").slice(0, 64)) + .filter(Boolean) + .join(", "); + const affectedCopy = affectedProviders ? ` Affected: ${affectedProviders}.` : ""; + return `Credentials were saved, but the model catalog could not refresh. Cached models are still available. Retry in Provider Settings.${affectedCopy}`; } function builtinProviderRecord( @@ -73,7 +66,7 @@ function builtinProviderRecord( baseUrl: provider.baseUrl ?? "", models: modelIds, modelMetadata: Object.fromEntries( - models.map((model) => [model.id, metadataFor(provider.id, model)]), + models.map((model) => [model.id, piModelMetadataFor(provider.id, model)]), ), defaultModel: modelIds[0], // Pi owns the exact auth semantics (including ambient credentials and @@ -93,6 +86,8 @@ export class ProviderRegistry { constructor( readonly models: Models, private readonly credentials: CredentialStore, + private readonly providerModelsStore: (providerId: string) => ProviderModelsStore = + piProviderModelsStore, ) { this.codex = new CodexProviderService(models, credentials); } @@ -196,6 +191,12 @@ export class ProviderRegistry { authenticate: (interaction: AuthInteraction) => auth.login!(interaction), commitCredential: async (credential: unknown) => { await this.credentials.modify(providerId, async () => credential as Credential); + // Credential setup is an explicit network action. Publish this + // provider's current Pi catalog before reporting setup complete so + // newly released models appear immediately on Mac and paired clients. + const errors = await this.refreshBuiltinCatalogs([providerId]); + const warning = catalogRefreshWarning(errors); + return warning ? { warning } : undefined; }, logout: () => this.credentials.delete(providerId), }; @@ -224,6 +225,57 @@ export class ProviderRegistry { }; } + /** + * Validate first-run OpenAI/Anthropic API keys with their authenticated, + * non-generation model catalogs before replacing any stored credential. + * Other Pi providers keep their provider-owned setup flows until they have an + * explicitly documented non-billable validation strategy. + */ + async validateAndStoreOnboardingApiKey( + providerId: "openai" | "anthropic", + key: string, + isCurrent: () => boolean, + ): Promise<{ provider: Provider; catalogWarning?: string }> { + const provider = this.models.getProvider(providerId); + if (!provider) throw new Error("This provider is unavailable in the installed catalog."); + const draft = { + ...builtinProviderRecord(provider), + kind: providerId === "anthropic" ? ("anthropic" as const) : ("openai" as const), + }; + if (!draft.baseUrl) throw new Error("This provider does not expose a validation endpoint."); + const installedModels = provider.getModels(); + const usableModelIds = await validateOnboardingProviderCredential({ + provider: draft, + apiKey: key, + installedModelIds: installedModels.map((model) => model.id), + isCurrent, + commit: async (apiKey) => { + await this.credentials.modify(providerId, async () => { + if (!isCurrent()) throw new Error("The onboarding window is no longer active."); + return { type: "api_key", key: apiKey }; + }); + }, + }); + const refreshErrors = await this.refreshBuiltinCatalogs([providerId]); + const refreshedModels = await this.models.getAvailable(providerId); + const accessible = new Set(usableModelIds); + const usableModels = refreshedModels.filter((model) => accessible.has(model.id)); + const configuredProvider: Provider = { + ...builtinProviderRecord(provider, usableModels), + hasKey: true, + canLogout: true, + authMethods: [ + { + type: "api_key", + label: provider.auth.apiKey?.name ?? "API key", + canLogin: Boolean(provider.auth.apiKey?.login), + }, + ], + }; + const catalogWarning = catalogRefreshWarning(refreshErrors); + return { provider: configuredProvider, ...(catalogWarning ? { catalogWarning } : {}) }; + } + /** Restore durable dynamic catalogs before exposing any Pi snapshot. */ async ensureBuiltinCatalogs(): Promise { if (!this.catalogHydration) { @@ -236,14 +288,32 @@ export class ProviderRegistry { } /** Refresh Pi-owned dynamic catalogs after valid setup or an explicit user action. */ - async refreshBuiltinCatalogs(): Promise> { + async refreshBuiltinCatalogs( + providerIds?: readonly string[], + force = true, + ): Promise> { await this.ensureBuiltinCatalogs(); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 15_000); timeout.unref?.(); try { - const result = await this.models.refresh({ force: true, signal: controller.signal }); - return result.errors; + const result = await refreshPiCatalogs({ + models: this.models, + credentials: this.credentials, + providerModelsStore: this.providerModelsStore, + // Launch revalidation is a stale-only pass over Aiden's cached Pi + // overlays. Do not invoke Radius' separate gateway discovery on every + // renderer launch. An explicit force refresh retains Pi's full behavior. + providerIds: providerIds ?? (force + ? undefined + : await staleCatalogProviderIds(this.models.getProviders(), this.providerModelsStore)), + force, + signal: controller.signal, + }); + if (!result.aborted) return result.errors; + const errors = new Map(result.errors); + errors.set(providerIds?.[0] ?? "provider catalogs", new Error("Model refresh timed out.")); + return errors; } finally { clearTimeout(timeout); } @@ -346,6 +416,21 @@ export class ProviderRegistry { } export const providerRegistry = new ProviderRegistry( - builtinModels({ credentials: piCredentialStore, modelsStore: piModelsStore }), + registerAidenBuiltinProviders( + (() => { + const models = createModels({ credentials: piCredentialStore, modelsStore: piModelsStore }); + for (const provider of builtinProviders()) { + const compatible = withAidenPiCompatibility(provider); + models.setProvider( + provider.id === "radius" + ? provider + : withPiRemoteCatalog(compatible, { + supportedApis: additionalAidenPiApis(provider.id), + }), + ); + } + return models; + })(), + ), piCredentialStore, ); diff --git a/main/services/schedule-execution.ts b/main/services/schedule-execution.ts index 61927b34..194f8798 100644 --- a/main/services/schedule-execution.ts +++ b/main/services/schedule-execution.ts @@ -6,10 +6,7 @@ import { chatStore } from "./chat-store.js"; import { configStore } from "./config-store.js"; import { llmClient } from "./llm-client.js"; import { providerRegistry } from "./provider-registry.js"; -import { - resolveScheduledScript, - runScheduledScript, -} from "./schedule-script.js"; +import { resolveScheduledScript, runScheduledScript } from "./schedule-script.js"; import { scheduleStore, type ScheduleStore } from "./schedule-store.js"; import { SCHEDULE_TOOL_NAME } from "./schedule-tool.js"; import { @@ -18,16 +15,12 @@ import { scheduledTaskGenerationMode, } from "./schedule-guard.js"; import { showScheduledNotification } from "./schedule-notification.js"; -import type { - ChatDone, - ChatError, - ScheduledRun, - ScheduledTask, -} from "./types.js"; +import type { ChatDone, ChatError, ScheduledRun, ScheduledTask } from "./types.js"; import type { ChatGenerationOwner } from "./chat-generation-owner.js"; import type { NotificationChannel } from "../../renderer/preload-channels.js"; import { assertManagedWorktreeAdmission } from "./managed-worktree-admission.js"; import { createScheduledChatClaim } from "./scheduled-chat-creation.js"; +import { firstVisibleModelForProvider } from "../../renderer/shared/model-visibility.js"; function createBackgroundOwner(streamId: string): { owner: ChatGenerationOwner; @@ -44,8 +37,7 @@ function createBackgroundOwner(streamId: string): { documentId: `scheduled:${streamId}`, isDestroyed: () => destroyed, send: (channel: NotificationChannel, payload: unknown) => { - if (destroyed) - throw new Error("The scheduled generation is no longer active."); + if (destroyed) throw new Error("The scheduled generation is no longer active."); if (channel === "chat:done" || channel === "chat:error") settle?.(payload as ChatDone | ChatError); }, @@ -71,10 +63,7 @@ function scheduledTurnId(taskId: string, kind: string): string { return `scheduled-${kind}-${taskId}-${Date.now().toString(36)}-${scheduledTurnSequence.toString(36)}`; } -async function appendClaimedChatMessage( - chatId: string, - content: string, -): Promise { +async function appendClaimedChatMessage(chatId: string, content: string): Promise { const chat = await chatStore.appendMessage(chatId, { role: "assistant", content, @@ -105,11 +94,7 @@ async function appendSerializedChatMessage( } } -function notify( - task: ScheduledTask, - body: string, - chatId: string | undefined, -): void { +function notify(task: ScheduledTask, body: string, chatId: string | undefined): void { showScheduledNotification(task, body, chatId, { isSupported: () => Notification.isSupported(), create: (options) => new Notification(options), @@ -140,10 +125,7 @@ export function createScheduleExecution(store: ScheduleStore = scheduleStore) { await store.clearChatId(task.id, chatId); chatId = await store.ensureChatId(task.id, create); chat = await chatStore.get(chatId); - if (!chat) - throw new Error( - "Could not create the scheduled task's dedicated chat.", - ); + if (!chat) throw new Error("Could not create the scheduled task's dedicated chat."); } if (chat) { ipcMain.broadcast("chats:metadata-updated", { @@ -173,25 +155,17 @@ export function createScheduleExecution(store: ScheduleStore = scheduleStore) { const workspace = task.workspaceId ? await configStore.getWorkspace(task.workspaceId) : undefined; - if (task.workspaceId && !workspace) - throw new Error("The task workspace no longer exists."); - if (workspace?.permission === "none") - throw new Error("The task workspace has No Access."); + if (task.workspaceId && !workspace) throw new Error("The task workspace no longer exists."); + if (workspace?.permission === "none") throw new Error("The task workspace has No Access."); if (workspace) await assertManagedWorktreeAdmission(workspace); const script = await resolveScheduledScript({ script: task.script ?? "", workspaceRoot: workspace?.folderPath, }); const turnId = scheduledTurnId(task.id, "script"); - const turn = llmClient.beginChatTurn( - chatId, - turnId, - `scheduled-script:${task.id}`, - ); + const turn = llmClient.beginChatTurn(chatId, turnId, `scheduled-script:${task.id}`); if (!turn) { - throw new Error( - "The scheduled task's dedicated chat already has a turn in progress.", - ); + throw new Error("The scheduled task's dedicated chat already has a turn in progress."); } try { const processResult = await runScheduledScript(script, { @@ -205,18 +179,12 @@ export function createScheduleExecution(store: ScheduleStore = scheduleStore) { } if (processResult.timedOut) { const error = "Script timed out after 60 seconds."; - await appendClaimedChatMessage( - chatId, - `Scheduled task failed: ${error}`, - ); + await appendClaimedChatMessage(chatId, `Scheduled task failed: ${error}`); return { result: "error", output: processResult.stdout, error }; } if (processResult.outputLimitExceeded) { const error = "Script exceeded the 1 MB output limit."; - await appendClaimedChatMessage( - chatId, - `Scheduled task failed: ${error}`, - ); + await appendClaimedChatMessage(chatId, `Scheduled task failed: ${error}`); return { result: "error", output: processResult.stdout, error }; } if (processResult.exitCode !== 0) { @@ -224,10 +192,7 @@ export function createScheduleExecution(store: ScheduleStore = scheduleStore) { processResult.stderr.trim() || `Process exited with code ${String(processResult.exitCode)}.`; const error = detail.slice(0, 4_096); - await appendClaimedChatMessage( - chatId, - `Scheduled task failed: ${error}`, - ); + await appendClaimedChatMessage(chatId, `Scheduled task failed: ${error}`); return { result: "error", output: processResult.stdout, error }; } if (!processResult.stdout.trim()) return { result: "silent", output: "" }; @@ -252,26 +217,23 @@ export function createScheduleExecution(store: ScheduleStore = scheduleStore) { const workspace = task.workspaceId ? await configStore.getWorkspace(task.workspaceId) : undefined; - if (task.workspaceId && !workspace) - throw new Error("The task workspace no longer exists."); - if (workspace?.permission === "none") - throw new Error("The task workspace has No Access."); + if (task.workspaceId && !workspace) throw new Error("The task workspace no longer exists."); + if (workspace?.permission === "none") throw new Error("The task workspace has No Access."); if (workspace) await assertManagedWorktreeAdmission(workspace); const settings = await configStore.getSettings(); const providerId = task.providerId ?? settings.lastProviderId; - if (!providerId) - throw new Error("Choose a provider before running this scheduled task."); + if (!providerId) throw new Error("Choose a provider before running this scheduled task."); const provider = (await providerRegistry.selectionProvider(providerId)) ?? (await configStore.getProvider(providerId)); if (!provider) throw new Error("The task provider no longer exists."); const model = task.model ?? - settings.lastModel ?? - provider.defaultModel ?? - provider.models[0]; - if (!model) - throw new Error("Choose a model before running this scheduled task."); + firstVisibleModelForProvider(settings.hiddenModelsByProvider, providerId, provider.models, [ + settings.lastProviderId === providerId ? settings.lastModel : undefined, + provider.defaultModel, + ]); + if (!model) throw new Error("Choose a model before running this scheduled task."); const prompt = task.prompt?.trim(); if (!prompt) throw new Error("The scheduled task prompt is empty."); if (signal.aborted) throw new Error("Scheduled task was cancelled."); @@ -286,18 +248,11 @@ export function createScheduleExecution(store: ScheduleStore = scheduleStore) { task.permission === "full"; const mcpServerIds = task.mcpServerIds ?? (legacyAllMcp ? undefined : []); const allowMcpTools = - task.permission === "full" && - (legacyAllMcp || (mcpServerIds?.length ?? 0) > 0); + task.permission === "full" && (legacyAllMcp || (mcpServerIds?.length ?? 0) > 0); const background = createBackgroundOwner(streamId); - const turn = llmClient.beginChatTurn( - chatId, - streamId, - background.owner.documentId, - ); + const turn = llmClient.beginChatTurn(chatId, streamId, background.owner.documentId); if (!turn) { - throw new Error( - "The scheduled task's dedicated chat already has a turn in progress.", - ); + throw new Error("The scheduled task's dedicated chat already has a turn in progress."); } activeStreams.set(task.id, streamId); try { @@ -337,27 +292,18 @@ export function createScheduleExecution(store: ScheduleStore = scheduleStore) { turnId: streamId, }, ); - if (!started) - throw new Error( - "The scheduled generation was cancelled before it started.", - ); + if (!started) throw new Error("The scheduled generation was cancelled before it started."); const terminal = await background.terminal; if (signal.aborted) { const error = "Scheduled task was cancelled."; await llmClient.waitForChatIdle(chatId); - await appendSerializedChatMessage( - chatId, - error, - scheduledTurnId(task.id, "cancelled"), - ); + await appendSerializedChatMessage(chatId, error, scheduledTurnId(task.id, "cancelled")); return { result: "blocked", output: "", error }; } if ("message" in terminal) { const error = terminal.message; return { - result: /\bblocked\b|\bread-only\b|\bdenied\b/iu.test(error) - ? "blocked" - : "error", + result: /\bblocked\b|\bread-only\b|\bdenied\b/iu.test(error) ? "blocked" : "error", output: terminal.content ?? "", error, }; @@ -374,7 +320,7 @@ export function createScheduleExecution(store: ScheduleStore = scheduleStore) { } return { - async run(task: ScheduledTask): Promise { + async run(task: ScheduledTask, runId?: string): Promise { if (activeControllers.has(task.id)) { throw new Error("This scheduled task is already running."); } @@ -415,6 +361,7 @@ export function createScheduleExecution(store: ScheduleStore = scheduleStore) { } try { const run = await store.recordRun({ + id: runId, taskId: task.id, startedAt, finishedAt: Date.now(), diff --git a/main/services/schedule-service-core.ts b/main/services/schedule-service-core.ts index b2c80498..4987a2da 100644 --- a/main/services/schedule-service-core.ts +++ b/main/services/schedule-service-core.ts @@ -7,7 +7,7 @@ import type { } from "./types.js"; interface ScheduleExecutionLike { - run(task: ScheduledTask): Promise; + run(task: ScheduledTask, runId?: string): Promise; cancel(taskId: string): boolean; cancelAll(): void; } @@ -107,7 +107,7 @@ export function createScheduleServiceCore( function dispatch( taskId: string, - options: { automatic: boolean }, + options: { automatic: boolean; runId?: string }, ): Promise { if (runningTasks.has(taskId)) { throw new Error("This scheduled task is already running."); @@ -142,7 +142,7 @@ export function createScheduleServiceCore( const claimed = options.automatic ? await advanceBeforeRun(task) : task; if (state.cancelRequested) throw new Error("This scheduled task was cancelled."); - return execution.run(claimed); + return execution.run(claimed, options.runId); } finally { if (!workspaceResolved) { workspaceResolved = true; @@ -374,10 +374,13 @@ export function createScheduleServiceCore( async remove( id: string, - options: { signal?: AbortSignal } = {}, + options: { signal?: AbortSignal; expectedUpdatedAt?: number } = {}, ): Promise { await withTaskLifecycle(id, async () => { const current = await store.get(id); + if (options.expectedUpdatedAt !== undefined && current?.updatedAt !== options.expectedUpdatedAt) { + throw new Error("This automation changed. Refresh it before trying again."); + } throwIfAborted(options.signal, "removal"); stopJob(id); await cancelAndSettle(id); @@ -392,10 +395,13 @@ export function createScheduleServiceCore( async pause( id: string, - options: { signal?: AbortSignal } = {}, + options: { signal?: AbortSignal; expectedUpdatedAt?: number } = {}, ): Promise { return withTaskLifecycle(id, async () => { const current = await store.get(id); + if (options.expectedUpdatedAt !== undefined && current?.updatedAt !== options.expectedUpdatedAt) { + throw new Error("This automation changed. Refresh it before trying again."); + } throwIfAborted(options.signal, "pause"); stopJob(id); await cancelAndSettle(id); @@ -419,10 +425,13 @@ export function createScheduleServiceCore( async resume( id: string, - options: { signal?: AbortSignal } = {}, + options: { signal?: AbortSignal; expectedUpdatedAt?: number } = {}, ): Promise { return withTaskLifecycle(id, async () => { const current = await store.get(id); + if (options.expectedUpdatedAt !== undefined && current?.updatedAt !== options.expectedUpdatedAt) { + throw new Error("This automation changed. Refresh it before trying again."); + } throwIfAborted(options.signal, "resume"); const task = await store.setEnabled(id, true); const restore = async () => { @@ -448,10 +457,10 @@ export function createScheduleServiceCore( runNow( id: string, - options: { signal?: AbortSignal } = {}, + options: { signal?: AbortSignal; runId?: string } = {}, ): Promise { throwIfAborted(options.signal, "run"); - const operation = dispatch(id, { automatic: false }); + const operation = dispatch(id, { automatic: false, runId: options.runId }); if (!options.signal) return operation; const cancel = () => { const state = runningTasks.get(id); diff --git a/main/services/scheduled-chat-creation.test.ts b/main/services/scheduled-chat-creation.test.ts index 4a303983..ef8f063a 100644 --- a/main/services/scheduled-chat-creation.test.ts +++ b/main/services/scheduled-chat-creation.test.ts @@ -30,19 +30,15 @@ class FailingTaskPersistence extends MemoryPersistence { const result = await mutation(draft); const failure = this.fail; this.fail = null; - if (failure === "before") - throw new Error("task mapping pre-commit failure"); + if (failure === "before") throw new Error("task mapping pre-commit failure"); this.data = draft; - if (failure === "after") - throw new Error("task mapping post-commit failure"); + if (failure === "after") throw new Error("task mapping post-commit failure"); return result; } } test("an indeterminate scheduled create preserves its exact recovered chat identity", async (t) => { - const directory = await fs.mkdtemp( - path.join(os.tmpdir(), "aiden-scheduled-chat-claim-"), - ); + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-scheduled-chat-claim-")); t.after(() => fs.rm(directory, { recursive: true, force: true })); let failIndexWrites = true; const interrupted = createChatStore(async () => directory, undefined, { @@ -93,12 +89,11 @@ test("an indeterminate scheduled create preserves its exact recovered chat ident [claimedChatId], ); - const execution = await fs.readFile( - new URL("./schedule-execution.ts", import.meta.url), - "utf8", - ); + const execution = await fs.readFile(new URL("./schedule-execution.ts", import.meta.url), "utf8"); assert.match(execution, /createScheduledChatClaim\(claimedChatId, \(\) =>/u); assert.match(execution, /store\.ensureChatId\(task\.id, create\)/u); + assert.match(execution, /task\.model \?\?[\s\S]*firstVisibleModelForProvider/u); + assert.match(execution, /settings\.hiddenModelsByProvider/u); }); test("schedule mapping failures happen before chat creation and cannot orphan a chat", async () => { @@ -123,10 +118,7 @@ test("schedule mapping failures happen before chat creation and cannot orphan a creates += 1; return { id: claimedChatId }; }), - new RegExp( - `task mapping ${failure === "before" ? "pre" : "post"}-commit failure`, - "u", - ), + new RegExp(`task mapping ${failure === "before" ? "pre" : "post"}-commit failure`, "u"), ); assert.equal(creates, 0); } diff --git a/main/services/scheduled-task-application-service-main.ts b/main/services/scheduled-task-application-service-main.ts new file mode 100644 index 00000000..ee4472ea --- /dev/null +++ b/main/services/scheduled-task-application-service-main.ts @@ -0,0 +1,26 @@ +import { ipcMain } from "../platform.js"; +import { configStore } from "./config-store.js"; +import { selectedMcpServers } from "./mcp-selection.js"; +import { listScheduledScripts } from "./schedule-script.js"; +import { scheduleService } from "./schedule-service.js"; +import { nextScheduledRuns, scheduleStore, systemTimezone, validateTimezone } from "./schedule-store.js"; +import { scheduledSettingsPatch } from "./scheduled-settings-core.js"; +import { createScheduledTaskApplicationService } from "./scheduled-task-application-service.js"; + +export const scheduledTaskApplicationService = createScheduledTaskApplicationService({ + store: scheduleStore, + service: scheduleService, + getSettings: () => configStore.getSettings(), + setSettings: (patch) => configStore.setSettings(patch), + getWorkspace: (id) => configStore.getWorkspace(id), + listMcpServers: () => configStore.listMcpServers(), + validateMcpSelection: (configured, selected) => { + selectedMcpServers(configured, selected); + }, + listScripts: listScheduledScripts, + nextRuns: nextScheduledRuns, + systemTimezone, + validateTimezone, + settingsPatch: (input) => scheduledSettingsPatch(input, validateTimezone), + notifyChanged: (payload) => ipcMain.broadcast("schedule:updated", payload), +}); diff --git a/main/services/scheduled-task-application-service.test.ts b/main/services/scheduled-task-application-service.test.ts new file mode 100644 index 00000000..bd248af3 --- /dev/null +++ b/main/services/scheduled-task-application-service.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createScheduledTaskApplicationService, scheduledTaskRevision } from "./scheduled-task-application-service.js"; +import type { AppSettings, ScheduledRun, ScheduledTask, ScheduledTaskInput } from "./types.js"; + +function fixture() { + let clock = 10; + let settings: AppSettings = {}; + const tasks = new Map(); + const runs = new Map(); + const savedOptions: Array<{ expectedUpdatedAt?: number; runId?: string }> = []; + const service = createScheduledTaskApplicationService({ + store: { + list: async () => [...tasks.values()], + get: async (id) => tasks.get(id), + runs: async (id) => runs.get(id) ?? [], + }, + service: { + save: async (input: ScheduledTaskInput, options = {}) => { + savedOptions.push({ expectedUpdatedAt: options.expectedUpdatedAt }); + const existing = input.id ? tasks.get(input.id) : undefined; + if (options.expectedUpdatedAt !== undefined && existing?.updatedAt !== options.expectedUpdatedAt) { + throw new Error("revision"); + } + clock += 1; + const task: ScheduledTask = { + id: existing?.id ?? "task-1", name: input.name, enabled: input.enabled ?? true, + mode: input.mode, cron: input.cron, timezone: input.timezone ?? "UTC", + workspaceId: input.workspaceId, providerId: input.providerId, model: input.model, + prompt: input.prompt, script: input.script, permission: input.permission ?? "read-only", + mcpServerIds: input.mcpServerIds, notify: input.notify ?? true, + createdAt: existing?.createdAt ?? clock, updatedAt: clock, + }; + tasks.set(task.id, task); + return task; + }, + remove: async (id, options = {}) => { + savedOptions.push({ expectedUpdatedAt: options.expectedUpdatedAt }); + if (tasks.get(id)?.updatedAt !== options.expectedUpdatedAt) throw new Error("revision"); + tasks.delete(id); + }, + pause: async (id, options = {}) => { + savedOptions.push({ expectedUpdatedAt: options.expectedUpdatedAt }); + const task = tasks.get(id)!; + if (task.updatedAt !== options.expectedUpdatedAt) throw new Error("revision"); + const next = { ...task, enabled: false, updatedAt: ++clock }; + tasks.set(id, next); + return next; + }, + resume: async (id, options = {}) => { + const task = tasks.get(id)!; + if (task.updatedAt !== options.expectedUpdatedAt) throw new Error("revision"); + const next = { ...task, enabled: true, updatedAt: ++clock }; + tasks.set(id, next); + return next; + }, + runNow: async (id, options = {}) => { + savedOptions.push({ runId: options.runId }); + const run: ScheduledRun = { id: options.runId ?? "run-local", taskId: id, startedAt: 1, finishedAt: 2, result: "success", output: "ok" }; + runs.set(id, [run]); + return run; + }, + setGlobalEnabled: async () => undefined, + isRunning: () => false, + }, + getSettings: async () => settings, + setSettings: async (patch) => (settings = { ...settings, ...patch }), + getWorkspace: async (id) => id === "workspace-1" ? ({ id, name: "One", permission: "full", folderPath: "/safe", createdAt: 1, updatedAt: 1 }) : undefined, + listMcpServers: async () => [], + validateMcpSelection: () => undefined, + listScripts: async () => ["safe.sh"], + nextRuns: (_cron, _timezone, count) => Array.from({ length: count }, (_, index) => index + 1), + systemTimezone: () => "UTC", + validateTimezone: (value) => value, + settingsPatch: (input) => ({ ...(typeof input.enabled === "boolean" ? { scheduledTasksEnabled: input.enabled } : {}) }), + notifyChanged: () => undefined, + }); + return { service, tasks, savedOptions }; +} + +test("shared scheduled-task service validates inventory and carries revisions into lifecycle commits", async () => { + const value = fixture(); + await assert.rejects( + value.service.save({ name: "Unsafe", mode: "script", cron: "* * * * *", timezone: "UTC", permission: "full", script: "raw.sh" }), + /inventory/u, + ); + const task = await value.service.save({ name: "Safe", mode: "script", cron: "* * * * *", timezone: "UTC", permission: "full", script: "safe.sh", workspaceId: "workspace-1" }); + const revision = scheduledTaskRevision(task); + await assert.rejects(value.service.pause(task.id, "rev_stale"), /changed/u); + const paused = await value.service.pause(task.id, revision); + assert.equal(paused.enabled, false); + assert.equal(value.savedOptions[value.savedOptions.length - 1]?.expectedUpdatedAt, task.updatedAt); +}); + +test("shared scheduled-task service carries a caller-owned run ID and revision-checks settings", async () => { + const value = fixture(); + const task = await value.service.save({ name: "Run", mode: "llm", cron: "* * * * *", timezone: "UTC", permission: "read-only", prompt: "hello" }); + await value.service.runNow(task.id, "run_remote"); + assert.equal(value.savedOptions[value.savedOptions.length - 1]?.runId, "run_remote"); + const current = await value.service.settings(); + await assert.rejects(value.service.updateSettings("rev_stale", { enabled: false }), /changed/u); + const updated = await value.service.updateSettings(current.revision, { enabled: false }); + assert.equal(updated.value.enabled, false); +}); + +test("concurrent desktop and remote lifecycle edits admit only one revision", async () => { + const value = fixture(); + const task = await value.service.save({ + name: "Concurrent", mode: "llm", cron: "* * * * *", timezone: "UTC", + permission: "read-only", prompt: "hello", + }); + const revision = scheduledTaskRevision(task); + const results = await Promise.allSettled([ + value.service.pause(task.id, revision), + value.service.pause(task.id, revision), + ]); + assert.equal(results.filter((result) => result.status === "fulfilled").length, 1); + assert.equal(results.filter((result) => result.status === "rejected").length, 1); +}); diff --git a/main/services/scheduled-task-application-service.ts b/main/services/scheduled-task-application-service.ts new file mode 100644 index 00000000..d651e058 --- /dev/null +++ b/main/services/scheduled-task-application-service.ts @@ -0,0 +1,236 @@ +import { createHash } from "node:crypto"; +import type { + AppSettings, + McpServer, + ScheduledRun, + ScheduledTask, + ScheduledTaskInput, + ScheduledTaskSettings, + Workspace, +} from "./types.js"; + +export interface ScheduledTaskApplicationDependencies { + store: { + list(): Promise; + get(id: string): Promise; + runs(id: string): Promise; + }; + service: { + save( + input: ScheduledTaskInput, + options?: { expectedUpdatedAt?: number; signal?: AbortSignal }, + ): Promise; + remove(id: string, options?: { signal?: AbortSignal; expectedUpdatedAt?: number }): Promise; + pause(id: string, options?: { signal?: AbortSignal; expectedUpdatedAt?: number }): Promise; + resume(id: string, options?: { signal?: AbortSignal; expectedUpdatedAt?: number }): Promise; + runNow( + id: string, + options?: { signal?: AbortSignal; runId?: string }, + ): Promise; + setGlobalEnabled(enabled: boolean): Promise; + isRunning(id: string): boolean; + }; + getSettings(): Promise; + setSettings(patch: Partial): Promise; + getWorkspace(id: string): Promise; + listMcpServers(): Promise; + validateMcpSelection(configured: readonly McpServer[], selected: readonly string[]): void; + listScripts(input: { workspaceRoot?: string }): Promise; + nextRuns(cron: string, timezone: string, count: number): number[]; + systemTimezone(): string; + validateTimezone(value: string): string; + settingsPatch(input: Record): Partial; + notifyChanged(payload: Record): void; +} + +export interface RevisionedScheduledTaskSettings { + revision: string; + value: ScheduledTaskSettings; +} + +function settingsValue( + input: AppSettings, + dependencies: Pick, +): ScheduledTaskSettings { + return { + enabled: input.scheduledTasksEnabled !== false, + defaultMode: input.scheduledDefaultMode === "script" ? "script" : "llm", + defaultPermission: input.scheduledDefaultPermission === "full" ? "full" : "read-only", + defaultMcpEnabled: input.scheduledDefaultMcpEnabled === true, + defaultNotify: input.scheduledDefaultNotify !== false, + defaultTimezone: dependencies.validateTimezone( + input.scheduledDefaultTimezone ?? dependencies.systemTimezone(), + ), + }; +} + +export function scheduledTaskRevision(task: ScheduledTask): string { + return `rev_${createHash("sha256") + .update(JSON.stringify({ id: task.id, updatedAt: task.updatedAt })) + .digest("base64url")}`; +} + +export function scheduledSettingsRevision(settings: ScheduledTaskSettings): string { + return `rev_${createHash("sha256") + .update(JSON.stringify(settings)) + .digest("base64url")}`; +} + +function expectedTaskRevision(task: ScheduledTask, expected?: string): number | undefined { + if (expected === undefined) return undefined; + if (scheduledTaskRevision(task) !== expected) { + throw new Error("This automation changed. Refresh it before trying again."); + } + return task.updatedAt; +} + +/** Shared main-process schedule orchestration for Electron and Aiden Remote. */ +export function createScheduledTaskApplicationService( + dependencies: ScheduledTaskApplicationDependencies, +) { + let settingsTail: Promise = Promise.resolve(); + + const validateInput = async (input: ScheduledTaskInput): Promise => { + if (input.workspaceId) { + const workspace = await dependencies.getWorkspace(input.workspaceId); + if (!workspace) throw new Error(`Workspace ${input.workspaceId} not found.`); + if (workspace.permission === "none") { + throw new Error("Scheduled tasks require a workspace with local access."); + } + } + if ((input.mcpServerIds?.length ?? 0) > 0) { + dependencies.validateMcpSelection( + await dependencies.listMcpServers(), + input.mcpServerIds!, + ); + } + if (input.mode === "script") { + const workspace = input.workspaceId + ? await dependencies.getWorkspace(input.workspaceId) + : undefined; + const scripts = await dependencies.listScripts({ + workspaceRoot: workspace?.folderPath, + }); + if (!input.script || !scripts.includes(input.script)) { + throw new Error("Select a script from Aiden's current script inventory."); + } + } + }; + + const get = async (id: string): Promise => { + const task = await dependencies.store.get(id); + if (!task) throw new Error(`Scheduled task ${id} not found.`); + return task; + }; + + const save = async ( + input: ScheduledTaskInput, + options: { expectedRevision?: string; signal?: AbortSignal } = {}, + ): Promise => { + await validateInput(input); + const existing = input.id ? await get(input.id) : undefined; + const task = await dependencies.service.save(input, { + ...(existing + ? { expectedUpdatedAt: expectedTaskRevision(existing, options.expectedRevision) } + : {}), + ...(options.signal ? { signal: options.signal } : {}), + }); + dependencies.notifyChanged({ taskId: task.id }); + return task; + }; + + const mutateExisting = async ( + id: string, + expectedRevision: string | undefined, + operation: (expectedUpdatedAt: number | undefined) => Promise, + ): Promise => { + const expectedUpdatedAt = expectedTaskRevision(await get(id), expectedRevision); + const result = await operation(expectedUpdatedAt); + dependencies.notifyChanged({ taskId: id }); + return result; + }; + + const settings = async (): Promise => { + const value = settingsValue(await dependencies.getSettings(), dependencies); + return { revision: scheduledSettingsRevision(value), value }; + }; + + const updateSettings = async ( + expectedRevision: string, + patch: Record, + ): Promise => { + const previous = settingsTail; + let release!: () => void; + settingsTail = new Promise((resolve) => { + release = resolve; + }); + await previous.catch(() => undefined); + try { + const current = await settings(); + if (current.revision !== expectedRevision) { + throw new Error("Scheduled-task settings changed. Refresh them before trying again."); + } + const parsed = dependencies.settingsPatch(patch); + if (Object.keys(parsed).length === 0) { + throw new Error("At least one scheduled-task setting must change."); + } + const saved = await dependencies.setSettings(parsed); + const next = settingsValue(saved, dependencies); + if (next.enabled !== current.value.enabled) { + await dependencies.service.setGlobalEnabled(next.enabled); + } + dependencies.notifyChanged({ settings: true }); + return { revision: scheduledSettingsRevision(next), value: next }; + } finally { + release(); + } + }; + + return { + list: () => dependencies.store.list(), + get, + save, + remove: (id: string, revision?: string, signal?: AbortSignal) => + mutateExisting(id, revision, (expectedUpdatedAt) => + dependencies.service.remove(id, { signal, expectedUpdatedAt })), + pause: (id: string, revision?: string, signal?: AbortSignal) => + mutateExisting(id, revision, (expectedUpdatedAt) => + dependencies.service.pause(id, { signal, expectedUpdatedAt })), + resume: (id: string, revision?: string, signal?: AbortSignal) => + mutateExisting(id, revision, (expectedUpdatedAt) => + dependencies.service.resume(id, { signal, expectedUpdatedAt })), + runNow: async (id: string, runId?: string) => { + await get(id); + const run = dependencies.service.runNow(id, { runId }); + if (runId) { + void run.catch(() => undefined); + return undefined; + } + return run; + }, + runs: async (id: string) => { + await get(id); + return dependencies.store.runs(id); + }, + preview: (cron: string, timezone: string, count = 3) => + dependencies.nextRuns(cron, timezone, count), + scripts: async (workspaceId?: string) => { + const workspace = workspaceId ? await dependencies.getWorkspace(workspaceId) : undefined; + if (workspaceId && !workspace) throw new Error(`Workspace ${workspaceId} not found.`); + if (workspace?.permission === "none") { + throw new Error("This workspace does not allow script inventory access."); + } + return dependencies.listScripts({ workspaceRoot: workspace?.folderPath }); + }, + mcpServers: async () => (await dependencies.listMcpServers()) + .filter((server) => server.enabled) + .map((server) => ({ id: server.id, name: server.name })), + settings, + updateSettings, + isRunning: (id: string) => dependencies.service.isRunning(id), + }; +} + +export type ScheduledTaskApplicationService = ReturnType< + typeof createScheduledTaskApplicationService +>; diff --git a/main/services/share-image-tool.ts b/main/services/share-image-tool.ts new file mode 100644 index 00000000..82b07371 --- /dev/null +++ b/main/services/share-image-tool.ts @@ -0,0 +1,123 @@ +import { randomUUID } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { Type } from "@earendil-works/pi-ai"; +import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core"; +import { MAX_IMAGE_BYTES, imageBytesMatchMime } from "./attachments.js"; +import { declarePiRuntimeReplay } from "./pi-runtime-tool.js"; +import type { Attachment } from "./types.js"; + +export const SHARE_IMAGE_TOOL_NAME = "share_image"; + +export interface ShareImageToolDependencies { + workspaceRoot: string; + share(attachment: Attachment): void; +} + +function textResult(text: string): AgentToolResult { + return { content: [{ type: "text", text }], details: null }; +} + +function completeImage(bytes: Buffer, mimeType: "image/png" | "image/jpeg"): boolean { + if (mimeType === "image/jpeg") { + return bytes.length >= 2 && bytes[bytes.length - 2] === 0xff && bytes[bytes.length - 1] === 0xd9; + } + const trailer = Buffer.from([0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130]); + return bytes.length >= trailer.length && bytes.subarray(-trailer.length).equals(trailer); +} + +function detectedMimeType(bytes: Buffer): "image/png" | "image/jpeg" | undefined { + if (imageBytesMatchMime(bytes, "image/png") && completeImage(bytes, "image/png")) { + return "image/png"; + } + if (imageBytesMatchMime(bytes, "image/jpeg") && completeImage(bytes, "image/jpeg")) { + return "image/jpeg"; + } + return undefined; +} + +function safeDisplayName(filePath: string, mimeType: "image/png" | "image/jpeg"): string { + const fallback = mimeType === "image/png" ? "Image.png" : "Image.jpg"; + const leaf = Array.from(path.basename(filePath)) + .filter((character) => { + const code = character.charCodeAt(0); + return code > 0x1f && code !== 0x7f; + }) + .join("") + .trim(); + return Array.from(leaf || fallback).slice(0, 255).join(""); +} + +async function readVerifiedImage(filePath: string, signal?: AbortSignal): Promise<{ + bytes: Buffer; + mimeType: "image/png" | "image/jpeg"; + resolvedPath: string; +}> { + if (signal?.aborted) throw signal.reason ?? new Error("Image sharing was cancelled."); + const resolvedPath = await fs.realpath(filePath); + const before = await fs.lstat(resolvedPath); + if (!before.isFile() || before.isSymbolicLink() || before.size < 1 || before.size > MAX_IMAGE_BYTES) { + throw new Error("Choose a PNG or JPEG image no larger than 8 MB."); + } + const handle = await fs.open( + resolvedPath, + fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0), + ); + try { + const opened = await handle.stat(); + if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) { + throw new Error("The image changed before Aiden could share it."); + } + const bytes = await handle.readFile(); + if (signal?.aborted) throw signal.reason ?? new Error("Image sharing was cancelled."); + const after = await fs.lstat(resolvedPath); + if ( + after.dev !== opened.dev || + after.ino !== opened.ino || + after.size !== bytes.length || + after.mtimeMs !== opened.mtimeMs + ) { + throw new Error("The image changed while Aiden was reading it."); + } + const mimeType = detectedMimeType(bytes); + if (!mimeType) throw new Error("Only complete PNG and JPEG images can be shared in chat."); + return { bytes, mimeType, resolvedPath }; + } finally { + await handle.close(); + } +} + +export function createShareImageTool(dependencies: ShareImageToolDependencies): AgentTool { + return declarePiRuntimeReplay({ + 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 view it in Aiden on Mac, iPhone, or iPad. Use this instead of opening Preview when the user asks to see or receive an image. 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." }), + }), + execute: async (_toolCallId, rawParams, signal): Promise> => { + const suppliedPath = (rawParams as { path?: unknown }).path; + if (typeof suppliedPath !== "string" || suppliedPath.trim().length === 0) { + throw new Error("An image path is required."); + } + const requestedPath = path.isAbsolute(suppliedPath) + ? path.normalize(suppliedPath) + : path.resolve(dependencies.workspaceRoot, suppliedPath); + const image = await readVerifiedImage(requestedPath, signal); + const attachment: Attachment = { + id: `shared_${randomUUID()}`, + name: safeDisplayName(image.resolvedPath, image.mimeType), + mimeType: image.mimeType, + kind: "image", + size: image.bytes.length, + data: image.bytes.toString("base64"), + }; + dependencies.share(attachment); + return textResult( + JSON.stringify({ shared: true, name: attachment.name, size: attachment.size }), + ); + }, + }, "never"); +} diff --git a/main/services/subagents/subagent-phase3-contract.test.ts b/main/services/subagents/subagent-phase3-contract.test.ts index ce3fe74c..03eac59e 100644 --- a/main/services/subagents/subagent-phase3-contract.test.ts +++ b/main/services/subagents/subagent-phase3-contract.test.ts @@ -77,15 +77,17 @@ test("historical inspector reads require a live document and matching chat owner }); test("run-store failures keep filesystem details out of renderer-visible errors", async () => { - const [llm, historyHandler, chatHandler] = await Promise.all([ + const [llm, historyHandler, chatHandler, chatApplicationService] = await Promise.all([ source("main/services/llm-client.ts"), source("main/handlers/subagents.ts"), source("main/handlers/chats.ts"), + source("main/services/chat-application-service.ts"), ]); assert.match(llm, /error: "local storage failed"/u); assert.match(historyHandler, /Aiden could not load subagent history/u); + assert.match(chatHandler, /chatApplicationService\.remove\(/u); assert.match( - chatHandler, + chatApplicationService, /Aiden could not delete this chat's subagent history/u, ); }); @@ -127,29 +129,31 @@ test("private run-store I/O is descriptor-bound, generation-checked, and package }); test("chat removal deletes private child history before the chat can disappear", async () => { - const [handler, llm] = await Promise.all([ + const [handler, applicationService, llm] = await Promise.all([ source("main/handlers/chats.ts"), + source("main/services/chat-application-service.ts"), source("main/services/llm-client.ts"), ]); - const beginDeletion = handler.indexOf("llmClient.beginChatDeletion(chatId)"); - const cancel = handler.indexOf("llmClient.cancelChat(chatId)"); - const deleteRuns = handler.indexOf( - "await subagentRunStore.deleteChat(chatId)", + assert.match(handler, /chatApplicationService\.remove\(asString\(id, "id"\)\)/u); + const beginDeletion = applicationService.indexOf("deps.llmClient.beginChatDeletion(chatId)"); + const cancel = applicationService.indexOf("deps.llmClient.cancelChat(chatId)"); + const deleteRuns = applicationService.indexOf( + "await deps.subagentRunStore.deleteChat(chatId)", cancel, ); - const deleteChat = handler.indexOf( - "await chatStore.remove(chatId)", + const deleteChat = applicationService.indexOf( + "await deps.chatStore.remove(chatId,", deleteRuns, ); - const completeDeletion = handler.indexOf( - "await subagentRunStore.completeChatDeletion(chatId)", + const completeDeletion = applicationService.indexOf( + "await deps.subagentRunStore.completeChatDeletion(chatId)", deleteChat, ); - const pendingDeletionCheck = handler.indexOf( - "await subagentRunStore.pendingChatDeletions()", + const pendingDeletionCheck = applicationService.indexOf( + "await deps.subagentRunStore.pendingChatDeletions()", completeDeletion, ); - const releaseAdmission = handler.indexOf( + const releaseAdmission = applicationService.indexOf( "if (releaseAdmission) finishDeletion()", completeDeletion, ); @@ -176,15 +180,16 @@ test("chat removal deletes private child history before the chat can disappear", assert.ok(registerInitialization > admissionCheck); assert.ok(requireExistingChat > registerInitialization); assert.doesNotMatch( - handler.slice(beginDeletion, deleteRuns), - /if \(!\(await chatStore\.get/u, + applicationService.slice(beginDeletion, deleteRuns), + /if \(!\(await deps\.chatStore\.get/u, ); }); test("renderer invalidation detaches while authority changes and shutdown still cancel", async () => { - const [llm, workspaces, main] = await Promise.all([ + const [llm, workspaces, workspaceApplicationService, main] = await Promise.all([ source("main/services/llm-client.ts"), source("main/handlers/workspaces.ts"), + source("main/services/workspace-application-service.ts"), source("main/index.ts"), ]); assert.match( @@ -193,13 +198,15 @@ test("renderer invalidation detaches while authority changes and shutdown still ); assert.match(llm, /this\.cancel\(streamId, "workspace_authority_change"\)/u); assert.match(llm, /this\.cancel\(streamId, "application_shutdown"\)/u); + assert.match(workspaces, /workspaceApplicationService\.update\(/u); + assert.match(workspaces, /workspaceApplicationService\.remove\(/u); assert.match( - workspaces, - /await llmClient\.cancelWorkspaceAndSettle\(existing\.id\)/u, + workspaceApplicationService, + /await deps\.llmClient\.cancelWorkspaceAndSettle\(existing\.id\)/u, ); assert.match( - workspaces, - /await llmClient\.cancelWorkspaceAndSettle\(workspaceId\)/u, + workspaceApplicationService, + /await deps\.llmClient\.cancelWorkspaceAndSettle\(id\)/u, ); assert.match(llm, /subagentRuntimeRegistry\.abortGeneration\(streamId\)/u); assert.match(llm, /subagentRuntimeRegistry\.abortChat\(chatId\)/u); @@ -217,8 +224,9 @@ test("renderer invalidation detaches while authority changes and shutdown still }); test("empty-chat workspace moves serialize against generation authority and terminal persistence", async () => { - const [handler, llm, chatStore] = await Promise.all([ + const [handler, applicationService, llm, chatStore] = await Promise.all([ source("main/handlers/chats.ts"), + source("main/services/chat-application-service.ts"), source("main/services/llm-client.ts"), source("main/services/chat-store-core.ts"), ]); @@ -226,20 +234,23 @@ test("empty-chat workspace moves serialize against generation authority and term const moveHandler = handler.indexOf( 'ipcMain.handle(\n "chats:moveEmptyToWorkspace"', ); - const beginMove = handler.indexOf( - "llmClient.beginChatWorkspaceChange(chatId)", - moveHandler, + assert.match( + handler.slice(moveHandler), + /chatApplicationService\.moveEmptyToWorkspace\(/u, + ); + const beginMove = applicationService.indexOf( + "deps.llmClient.beginChatWorkspaceChange(chatId)", ); - const workspaceLookup = handler.indexOf( - "await configStore.getWorkspace(nextWorkspaceId)", + const workspaceLookup = applicationService.indexOf( + "await deps.configStore.getWorkspace(workspaceId)", beginMove, ); - const moveCommit = handler.indexOf( - "await chatStore.moveEmptyChatToWorkspace(", + const moveCommit = applicationService.indexOf( + "await deps.chatStore.moveEmptyChatToWorkspace(", workspaceLookup, ); assert.ok(moveHandler >= 0); - assert.ok(beginMove > moveHandler); + assert.ok(beginMove >= 0); assert.ok(workspaceLookup > beginMove); assert.ok(moveCommit > workspaceLookup); @@ -318,8 +329,10 @@ test("renderer message appends serialize against detached terminal persistence", ); assert.match( handler, - /"chats:waitUntilIdle"[\s\S]{0,160}llmClient\.waitForChatIdle\(asString\(id, "id"\)\)/u, + /"chats:waitUntilIdle"[\s\S]{0,180}chatApplicationService\.waitUntilIdle\(asString\(id, "id"\)\)/u, ); + const applicationService = await source("main/services/chat-application-service.ts"); + assert.match(applicationService, /return deps\.llmClient\.waitForChatIdle\(chatId\)/u); }); test("renderer turn tokens cross append and generation IPC without an admission gap", async () => { @@ -371,24 +384,28 @@ test("main announces normalized settlement only after generation ownership exits }); test("replacement chat reads mark bounded wait timeouts for retained renderer reconciliation", async () => { - const [handler, llm] = await Promise.all([ + const [handler, applicationService, llm] = await Promise.all([ source("main/handlers/chats.ts"), + source("main/services/chat-application-service.ts"), source("main/services/llm-client.ts"), ]); const getHandler = ipcHandlerStart(handler, "chats:get"); - const inactiveCheck = handler.indexOf( - "llmClient.isChatOwnedByInactiveRenderer(chatId)", - getHandler, + assert.match( + handler.slice(getHandler), + /chatApplicationService\.get\(asString\(id, "id"\)\)/u, + ); + const inactiveCheck = applicationService.indexOf( + "deps.llmClient.isChatOwnedByInactiveRenderer(chatId)", ); - const idleWait = handler.indexOf( - "await llmClient.waitForChatIdle(chatId)", + const idleWait = applicationService.indexOf( + "await deps.llmClient.waitForChatIdle(chatId)", inactiveCheck, ); - const read = handler.indexOf( - "const chat = await chatStore.get(chatId)", + const read = applicationService.indexOf( + "const chat = await deps.chatStore.get(chatId)", idleWait, ); - const response = handler.indexOf( + const response = applicationService.indexOf( "reconciliation: reconciliationRequired", read, ); @@ -399,12 +416,12 @@ test("replacement chat reads mark bounded wait timeouts for retained renderer re assert.ok(read > idleWait); assert.ok(response > read); assert.match( - handler, - /reconciliationRequired = !\(await llmClient\.waitForChatIdle\(chatId\)\)/u, + applicationService, + /reconciliationRequired = !\(await deps\.llmClient\.waitForChatIdle\(chatId\)\)/u, ); assert.match( - handler, - /reconciliationRequired \|\|= llmClient\.isChatOwnedByInactiveRenderer\(chatId\)/u, + applicationService, + /reconciliationRequired \|\|= deps\.llmClient\.isChatOwnedByInactiveRenderer\(chatId\)/u, ); const inactiveOwnerStart = llm.indexOf( "isChatOwnedByInactiveRenderer(chatId: string)", @@ -458,9 +475,10 @@ test("application startup reconciles private runs and worktree deletions before }); test("persisted chat workspace ownership closes generation admission before setup", async () => { - const [llm, workspaces] = await Promise.all([ + const [llm, workspaces, workspaceApplicationService] = await Promise.all([ source("main/services/llm-client.ts"), source("main/handlers/workspaces.ts"), + source("main/services/workspace-application-service.ts"), ]); const chatRead = llm.indexOf( "const chat = await chatStore.get(params.chatId)", @@ -486,37 +504,41 @@ test("persisted chat workspace ownership closes generation admission before setu assert.ok(prepare > admissionCheck); const updateHandler = ipcHandlerStart(workspaces, "workspaces:update"); - const beginMutation = workspaces.indexOf( - "workspaceMutationGate.begin(workspaceId)", - updateHandler, + assert.match( + workspaces.slice(updateHandler), + /workspaceApplicationService\.update\(asString\(id, "id"\), patch\)/u, + ); + const beginMutation = workspaceApplicationService.indexOf( + "deps.workspaceMutationGate.begin(id)", ); - const drainWorkspaceOperations = workspaces.indexOf( - "await workspaceOperationRegistry.cancelAndSettle(workspaceId)", + const drainWorkspaceOperations = workspaceApplicationService.indexOf( + "await deps.workspaceOperationRegistry.cancelAndSettle(id)", beginMutation, ); - const cancelGeneration = workspaces.indexOf( - "await llmClient.cancelWorkspaceAndSettle(existing.id)", + const cancelGeneration = workspaceApplicationService.indexOf( + "await deps.llmClient.cancelWorkspaceAndSettle(existing.id)", beginMutation, ); - const saveWorkspace = workspaces.indexOf( - "configStore.saveWorkspace(next)", + const saveWorkspace = workspaceApplicationService.indexOf( + "deps.configStore.saveWorkspace(next)", cancelGeneration, ); assert.ok(updateHandler >= 0); - assert.ok(beginMutation > updateHandler); + assert.ok(beginMutation >= 0); assert.ok(drainWorkspaceOperations > beginMutation); assert.ok(cancelGeneration > beginMutation); assert.ok(cancelGeneration > drainWorkspaceOperations); assert.ok(saveWorkspace > cancelGeneration); assert.match( - workspaces.slice(updateHandler, saveWorkspace), - /await llmClient\.cancelWorkspaceAndSettle\(existing\.id\)/u, + workspaceApplicationService.slice(beginMutation, saveWorkspace), + /await deps\.llmClient\.cancelWorkspaceAndSettle\(existing\.id\)/u, ); }); test("managed worktree deletion and terminal creation share workspace mutation admission", async () => { - const [workspaces, terminal, terminalService, git] = await Promise.all([ + const [workspaces, worktreeApplicationService, terminal, terminalService, git] = await Promise.all([ source("main/handlers/workspaces.ts"), + source("main/services/workspace-worktree-application-service.ts"), source("main/handlers/terminal.ts"), source("main/services/terminal.ts"), source("main/services/git.ts"), @@ -525,23 +547,26 @@ test("managed worktree deletion and terminal creation share workspace mutation a workspaces, "git:deleteManagedWorktree", ); - const beginMutation = workspaces.indexOf( - "workspaceMutationGate.begin(id)", - deleteHandler, + const beginMutation = worktreeApplicationService.indexOf( + "dependencies.beginWorkspaceMutation(workspaceId)", ); - const destructiveDelete = workspaces.indexOf( - "gitDeleteManagedWorktree(", - deleteHandler, + const destructiveDelete = worktreeApplicationService.indexOf( + "dependencies.deleteManagedWorktree(managed, signal)", + beginMutation, ); assert.ok(deleteHandler >= 0); - assert.ok(beginMutation > deleteHandler); - assert.ok(destructiveDelete > beginMutation); assert.match( workspaces.slice(deleteHandler), - /withWorkspaceRecordOperation\([\s\S]+worktreeRegistered:[\s\S]+gitManagedWorktreeRegistered\([\s\S]+managed\.worktreeGitDir,[\s\S]+managed\.ownershipToken/u, + /workspaceWorktreeApplicationService\.remove\(owner, id\)/u, ); + assert.ok(beginMutation >= 0); + assert.ok(destructiveDelete > beginMutation); assert.match( - workspaces.slice(deleteHandler), + worktreeApplicationService, + /environment\.runRecord\([\s\S]+worktreeRegistered:[\s\S]+managedWorktreeRegistered\(managed\)/u, + ); + assert.match( + worktreeApplicationService.slice(beginMutation), /reconciledResult: \(\) => \(\{ branchDeleted: false \}\)/u, ); assert.match( @@ -700,14 +725,16 @@ test("managed worktree cleanup is root-bound, resumable, and packaged as signed }); test("every workspace path capability is renderer-document owned and mutation admitted", async () => { - const [workspaces, operations, git] = await Promise.all([ + const [workspaces, environment, operations, git] = await Promise.all([ source("main/handlers/workspaces.ts"), + source("main/services/workspace-environment-application-service.ts"), source("main/services/workspace-operation-registry.ts"), source("main/services/git.ts"), ]); assert.match(workspaces, /rendererDocumentOwner\(\s*event,/u); - assert.match(workspaces, /admitRendererOwnedWorkspaceOperation\(/u); + assert.match(workspaces, /workspaceEnvironmentApplicationService\.run(?:Optional)?\(/u); + assert.match(environment, /admitOwnedWorkspaceOperation\(/u); assert.doesNotMatch(workspaces, /sender\.once\("destroyed"/u); assert.match(operations, /owner\.onInvalidated\(cancel\)/u); @@ -748,11 +775,12 @@ test("every workspace path capability is renderer-document owned and mutation ad }); test("managed worktree identity gates generation, terminal, scheduled, and workspace capabilities", async () => { - const [llm, terminal, scheduled, workspaces, admission] = await Promise.all([ + const [llm, terminal, scheduled, workspaces, environment, admission] = await Promise.all([ source("main/services/llm-client.ts"), source("main/handlers/terminal.ts"), source("main/services/schedule-execution.ts"), source("main/handlers/workspaces.ts"), + source("main/services/workspace-environment-application-service.ts"), source("main/services/managed-worktree-admission.ts"), ]); assert.match( @@ -776,8 +804,8 @@ test("managed worktree identity gates generation, terminal, scheduled, and works /executeLlm[\s\S]+assertManagedWorktreeAdmission\(workspace\)/u, ); assert.match( - workspaces, - /workspaceDirectory[\s\S]+assertManagedWorktreeAdmission\(workspace\)/u, + environment, + /resolve[\s\S]+assertManagedWorktreeAdmission\(workspace\)/u, ); assert.match( workspaces, @@ -790,9 +818,11 @@ test("managed worktree identity gates generation, terminal, scheduled, and works }); test("terminal writes pause across workspace mutations and documents lose PTYs on reload", async () => { - const [terminal, workspaces, main] = await Promise.all([ + const [terminal, workspaces, workspaceApplicationService, worktreeApplicationService, main] = await Promise.all([ source("main/handlers/terminal.ts"), source("main/handlers/workspaces.ts"), + source("main/services/workspace-application-service.ts"), + source("main/services/workspace-worktree-application-service.ts"), source("main/index.ts"), ]); const accessCheck = terminal.indexOf("async function ensureSessionAccess"); @@ -825,41 +855,46 @@ test("terminal writes pause across workspace mutations and documents lose PTYs o assert.ok(guardedWrite > writeHandler); const updateHandler = ipcHandlerStart(workspaces, "workspaces:update"); - const permissionChange = workspaces.indexOf( - "if (next.permission !== existing.permission)", - updateHandler, + assert.match( + workspaces.slice(updateHandler), + /workspaceApplicationService\.update\(asString\(id, "id"\), patch\)/u, + ); + const updateMethod = workspaceApplicationService.indexOf("async update("); + const permissionChange = workspaceApplicationService.indexOf( + "if (next.permission === existing.permission)", + updateMethod, ); - const updateTerminalClose = workspaces.indexOf( - "terminalService.closeForWorkspace(existing.id)", + const updateTerminalClose = workspaceApplicationService.indexOf( + "deps.terminalService.closeForWorkspace(existing.id)", permissionChange, ); - const updateScheduleRestoration = workspaces.lastIndexOf( + const updateScheduleRestoration = workspaceApplicationService.lastIndexOf( "withWorkspaceScheduleRestoration(", updateTerminalClose, ); - const updateScheduleCancel = workspaces.indexOf( - "await scheduleService.cancelWorkspace(existing.id)", + const updateScheduleCancel = workspaceApplicationService.indexOf( + "await deps.scheduleService.cancelWorkspace(existing.id)", permissionChange, ); assert.ok(updateTerminalClose > permissionChange); assert.ok(updateScheduleRestoration > permissionChange); assert.ok(updateTerminalClose > updateScheduleRestoration); - const updateGenerationDrain = workspaces.indexOf( - "await llmClient.cancelWorkspaceAndSettle(existing.id)", + const updateGenerationDrain = workspaceApplicationService.indexOf( + "await deps.llmClient.cancelWorkspaceAndSettle(existing.id)", permissionChange, ); assert.ok(updateGenerationDrain > updateTerminalClose); assert.ok(updateScheduleCancel > updateTerminalClose); - const updateSave = workspaces.indexOf( - "await configStore.saveWorkspace(next)", + const updateSave = workspaceApplicationService.indexOf( + "await deps.configStore.saveWorkspace(next)", updateScheduleCancel, ); - const armPostSaveResume = workspaces.indexOf( + const armPostSaveResume = workspaceApplicationService.indexOf( "ensureResumedOnExit()", updateSave, ); - const firstPostSaveResume = workspaces.indexOf( - "await scheduleService.resumeWorkspace(saved.id)", + const firstPostSaveResume = workspaceApplicationService.indexOf( + "await deps.scheduleService.resumeWorkspace(saved.id)", armPostSaveResume, ); assert.ok(updateSave > updateScheduleCancel); @@ -867,38 +902,43 @@ test("terminal writes pause across workspace mutations and documents lose PTYs o assert.ok(firstPostSaveResume > armPostSaveResume); const removeHandler = ipcHandlerStart(workspaces, "workspaces:remove"); - const removeTerminalClose = workspaces.indexOf( - "terminalService.closeForWorkspace(workspaceId)", - removeHandler, + assert.match( + workspaces.slice(removeHandler), + /workspaceApplicationService\.remove\(asString\(id, "id"\)\)/u, ); - const removeWorkspaceRead = workspaces.indexOf( - "await configStore.getWorkspace(workspaceId)", - removeHandler, + const removeMethod = workspaceApplicationService.indexOf("async remove("); + const removeTerminalClose = workspaceApplicationService.indexOf( + "deps.terminalService.closeForWorkspace(id)", + removeMethod, ); - const managedRemovalGuard = workspaces.indexOf( + const removeWorkspaceRead = workspaceApplicationService.indexOf( + "await deps.configStore.getWorkspace(id)", + removeMethod, + ); + const managedRemovalGuard = workspaceApplicationService.indexOf( "assertWorkspaceRecordRemovalAllowed(existing)", removeWorkspaceRead, ); - const removeOperationDrain = workspaces.indexOf( - "await workspaceOperationRegistry.cancelAndSettle(workspaceId)", - removeHandler, + const removeOperationDrain = workspaceApplicationService.indexOf( + "await deps.workspaceOperationRegistry.cancelAndSettle(id)", + removeMethod, ); - const removeScheduleRestoration = workspaces.indexOf( + const removeScheduleRestoration = workspaceApplicationService.indexOf( "withWorkspaceScheduleRestoration(", removeWorkspaceRead, ); - assert.ok(removeTerminalClose > removeHandler); - assert.ok(removeOperationDrain > removeTerminalClose); + assert.ok(removeTerminalClose > removeMethod); + assert.ok(removeOperationDrain > removeMethod); assert.ok(removeWorkspaceRead > removeOperationDrain); - assert.ok(removeWorkspaceRead > removeTerminalClose); - assert.ok(managedRemovalGuard > removeWorkspaceRead); + assert.ok(removeTerminalClose > removeWorkspaceRead); + assert.ok(managedRemovalGuard > removeTerminalClose); assert.ok(removeScheduleRestoration > managedRemovalGuard); - const removeGenerationDrain = workspaces.indexOf( - "await llmClient.cancelWorkspaceAndSettle(workspaceId)", + const removeGenerationDrain = workspaceApplicationService.indexOf( + "await deps.llmClient.cancelWorkspaceAndSettle(id)", removeWorkspaceRead, ); - const removeRecord = workspaces.indexOf( - "await configStore.removeWorkspace(workspaceId)", + const removeRecord = workspaceApplicationService.indexOf( + "await deps.configStore.removeWorkspace(id)", removeGenerationDrain, ); assert.ok(removeGenerationDrain > removeWorkspaceRead); @@ -909,63 +949,68 @@ test("terminal writes pause across workspace mutations and documents lose PTYs o workspaces, "git:deleteManagedWorktree", ); - const managedTerminalClose = workspaces.indexOf( - "terminalService.closeForWorkspace(id)", - managedDelete, + assert.match( + workspaces.slice(managedDelete), + /workspaceWorktreeApplicationService\.remove\(owner, id\)/u, + ); + const managedRemove = worktreeApplicationService.indexOf("const remove = async ("); + const managedTerminalClose = worktreeApplicationService.indexOf( + "dependencies.closeWorkspaceTerminals(workspaceId)", + managedRemove, ); - const managedScheduleCancel = workspaces.indexOf( - "await scheduleService.cancelWorkspace(id)", - managedDelete, + const managedScheduleCancel = worktreeApplicationService.indexOf( + "await dependencies.cancelWorkspaceSchedules(workspaceId)", + managedTerminalClose, ); - const managedScheduleRestoration = workspaces.indexOf( + const managedScheduleRestoration = worktreeApplicationService.indexOf( "withWorkspaceScheduleRestoration(", - managedDelete, + managedRemove, ); - assert.ok(managedTerminalClose > managedDelete); - assert.ok(managedScheduleRestoration > managedDelete); + assert.ok(managedTerminalClose > managedRemove); + assert.ok(managedScheduleRestoration > managedRemove); assert.ok(managedTerminalClose > managedScheduleRestoration); - const managedGenerationDrain = workspaces.indexOf( - "await llmClient.cancelWorkspaceAndSettle(id)", + const managedGenerationDrain = worktreeApplicationService.indexOf( + "await dependencies.cancelWorkspaceGeneration(workspaceId)", managedTerminalClose, ); - const managedOperationDrain = workspaces.indexOf( - "await workspaceOperationRegistry.cancelAndSettle(id", - managedDelete, + const managedOperationDrain = worktreeApplicationService.indexOf( + "await dependencies.cancelWorkspaceOperations(workspaceId, signal)", + managedRemove, ); - const managedWorktreeRemoval = workspaces.indexOf( - "const result = await removeManagedWorkspace", + const managedWorktreeRemoval = worktreeApplicationService.indexOf( + "const deletion = await removeManagedWorkspace", managedGenerationDrain, ); - const managedDeletionFinalize = workspaces.indexOf( - "await gitFinalizeManagedWorktreeDeletion", + const managedDeletionFinalize = worktreeApplicationService.indexOf( + "await dependencies.finalizeManagedWorktreeDeletion(managed)", managedWorktreeRemoval, ); - const managedDeleteReturn = workspaces.indexOf( - "return result", + const managedDeleteReturn = worktreeApplicationService.indexOf( + "return deletion", managedDeletionFinalize, ); assert.ok(managedGenerationDrain > managedTerminalClose); - assert.ok(managedOperationDrain > managedDelete); + assert.ok(managedOperationDrain > managedRemove); assert.ok(managedGenerationDrain > managedOperationDrain); assert.ok(managedWorktreeRemoval > managedGenerationDrain); assert.ok(managedDeletionFinalize > managedWorktreeRemoval); assert.ok(managedDeleteReturn > managedDeletionFinalize); assert.ok(managedScheduleCancel > managedTerminalClose); assert.match( - workspaces.slice(managedDelete), + worktreeApplicationService.slice(managedRemove), /destructiveMutationAttempted:[\s\S]+GitManagedWorktreeDeleteError[\s\S]+destructiveMutationAttempted/u, ); assert.match( - workspaces.slice(managedDelete), - /worktreeUsable:[\s\S]+gitManagedWorktreeUsable\(/u, + worktreeApplicationService.slice(managedRemove), + /worktreeUsable:[\s\S]+managedWorktreeUsable\(managed\)/u, ); assert.match( - workspaces.slice(managedDelete), - /deletionPending:[\s\S]+gitManagedWorktreeDeletionPending\(/u, + worktreeApplicationService.slice(managedRemove), + /deletionPending:[\s\S]+managedWorktreeDeletionPending\(managed\)/u, ); assert.match( - workspaces, - /commitManagedWorktreeCreation\([\s\S]+removeWorkspaceRecord:[\s\S]+configStore\.removeWorkspace\(saved\.id\)[\s\S]+rollbackWorktree:/u, + worktreeApplicationService, + /commitManagedWorktreeCreation\([\s\S]+removeWorkspaceRecord:[\s\S]+removeWorkspace\(savedWorkspace\.id\)[\s\S]+rollbackWorktree:/u, ); const didStartLoading = main.indexOf('webContents.on("did-start-loading"'); diff --git a/main/services/telegram/telegram-controls.test.ts b/main/services/telegram/telegram-controls.test.ts index eb320736..01d7e2c7 100644 --- a/main/services/telegram/telegram-controls.test.ts +++ b/main/services/telegram/telegram-controls.test.ts @@ -10,8 +10,21 @@ import { buildThinkingMenu, commandArgument, commandName, + visibleTelegramModelChoices, } from "./telegram-controls.js"; +test("Telegram model choices omit hidden models without invalidating explicit execution state", () => { + const choices = [ + { providerId: "google", providerLabel: "Google", model: "pro", reasoning: true }, + { providerId: "google", providerLabel: "Google", model: "flash", reasoning: false }, + ]; + assert.deepEqual( + visibleTelegramModelChoices(choices, { google: ["pro"] }).map((choice) => choice.model), + ["flash"], + ); + assert.equal(choices[0]?.model, "pro"); +}); + test("command catalog exposes the first-class operator controls", () => { assert.deepEqual( TELEGRAM_COMMANDS.slice(0, 6).map((command) => command.command), @@ -33,23 +46,44 @@ test("main menu projects model, thinking, queue, workspace, and active controls" workspaceLabel: "Aiden", }); const callbacks = menu.inline_keyboard.flat().map((button) => button.callback_data); - for (const expected of ["menu:model", "menu:thinking", "menu:queue", "menu:workspace", "turn:abort"]) { + for (const expected of [ + "menu:model", + "menu:thinking", + "menu:queue", + "menu:workspace", + "turn:abort", + ]) { assert.ok(callbacks.includes(expected)); } }); test("model, thinking, and queue menus carry short callback identities", () => { - const model = buildModelMenu([ - { providerId: "p", providerLabel: "Provider", model: "m", reasoning: true }, - ], "p", "m", 0); - assert.equal(model.markup.inline_keyboard[model.markup.inline_keyboard.length - 1]?.[0]?.callback_data, "model:set:0"); + const model = buildModelMenu( + [{ providerId: "p", providerLabel: "Provider", model: "m", reasoning: true }], + "p", + "m", + 0, + ); + assert.equal( + model.markup.inline_keyboard[model.markup.inline_keyboard.length - 1]?.[0]?.callback_data, + "model:set:0", + ); const thinking = buildThinkingMenu("medium", ["off", "medium", "high"]); - assert.equal(thinking.markup.inline_keyboard[thinking.markup.inline_keyboard.length - 1]?.[0]?.callback_data, "thinking:set:high"); + assert.equal( + thinking.markup.inline_keyboard[thinking.markup.inline_keyboard.length - 1]?.[0]?.callback_data, + "thinking:set:high", + ); const item = { id: 9, lane: "default" as const, text: "Review this", chatId: 1, ownerUserId: 2 }; - assert.equal(buildQueueMenu([item]).markup.inline_keyboard[2]?.[0]?.callback_data, "queue:item:9"); - assert.equal(buildQueueItemMenu(item).markup.inline_keyboard[1]?.[1]?.callback_data, "queue:delete:9"); + assert.equal( + buildQueueMenu([item]).markup.inline_keyboard[2]?.[0]?.callback_data, + "queue:item:9", + ); + assert.equal( + buildQueueItemMenu(item).markup.inline_keyboard[1]?.[1]?.callback_data, + "queue:delete:9", + ); }); test("Telegram settings expose native rendering and voice policy controls", () => { diff --git a/main/services/telegram/telegram-controls.ts b/main/services/telegram/telegram-controls.ts index 3d0c08c9..dc2c474d 100644 --- a/main/services/telegram/telegram-controls.ts +++ b/main/services/telegram/telegram-controls.ts @@ -4,12 +4,13 @@ // effects stay in telegram-service-core so this module remains a leaf in the // Telegram domain DAG. -import type { - TelegramBotCommand, - TelegramInlineKeyboardMarkup, -} from "./telegram-bot-api.js"; +import type { TelegramBotCommand, TelegramInlineKeyboardMarkup } from "./telegram-bot-api.js"; import type { QueuedTelegramTurn } from "./telegram-queue.js"; import type { GenerationThinkingLevel } from "../../../renderer/shared/generation-thinking.js"; +import { + isModelHidden, + type HiddenModelsByProvider, +} from "../../../renderer/shared/model-visibility.js"; export const TELEGRAM_COMMANDS = [ { command: "start", description: "Open the Aiden operator menu" }, @@ -38,6 +39,15 @@ export interface TelegramModelChoice { thinkingLevels?: readonly GenerationThinkingLevel[]; } +export function visibleTelegramModelChoices( + models: readonly TelegramModelChoice[], + hiddenModelsByProvider: HiddenModelsByProvider | undefined, +): readonly TelegramModelChoice[] { + return models.filter( + (choice) => !isModelHidden(hiddenModelsByProvider, choice.providerId, choice.model), + ); +} + export interface TelegramControlStatus { botUsername?: string; allowedUserId?: number; @@ -95,23 +105,37 @@ export function buildMainMenu(status: TelegramControlStatus): TelegramInlineKeyb }, ], [{ text: `🧠 Thinking: ${status.thinkingLevel}`, callback_data: "menu:thinking" }], - [{ text: `${status.queueCount ? "⏳" : "⌛"} Queue: ${status.queueCount}`, callback_data: "menu:queue" }], - [{ text: `🗂 Workspace: ${truncate(status.workspaceLabel, 40)}`, callback_data: "menu:workspace" }], + [ + { + text: `${status.queueCount ? "⏳" : "⌛"} Queue: ${status.queueCount}`, + callback_data: "menu:queue", + }, + ], + [ + { + text: `🗂 Workspace: ${truncate(status.workspaceLabel, 40)}`, + callback_data: "menu:workspace", + }, + ], [ { text: "🗜 Compact", callback_data: "compact:ask" }, { text: "⚙️ Settings", callback_data: "menu:settings" }, ], ...(status.active - ? [[ - { text: "⏭ Next", callback_data: "turn:next" }, - { text: "⏹ Abort", callback_data: "turn:abort" }, - { text: "🛑 Stop all", callback_data: "turn:stop" }, - ]] + ? [ + [ + { text: "⏭ Next", callback_data: "turn:next" }, + { text: "⏹ Abort", callback_data: "turn:abort" }, + { text: "🛑 Stop all", callback_data: "turn:stop" }, + ], + ] : []), - ...(status.extensionSections ?? []).map((section) => [{ - text: section.label, - callback_data: section.callbackData, - }]), + ...(status.extensionSections ?? []).map((section) => [ + { + text: section.label, + callback_data: section.callbackData, + }, + ]), ], }; } @@ -128,10 +152,12 @@ export function buildModelMenu( const start = safePage * pageSize; const rows = models.slice(start, start + pageSize).map((choice, offset) => { const selected = choice.providerId === activeProviderId && choice.model === activeModel; - return [{ - text: `${selected ? "🟢" : "⚫"} ${truncate(`${choice.providerLabel}/${choice.modelLabel ?? choice.model}`, 48)}`, - callback_data: `model:set:${start + offset}`, - }]; + return [ + { + text: `${selected ? "🟢" : "⚫"} ${truncate(`${choice.providerLabel}/${choice.modelLabel ?? choice.model}`, 48)}`, + callback_data: `model:set:${start + offset}`, + }, + ]; }); return { text: `🤖 Choose a model\n\n${models.length ? "The selected model is used for future Telegram turns." : "No configured models are available. Add a provider in Aiden Settings."}`, @@ -139,11 +165,16 @@ export function buildModelMenu( inline_keyboard: [ [{ text: "⬆️ Main menu", callback_data: "menu:back" }], ...(totalPages > 1 - ? [[ - { text: "⬅️", callback_data: `model:page:${Math.max(0, safePage - 1)}` }, - { text: `${safePage + 1}/${totalPages}`, callback_data: "noop" }, - { text: "➡️", callback_data: `model:page:${Math.min(totalPages - 1, safePage + 1)}` }, - ]] + ? [ + [ + { text: "⬅️", callback_data: `model:page:${Math.max(0, safePage - 1)}` }, + { text: `${safePage + 1}/${totalPages}`, callback_data: "noop" }, + { + text: "➡️", + callback_data: `model:page:${Math.min(totalPages - 1, safePage + 1)}`, + }, + ], + ] : []), ...rows, ], @@ -162,10 +193,12 @@ export function buildThinkingMenu( markup: { inline_keyboard: [ [{ text: "⬆️ Main menu", callback_data: "menu:back" }], - ...levels.map((level) => [{ - text: `${level === current ? "🟢" : "⚫"} ${level}`, - callback_data: `thinking:set:${level}`, - }]), + ...levels.map((level) => [ + { + text: `${level === current ? "🟢" : "⚫"} ${level}`, + callback_data: `thinking:set:${level}`, + }, + ]), ], }, }; @@ -175,10 +208,12 @@ export function buildQueueMenu(items: readonly QueuedTelegramTurn[]): { text: string; markup: TelegramInlineKeyboardMarkup; } { - const rows = items.map((item, index) => [{ - text: `${index + 1}. ${item.lane === "priority" ? "⚡ " : ""}${truncate(item.text.replace(/\s+/gu, " "), 42)}`, - callback_data: `queue:item:${item.id}`, - }]); + const rows = items.map((item, index) => [ + { + text: `${index + 1}. ${item.lane === "priority" ? "⚡ " : ""}${truncate(item.text.replace(/\s+/gu, " "), 42)}`, + callback_data: `queue:item:${item.id}`, + }, + ]); return { text: items.length ? `⏳ Queue\n\n${items.length} prompt${items.length === 1 ? "" : "s"} waiting.` @@ -204,7 +239,10 @@ export function buildQueueItemMenu(item: QueuedTelegramTurn): { inline_keyboard: [ [{ text: "⬆️ Back", callback_data: "menu:queue" }], [ - { text: item.lane === "priority" ? "🟡 Priority" : "⚡ Make priority", callback_data: `queue:priority:${item.id}` }, + { + text: item.lane === "priority" ? "🟡 Priority" : "⚡ Make priority", + callback_data: `queue:priority:${item.id}`, + }, { text: "🗑 Delete", callback_data: `queue:delete:${item.id}` }, ], ], @@ -219,20 +257,24 @@ export function confirmationMenu( return { text: `${escapeHtml(title)}`, markup: { - inline_keyboard: [[ - { text: "✅ Confirm", callback_data: confirmData }, - { text: "❌ Cancel", callback_data: "menu:back" }, - ]], + inline_keyboard: [ + [ + { text: "✅ Confirm", callback_data: confirmData }, + { text: "❌ Cancel", callback_data: "menu:back" }, + ], + ], }, }; } -export function buildSettingsMenu(options: { - draftPreviews?: boolean; - activity?: "quiet" | "thinking" | "tools" | "verbose"; - rendering?: "rich" | "html"; - voiceMode?: "hidden" | "mirror" | "always"; -} = {}): { text: string; markup: TelegramInlineKeyboardMarkup } { +export function buildSettingsMenu( + options: { + draftPreviews?: boolean; + activity?: "quiet" | "thinking" | "tools" | "verbose"; + rendering?: "rich" | "html"; + voiceMode?: "hidden" | "mirror" | "always"; + } = {}, +): { text: string; markup: TelegramInlineKeyboardMarkup } { return { text: [ "⚙️ Telegram agent settings", @@ -245,22 +287,30 @@ export function buildSettingsMenu(options: { [{ text: "🤖 Model", callback_data: "menu:model" }], [{ text: "🧠 Thinking", callback_data: "menu:thinking" }], [{ text: "🗂 Workspace", callback_data: "menu:workspace" }], - [{ - text: `${options.draftPreviews ? "🟢" : "⚫"} Draft previews`, - callback_data: "settings:drafts:toggle", - }], - [{ - text: `🔧 Activity: ${options.activity ?? "quiet"}`, - callback_data: "settings:activity:next", - }], - [{ - text: `📝 Rendering: ${options.rendering ?? "rich"}`, - callback_data: "settings:rendering:toggle", - }], - [{ - text: `👄 Voice: ${options.voiceMode ?? "hidden"}`, - callback_data: "settings:voice:next", - }], + [ + { + text: `${options.draftPreviews ? "🟢" : "⚫"} Draft previews`, + callback_data: "settings:drafts:toggle", + }, + ], + [ + { + text: `🔧 Activity: ${options.activity ?? "quiet"}`, + callback_data: "settings:activity:next", + }, + ], + [ + { + text: `📝 Rendering: ${options.rendering ?? "rich"}`, + callback_data: "settings:rendering:toggle", + }, + ], + [ + { + text: `👄 Voice: ${options.voiceMode ?? "hidden"}`, + callback_data: "settings:voice:next", + }, + ], ], }, }; @@ -277,21 +327,30 @@ export function buildWorkspaceMenu( "", `Current: ${escapeHtml(selected?.name ?? (selectedWorkspaceId ? "Unavailable" : "Assistant only"))}`, "", - ...workspaces.map((workspace, index) => `${index + 1}. ${escapeHtml(workspace.name)}\n ${escapeHtml(workspace.folderPath)}`), - ...(workspaces.length ? ["", "You can also use /workspace <number>."] : []), + ...workspaces.map( + (workspace, index) => + `${index + 1}. ${escapeHtml(workspace.name)}\n ${escapeHtml(workspace.folderPath)}`, + ), + ...(workspaces.length + ? ["", "You can also use /workspace <number>."] + : []), "Workspace authority is captured when each prompt enters the queue.", ].join("\n"), markup: { inline_keyboard: [ [{ text: "⬆️ Main menu", callback_data: "menu:back" }], - [{ - text: `${selectedWorkspaceId === undefined ? "🟢" : "⚫"} Assistant only`, - callback_data: "workspace:set:off", - }], - ...workspaces.map((workspace, index) => [{ - text: `${workspace.id === selectedWorkspaceId ? "🟢" : "⚫"} ${truncate(workspace.name, 44)}`, - callback_data: `workspace:set:${index}`, - }]), + [ + { + text: `${selectedWorkspaceId === undefined ? "🟢" : "⚫"} Assistant only`, + callback_data: "workspace:set:off", + }, + ], + ...workspaces.map((workspace, index) => [ + { + text: `${workspace.id === selectedWorkspaceId ? "🟢" : "⚫"} ${truncate(workspace.name, 44)}`, + callback_data: `workspace:set:${index}`, + }, + ]), ], }, }; diff --git a/main/services/telegram/telegram-service-core.test.ts b/main/services/telegram/telegram-service-core.test.ts index d6cd7a23..4aab1aa4 100644 --- a/main/services/telegram/telegram-service-core.test.ts +++ b/main/services/telegram/telegram-service-core.test.ts @@ -147,11 +147,24 @@ function createMockApi(opts: MockApiOptions) { text: p.text, }; }, - async sendRichMessage(p: { chatId: number; threadId?: number; markdown: string }): Promise { + async sendRichMessage(p: { + chatId: number; + threadId?: number; + markdown: string; + }): Promise { richMessages.push(p); - return { message_id: richMessages.length, chat: { id: p.chatId, type: "private" }, date: 0, text: p.markdown }; + return { + message_id: richMessages.length, + chat: { id: p.chatId, type: "private" }, + date: 0, + text: p.markdown, + }; }, - async sendVoice(p: { chatId: number; threadId?: number; bytes: Uint8Array }): Promise { + async sendVoice(p: { + chatId: number; + threadId?: number; + bytes: Uint8Array; + }): Promise { voiceMessages.push(p); return { message_id: voiceMessages.length, chat: { id: p.chatId, type: "private" }, date: 0 }; }, @@ -279,7 +292,12 @@ function createMockTurn(opts: MockTurnOptions = {}) { const pendingStart = new EventEmitter(); let pendingOwner: TurnOwner | undefined; const createdChats: Array<{ id: string; workspaceId?: string }> = []; - const startedParams: Array<{ chatId: string; workspaceId?: string; mode?: string; content?: string }> = []; + const startedParams: Array<{ + chatId: string; + workspaceId?: string; + mode?: string; + content?: string; + }> = []; const llmClient = { beginChatTurn() { if (opts.busy) return null; @@ -294,7 +312,12 @@ function createMockTurn(opts: MockTurnOptions = {}) { }, async start( streamId: string, - _params: { chatId: string; workspaceId?: string; mode?: string; messages?: Array<{ content: string }> }, + _params: { + chatId: string; + workspaceId?: string; + mode?: string; + messages?: Array<{ content: string }>; + }, owner: TurnOwner, _options: unknown, ): Promise { @@ -441,7 +464,9 @@ interface HarnessOptions { resolveThreadWorkspace?: (threadId: number) => Promise; clearThreadTargets?: () => Promise; listModels?: () => Promise; - applyModelSelection?: (choice: import("./telegram-controls.js").TelegramModelChoice) => Promise; + applyModelSelection?: ( + choice: import("./telegram-controls.js").TelegramModelChoice, + ) => Promise; abortChat?: (chatId: string) => Promise; mediaGroupDebounceMs?: number; handleExtensionUpdate?: import("./telegram-service-core.js").TelegramServiceDeps["handleExtensionUpdate"]; @@ -950,7 +975,11 @@ test("always voice mode intercepts automatic text and falls back only when synth telegramRendering: "rich", telegramVoiceMode: "always", batches: [[makeUpdate(1, makeMessage(10, owner, "Speak"))]], - synthesizeVoice: async () => ({ bytes: new Uint8Array([1]), name: "voice.ogg", mimeType: "audio/ogg" }), + synthesizeVoice: async () => ({ + bytes: new Uint8Array([1]), + name: "voice.ogg", + mimeType: "audio/ogg", + }), }); await voiced.service.start(); await waitFor(() => voiced.api.voiceMessages.length === 1); @@ -1039,13 +1068,20 @@ test("thumbs-down reaction removes the matching queued prompt", async () => { pendingTurn: true, delayAfterFirstBatch: true, batches: [ - [makeUpdate(1, makeMessage(10, owner, "active")), makeUpdate(2, makeMessage(11, owner, "queued"))], + [ + makeUpdate(1, makeMessage(10, owner, "active")), + makeUpdate(2, makeMessage(11, owner, "queued")), + ], [reaction], ], autoStop: false, }); await service.start(); - await waitFor(() => turnMock.startCalls() === 1 && api.sentMessages.some(({ text }) => text.includes("Queued prompt removed"))); + await waitFor( + () => + turnMock.startCalls() === 1 && + api.sentMessages.some(({ text }) => text.includes("Queued prompt removed")), + ); assert.equal(service.queueSize, 0); turnMock.completePendingTurn(); service.stop(); @@ -1060,18 +1096,23 @@ test("reaction shortcut variants normalize variation selectors and promote queue autoStop: false, delayAfterFirstBatch: true, batches: [ - [makeUpdate(1, makeMessage(10, owner, "active")), makeUpdate(2, makeMessage(11, owner, "waiting"))], - [{ - update_id: 3, - message_reaction: { - chat: { id: 100, type: "private" }, - message_id: 11, - user: owner, - old_reaction: [], - new_reaction: [{ type: "emoji", emoji: "❤️" }], - date: 0, + [ + makeUpdate(1, makeMessage(10, owner, "active")), + makeUpdate(2, makeMessage(11, owner, "waiting")), + ], + [ + { + update_id: 3, + message_reaction: { + chat: { id: 100, type: "private" }, + message_id: 11, + user: owner, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "❤️" }], + date: 0, + }, }, - }], + ], ], }); await result.service.start(); @@ -1088,7 +1129,11 @@ test("thread provisioning reports the BotFather capability prerequisite", async }); await result.service.start(); await assert.rejects(result.service.ensureThreads(), /BotFather/u); - assert.ok(result.service.getStatus().recentDiagnostics.some(({ message }) => message.includes("BotFather"))); + assert.ok( + result.service + .getStatus() + .recentDiagnostics.some(({ message }) => message.includes("BotFather")), + ); }); test("reaction updates are offered to registered extension routing first", async () => { @@ -1097,17 +1142,21 @@ test("reaction updates are offered to registered extension routing first", async const { service } = harness({ enabled: true, allowedUserId: 42, - batches: [[{ - update_id: 1, - message_reaction: { - chat: { id: 100, type: "private" }, - message_id: 10, - user: owner, - old_reaction: [], - new_reaction: [{ type: "emoji", emoji: "👍" }], - date: 0, - }, - }]], + batches: [ + [ + { + update_id: 1, + message_reaction: { + chat: { id: 100, type: "private" }, + message_id: 10, + user: owner, + old_reaction: [], + new_reaction: [{ type: "emoji", emoji: "👍" }], + date: 0, + }, + }, + ], + ], handleExtensionUpdate: async () => { extensionCalls += 1; return true; @@ -1126,7 +1175,7 @@ test("thread messages capture their durable workspace and replies stay in the th allowedUserId: 42, telegramRendering: "rich", workspaces: [{ id: "project", name: "Project", folderPath: "/tmp/project" }], - resolveThreadWorkspace: async (threadId) => threadId === 77 ? "project" : undefined, + resolveThreadWorkspace: async (threadId) => (threadId === 77 ? "project" : undefined), batches: [[makeUpdate(1, message)]], }); await service.start(); @@ -1140,7 +1189,9 @@ test("pairing reset clears queued work and durable thread bindings", async () => const { service, config } = harness({ enabled: true, allowedUserId: 42, - clearThreadTargets: async () => { cleared += 1; }, + clearThreadTargets: async () => { + cleared += 1; + }, batches: [], autoStop: false, }); @@ -1171,8 +1222,14 @@ test("offset persistence failure is diagnostic and polling remains live", async await originalPersist(offset); }; await service.start(); - await waitFor(() => api.sentMessages.filter(({ text }) => text.includes("Aiden Telegram Agent")).length === 2); - assert.ok(service.getStatus().recentDiagnostics.some(({ message }) => message.includes("disk unavailable"))); + await waitFor( + () => api.sentMessages.filter(({ text }) => text.includes("Aiden Telegram Agent")).length === 2, + ); + assert.ok( + service + .getStatus() + .recentDiagnostics.some(({ message }) => message.includes("disk unavailable")), + ); }); test("active-run model switching persists first, aborts safely, and queues a continuation", async () => { @@ -1195,13 +1252,22 @@ test("active-run model switching persists first, aborts safely, and queues a con autoStop: false, batches: [ [makeUpdate(1, makeMessage(10, owner, "long task"))], - [{ - update_id: 2, - callback_query: { id: "model", from: owner, message: controlMessage, data: "model:set:0" }, - }], + [ + { + update_id: 2, + callback_query: { + id: "model", + from: owner, + message: controlMessage, + data: "model:set:0", + }, + }, + ], ], listModels: async () => [choice], - applyModelSelection: async (selected) => { applied.push(selected); }, + applyModelSelection: async (selected) => { + applied.push(selected); + }, abortChat: async () => { aborts += 1; finishActive(); @@ -1211,6 +1277,39 @@ test("active-run model switching persists first, aborts safely, and queues a con await result.service.start(); await waitFor(() => applied.length === 1 && aborts === 1 && result.turnMock.startCalls() === 2); assert.deepEqual(applied, [choice]); - assert.match(result.turnMock.startedParams()[1]?.content ?? "", /Continue the interrupted task using Next\/next-model/); + assert.match( + result.turnMock.startedParams()[1]?.content ?? "", + /Continue the interrupted task using Next\/next-model/, + ); + result.service.stop(); +}); + +test("a stale model callback cannot select a model omitted by the current visible catalog", async () => { + const owner = person(42); + const applied: unknown[] = []; + const result = harness({ + enabled: true, + allowedUserId: 42, + batches: [ + [ + { + update_id: 1, + callback_query: { + id: "hidden-model", + from: owner, + message: makeMessage(20, BOT, "model menu"), + data: "model:set:0", + }, + }, + ], + ], + listModels: async () => [], + applyModelSelection: async (selected) => { + applied.push(selected); + }, + }); + await result.service.start(); + await waitFor(() => result.api.answerCallbackQueryCalls() > 0); + assert.deepEqual(applied, []); result.service.stop(); }); diff --git a/main/services/telegram/telegram-service.ts b/main/services/telegram/telegram-service.ts index 2f8fb07b..00df1ecd 100644 --- a/main/services/telegram/telegram-service.ts +++ b/main/services/telegram/telegram-service.ts @@ -33,7 +33,7 @@ import { mkdir, readFile, readdir, realpath, stat, unlink, writeFile } from "nod import path from "node:path"; import { randomUUID } from "node:crypto"; import type { TelegramModelChoice } from "./telegram-controls.js"; -import { TELEGRAM_COMMANDS } from "./telegram-controls.js"; +import { TELEGRAM_COMMANDS, visibleTelegramModelChoices } from "./telegram-controls.js"; import { getTelegramExtensions } from "./telegram-extension-registry.js"; import { DEFAULT_TELEGRAM_PROFILE, @@ -47,8 +47,13 @@ import { } from "./telegram-profile-config.js"; import { createTelegramThreadStore } from "./telegram-thread-store.js"; import { createTelegramOwnershipLease } from "./telegram-ownership.js"; -import { chunkForTelegram, chunkRichMarkdown, markdownToTelegramHtml } from "./telegram-markdown.js"; +import { + chunkForTelegram, + chunkRichMarkdown, + markdownToTelegramHtml, +} from "./telegram-markdown.js"; import { registerTelegramDirectRuntime } from "./telegram-direct-runtime.js"; +import { firstVisibleModelForProvider } from "../../../renderer/shared/model-visibility.js"; export const TELEGRAM_PROVIDER_ID = "telegram"; let profileSettingsMutation = Promise.resolve(); @@ -57,7 +62,10 @@ async function getProfileSettings(profile: string) { return projectTelegramProfile(await configStore.getSettings(), profile); } -async function setProfileSettings(profile: string, patch: Partial) { +async function setProfileSettings( + profile: string, + patch: Partial, +) { let result: import("../types.js").AppSettings | undefined; const operation = profileSettingsMutation.then(async () => { const current = await configStore.getSettings(); @@ -66,7 +74,10 @@ async function setProfileSettings(profile: string, patch: Partial undefined, () => undefined); + profileSettingsMutation = operation.then( + () => undefined, + () => undefined, + ); await operation; return result!; } @@ -85,7 +96,11 @@ async function resolveProvider(profile = DEFAULT_TELEGRAM_PROFILE): Promise<{ (await configStore.getProvider(providerId)); if (!provider) return null; const model = - settings.telegramModel ?? settings.lastModel ?? provider.defaultModel ?? provider.models[0]; + settings.telegramModel ?? + firstVisibleModelForProvider(settings.hiddenModelsByProvider, providerId, provider.models, [ + settings.lastProviderId === providerId ? settings.lastModel : undefined, + provider.defaultModel, + ]); if (!model) return null; return { providerId, model, provider }; } @@ -101,10 +116,11 @@ async function resolveWorkspace(workspaceId?: string): Promise { - const [builtin, custom, codex] = await Promise.all([ + const [builtin, custom, codex, settings] = await Promise.all([ providerRegistry.listBuiltinProviders(), listProvidersWithLegacyPiCredentialMigration(), providerRegistry.codex.snapshot().catch(() => null), + configStore.getSettings(), ]); const byId = new Map(); for (const provider of [...builtin, ...custom]) { @@ -118,15 +134,18 @@ async function listTelegramModels(): Promise { baseUrl: "", models: codex.models.map((model) => model.id), modelMetadata: Object.fromEntries( - codex.models.map((model) => [model.id, { - source: "provider" as const, - name: model.name, - type: "llm" as const, - vision: model.vision, - reasoning: model.reasoning, - thinkingLevels: model.thinkingLevels, - contextLength: model.contextWindow, - }]), + codex.models.map((model) => [ + model.id, + { + source: "provider" as const, + name: model.name, + type: "llm" as const, + vision: model.vision, + reasoning: model.reasoning, + thinkingLevels: model.thinkingLevels, + contextLength: model.contextWindow, + }, + ]), ), defaultModel: codex.models[0]?.id, needsKey: true, @@ -134,7 +153,7 @@ async function listTelegramModels(): Promise { isBuiltin: true, }); } - return [...byId.values()].flatMap((provider) => + const models = [...byId.values()].flatMap((provider) => provider.models.map((model) => { const metadata = provider.modelMetadata?.[model]; return { @@ -147,28 +166,44 @@ async function listTelegramModels(): Promise { }; }), ); + return visibleTelegramModelChoices(models, settings.hiddenModelsByProvider); } async function readWorkspaceAttachment(workspaceId: string | undefined, requestedPath: string) { if (!workspaceId) throw new Error("Choose a folder workspace before attaching local files."); const workspace = await configStore.getWorkspace(workspaceId); - if (!isTelegramFolderWorkspace(workspace) || !workspace.folderPath) throw new Error("The selected workspace is unavailable."); + if (!isTelegramFolderWorkspace(workspace) || !workspace.folderPath) + throw new Error("The selected workspace is unavailable."); const folderPath = workspace.folderPath; const root = await realpath(folderPath); - const candidate = path.isAbsolute(requestedPath) ? requestedPath : path.resolve(root, requestedPath); + const candidate = path.isAbsolute(requestedPath) + ? requestedPath + : path.resolve(root, requestedPath); const resolved = await realpath(candidate); const relative = path.relative(root, resolved); - if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Attachments must stay inside the selected workspace."); + if (relative.startsWith("..") || path.isAbsolute(relative)) + throw new Error("Attachments must stay inside the selected workspace."); const metadata = await stat(resolved); if (!metadata.isFile()) throw new Error("The attachment is not a regular file."); if (metadata.size > 50 * 1024 * 1024) throw new Error("Telegram documents are limited to 50 MB."); const extension = path.extname(resolved).toLowerCase(); - const mimeType = ({ - ".pdf": "application/pdf", ".json": "application/json", ".txt": "text/plain", - ".md": "text/markdown", ".png": "image/png", ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", ".webp": "image/webp", ".zip": "application/zip", - ".csv": "text/csv", ".mp4": "video/mp4", ".mp3": "audio/mpeg", - } as Record)[extension] ?? "application/octet-stream"; + const mimeType = + ( + { + ".pdf": "application/pdf", + ".json": "application/json", + ".txt": "text/plain", + ".md": "text/markdown", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".zip": "application/zip", + ".csv": "text/csv", + ".mp4": "video/mp4", + ".mp3": "audio/mpeg", + } as Record + )[extension] ?? "application/octet-stream"; return { bytes: await readFile(resolved), name: path.basename(resolved), mimeType }; } @@ -182,7 +217,12 @@ function validateVoiceResult( throw new Error("Telegram voice messages are limited to 20 MB."); } const name = voice.name ?? "aiden-voice.ogg"; - if (!/\.(?:ogg|opus)$/iu.test(name) || (voice.mimeType !== undefined && voice.mimeType !== "audio/ogg" && voice.mimeType !== "audio/opus")) { + if ( + !/\.(?:ogg|opus)$/iu.test(name) || + (voice.mimeType !== undefined && + voice.mimeType !== "audio/ogg" && + voice.mimeType !== "audio/opus") + ) { throw new Error("Telegram-native voice providers must return OGG/Opus audio."); } return { ...voice, name }; @@ -258,15 +298,24 @@ export function createTelegramService(profileName = DEFAULT_TELEGRAM_PROFILE) { const settings = await getProfileSettings(profile); if (!settings.telegramThreadedMode) return; const workspaces = (await configStore.listWorkspaces()).filter(isTelegramFolderWorkspace); - const removed = await threadStore.retainWorkspaces(new Set(workspaces.map(({ id }) => id)), chatId); + const removed = await threadStore.retainWorkspaces( + new Set(workspaces.map(({ id }) => id)), + chatId, + ); for (const target of removed) { await api.deleteForumTopic(target.chatId, target.threadId).catch((cause) => { - logger.warn("telegram", `[${profile}] Could not remove stale Telegram thread ${target.threadId}: ${cause instanceof Error ? cause.message : String(cause)}`); + logger.warn( + "telegram", + `[${profile}] Could not remove stale Telegram thread ${target.threadId}: ${cause instanceof Error ? cause.message : String(cause)}`, + ); }); } for (const workspace of workspaces) { if (await threadStore.findWorkspace(workspace.id)) continue; - const topic = await api.createForumTopic(chatId, `Aiden · ${workspace.name}`.slice(0, 128)); + const topic = await api.createForumTopic( + chatId, + `Aiden · ${workspace.name}`.slice(0, 128), + ); await threadStore.upsert({ threadId: topic.message_thread_id, chatId, @@ -281,14 +330,20 @@ export function createTelegramService(profileName = DEFAULT_TELEGRAM_PROFILE) { }); } }); - threadProvisioning = operation.then(() => undefined, () => undefined); + threadProvisioning = operation.then( + () => undefined, + () => undefined, + ); return operation; }, clearThreadTargets: async () => { const targets = await threadStore.list(); for (const target of targets) { await api.deleteForumTopic(target.chatId, target.threadId).catch((cause) => { - logger.warn("telegram", `[${profile}] Could not remove Telegram thread ${target.threadId}: ${cause instanceof Error ? cause.message : String(cause)}`); + logger.warn( + "telegram", + `[${profile}] Could not remove Telegram thread ${target.threadId}: ${cause instanceof Error ? cause.message : String(cause)}`, + ); }); } await threadStore.clear(); @@ -302,16 +357,18 @@ export function createTelegramService(profileName = DEFAULT_TELEGRAM_PROFILE) { openSession: (id) => piCompactionSessionStore.openChat(id), resolveProvider: () => resolveProvider(profile), resolveRuntime: resolveModelRuntime, - resolveThinkingLevel: async () => (await getProfileSettings(profile)).telegramThinkingLevel, + resolveThinkingLevel: async () => + (await getProfileSettings(profile)).telegramThinkingLevel, }, chatId, ), transcribeAudio: transcribe, storeInboundFile: async ({ bytes, name, workspaceId }) => { const workspace = workspaceId ? await configStore.getWorkspace(workspaceId) : undefined; - const workspaceRoot = isTelegramFolderWorkspace(workspace) && workspace.folderPath - ? await realpath(workspace.folderPath) - : undefined; + const workspaceRoot = + isTelegramFolderWorkspace(workspace) && workspace.folderPath + ? await realpath(workspace.folderPath) + : undefined; const root = workspaceRoot ? path.join(workspaceRoot, ".aiden", "telegram-inbox", profile) : path.join(app.getPath("userData"), "telegram-inbox", profile); @@ -323,7 +380,11 @@ export function createTelegramService(profileName = DEFAULT_TELEGRAM_PROFILE) { const metadata = await stat(candidate).catch(() => undefined); if (metadata && metadata.mtimeMs < cutoff) await unlink(candidate).catch(() => undefined); } - const safeName = path.basename(name).replace(/[^A-Za-z0-9._-]+/gu, "_").slice(-120) || "telegram-file"; + const safeName = + path + .basename(name) + .replace(/[^A-Za-z0-9._-]+/gu, "_") + .slice(-120) || "telegram-file"; const destination = path.join(root, `${randomUUID()}-${safeName}`); await writeFile(destination, bytes, { flag: "wx", mode: 0o600 }); return destination; @@ -345,7 +406,10 @@ export function createTelegramService(profileName = DEFAULT_TELEGRAM_PROFILE) { }); if (result) return validateVoiceResult(result); } catch (cause) { - logger.warn("telegram", `[${profile}] Voice provider ${extension.id} failed: ${cause instanceof Error ? cause.message : String(cause)}`); + logger.warn( + "telegram", + `[${profile}] Voice provider ${extension.id} failed: ${cause instanceof Error ? cause.message : String(cause)}`, + ); } } return undefined; @@ -354,7 +418,7 @@ export function createTelegramService(profileName = DEFAULT_TELEGRAM_PROFILE) { const rows: string[] = []; const sections: Array<{ label: string; callbackData: string }> = []; for (const extension of getTelegramExtensions()) { - if (extension.statusRows) rows.push(...await extension.statusRows()); + if (extension.statusRows) rows.push(...(await extension.statusRows())); for (const section of extension.sections ?? []) { sections.push({ label: section.label, callbackData: section.callbackData }); } @@ -363,13 +427,19 @@ export function createTelegramService(profileName = DEFAULT_TELEGRAM_PROFILE) { }, handleExtensionCallback: async (data, context) => { const match = /^ext:([a-z][a-z0-9_-]{0,31}):/u.exec(data); - const extension = match ? getTelegramExtensions().find(({ id }) => id === match[1]) : undefined; - if (!extension?.handleCallback) throw new Error("This extension control is no longer available."); + const extension = match + ? getTelegramExtensions().find(({ id }) => id === match[1]) + : undefined; + if (!extension?.handleCallback) + throw new Error("This extension control is no longer available."); return extension.handleCallback(data, { profile, ...context }); }, handleExtensionUpdate: async (update, context) => { for (const extension of getTelegramExtensions()) { - if (extension.handleUpdate && await extension.handleUpdate(update, { profile, ...context })) { + if ( + extension.handleUpdate && + (await extension.handleUpdate(update, { profile, ...context })) + ) { return true; } } @@ -379,7 +449,10 @@ export function createTelegramService(profileName = DEFAULT_TELEGRAM_PROFILE) { let transformed = content; for (const extension of getTelegramExtensions()) { if (extension.transformInbound) { - transformed = await extension.transformInbound(transformed, message, { profile, ...context }); + transformed = await extension.transformInbound(transformed, message, { + profile, + ...context, + }); } } return transformed; @@ -399,12 +472,17 @@ export function createTelegramService(profileName = DEFAULT_TELEGRAM_PROFILE) { (extension.commands ?? []).flatMap((command) => { if (used.has(command.name)) return []; used.add(command.name); - return [{ - command: command.name, - description: command.description.replace(/\s+/gu, " ").slice(0, 256), - handle: (argument: string, message: Parameters[0]["message"], context: Omit[0]["context"], "profile">) => - command.handler({ argument, message, context: { profile, ...context } }), - }]; + return [ + { + command: command.name, + description: command.description.replace(/\s+/gu, " ").slice(0, 256), + handle: ( + argument: string, + message: Parameters[0]["message"], + context: Omit[0]["context"], "profile">, + ) => command.handler({ argument, message, context: { profile, ...context } }), + }, + ]; }), ); if (!workspaceId) return extensionCommands; @@ -417,16 +495,24 @@ export function createTelegramService(profileName = DEFAULT_TELEGRAM_PROFILE) { .slice(0, 32); if (!command || !/^[a-z]/u.test(command) || used.has(command)) return []; used.add(command); - return [{ - command, - description: (skill.description || `Run ${skill.name}`).replace(/\s+/gu, " ").slice(0, 256), - expand: (argument: string) => formatSkillInvocation({ - name: skill.name, - description: skill.description, - content: skill.instructions, - filePath: skill.path ?? "/Aiden/Configured Skills/SKILL.md", - }, argument), - }]; + return [ + { + command, + description: (skill.description || `Run ${skill.name}`) + .replace(/\s+/gu, " ") + .slice(0, 256), + expand: (argument: string) => + formatSkillInvocation( + { + name: skill.name, + description: skill.description, + content: skill.instructions, + filePath: skill.path ?? "/Aiden/Configured Skills/SKILL.md", + }, + argument, + ), + }, + ]; }); return [...extensionCommands, ...skillCommands]; }, @@ -467,11 +553,18 @@ export function createTelegramService(profileName = DEFAULT_TELEGRAM_PROFILE) { if (chatId === undefined) throw new Error(`Telegram profile ${profile} is not paired.`); if (thread === undefined) return { chatId, workspaceId: settings.telegramWorkspaceId }; const targets = await threadStore.list(); - const target = typeof thread === "number" - ? targets.find((candidate) => candidate.threadId === thread) - : targets.find((candidate) => candidate.name.toLowerCase() === thread.trim().toLowerCase()); - if (!target || target.chatId !== chatId) throw new Error(`Unknown live Telegram thread target: ${String(thread)}`); - return { chatId, threadId: target.threadId, workspaceId: target.workspaceId, name: target.name }; + const target = + typeof thread === "number" + ? targets.find((candidate) => candidate.threadId === thread) + : targets.find((candidate) => candidate.name.toLowerCase() === thread.trim().toLowerCase()); + if (!target || target.chatId !== chatId) + throw new Error(`Unknown live Telegram thread target: ${String(thread)}`); + return { + chatId, + threadId: target.threadId, + workspaceId: target.workspaceId, + name: target.name, + }; } return Object.assign(core, { @@ -485,21 +578,27 @@ export function createTelegramService(profileName = DEFAULT_TELEGRAM_PROFILE) { const richChunks = chunkRichMarkdown(input.text); for (let index = 0; index < richChunks.length; index += 1) { try { - sent.push(await api.sendRichMessage({ - chatId: target.chatId, - threadId: target.threadId, - markdown: richChunks[index]!, - })); - } catch (cause) { - if (!(cause instanceof TelegramApiError) || cause.code !== 400) throw cause; - for (const text of chunkForTelegram(markdownToTelegramHtml(richChunks.slice(index).join("\n\n")))) { - sent.push(await api.sendMessage({ + sent.push( + await api.sendRichMessage({ chatId: target.chatId, threadId: target.threadId, - text, - parseMode: "HTML", - disablePreview: true, - })); + markdown: richChunks[index]!, + }), + ); + } catch (cause) { + if (!(cause instanceof TelegramApiError) || cause.code !== 400) throw cause; + for (const text of chunkForTelegram( + markdownToTelegramHtml(richChunks.slice(index).join("\n\n")), + )) { + sent.push( + await api.sendMessage({ + chatId: target.chatId, + threadId: target.threadId, + text, + parseMode: "HTML", + disablePreview: true, + }), + ); } break; } @@ -508,22 +607,38 @@ export function createTelegramService(profileName = DEFAULT_TELEGRAM_PROFILE) { } const sent = []; for (const text of chunkForTelegram(markdownToTelegramHtml(input.text))) { - sent.push(await api.sendMessage({ - chatId: target.chatId, - threadId: target.threadId, - text, - parseMode: "HTML", - disablePreview: true, - })); + sent.push( + await api.sendMessage({ + chatId: target.chatId, + threadId: target.threadId, + text, + parseMode: "HTML", + disablePreview: true, + }), + ); } return sent; }, - async sendDirectAttachment(input: { path: string; caption?: string; thread?: string | number }) { + async sendDirectAttachment(input: { + path: string; + caption?: string; + thread?: string | number; + }) { const target = await resolveDirectTarget(input.thread); const file = await readWorkspaceAttachment(target.workspaceId, input.path); - return api.sendDocument({ chatId: target.chatId, threadId: target.threadId, ...file, caption: input.caption }); + return api.sendDocument({ + chatId: target.chatId, + threadId: target.threadId, + ...file, + caption: input.caption, + }); }, - async sendDirectVoice(input: { text: string; lang?: string; rate?: string; thread?: string | number }) { + async sendDirectVoice(input: { + text: string; + lang?: string; + rate?: string; + thread?: string | number; + }) { const target = await resolveDirectTarget(input.thread); for (const extension of getTelegramExtensions()) { try { @@ -538,9 +653,17 @@ export function createTelegramService(profileName = DEFAULT_TELEGRAM_PROFILE) { workspaceId: target.workspaceId, }, }); - if (voice) return api.sendVoice({ chatId: target.chatId, threadId: target.threadId, ...validateVoiceResult(voice) }); + if (voice) + return api.sendVoice({ + chatId: target.chatId, + threadId: target.threadId, + ...validateVoiceResult(voice), + }); } catch (cause) { - logger.warn("telegram", `[${profile}] Voice provider ${extension.id} failed: ${cause instanceof Error ? cause.message : String(cause)}`); + logger.warn( + "telegram", + `[${profile}] Voice provider ${extension.id} failed: ${cause instanceof Error ? cause.message : String(cause)}`, + ); } } throw new Error("No Telegram voice synthesis provider is registered."); @@ -567,7 +690,8 @@ export function createTelegramProfileManager() { const settings = await configStore.getSettings(); const profiles = listTelegramProfileNames(settings); const requested = settings.telegramActiveProfile; - activeProfile = requested && profiles.includes(requested) ? requested : DEFAULT_TELEGRAM_PROFILE; + activeProfile = + requested && profiles.includes(requested) ? requested : DEFAULT_TELEGRAM_PROFILE; return profiles; } @@ -597,12 +721,14 @@ export function createTelegramProfileManager() { async listProfiles() { const settings = await configStore.getSettings(); const profiles = listTelegramProfileNames(settings); - return Promise.all(profiles.map(async (profile) => ({ - name: profile, - settings: telegramProfileFromSettings(settings, profile), - hasToken: await secrets.hasKey(telegramProfileTokenKey(profile)), - status: serviceFor(profile).getStatus(), - }))); + return Promise.all( + profiles.map(async (profile) => ({ + name: profile, + settings: telegramProfileFromSettings(settings, profile), + hasToken: await secrets.hasKey(telegramProfileTokenKey(profile)), + status: serviceFor(profile).getStatus(), + })), + ); }, async selectProfile(value: string): Promise { const profile = normalizeTelegramProfileName(value); @@ -615,7 +741,8 @@ export function createTelegramProfileManager() { async createProfile(value: string): Promise { const profile = normalizeTelegramProfileName(value); const settings = await configStore.getSettings(); - if (listTelegramProfileNames(settings).includes(profile)) throw new Error(`Telegram profile already exists: ${profile}`); + if (listTelegramProfileNames(settings).includes(profile)) + throw new Error(`Telegram profile already exists: ${profile}`); if (Object.keys(settings.telegramProfiles ?? {}).length >= 16) { throw new Error("Aiden supports up to 16 named Telegram profiles."); } @@ -629,7 +756,8 @@ export function createTelegramProfileManager() { }, async deleteProfile(value: string): Promise { const profile = normalizeTelegramProfileName(value); - if (profile === DEFAULT_TELEGRAM_PROFILE) throw new Error("The default Telegram profile cannot be deleted."); + if (profile === DEFAULT_TELEGRAM_PROFILE) + throw new Error("The default Telegram profile cannot be deleted."); const service = services.get(profile); await service?.stopAndSettle(); await service?.resetPairing(); @@ -639,7 +767,10 @@ export function createTelegramProfileManager() { const profiles = { ...(settings.telegramProfiles ?? {}) }; delete profiles[profile]; activeProfile = DEFAULT_TELEGRAM_PROFILE; - await configStore.setSettings({ telegramProfiles: profiles, telegramActiveProfile: activeProfile }); + await configStore.setSettings({ + telegramProfiles: profiles, + telegramActiveProfile: activeProfile, + }); }, async getActiveSettings() { await refreshProfiles(); @@ -671,15 +802,32 @@ export function createTelegramProfileManager() { return serviceFor(targetProfile).listTargets(); }, async sendDirectMessage(input: { profile?: string; thread?: string | number; text: string }) { - const targetProfile = input.profile ? normalizeTelegramProfileName(input.profile) : activeProfile; + const targetProfile = input.profile + ? normalizeTelegramProfileName(input.profile) + : activeProfile; return serviceFor(targetProfile).sendDirectMessage(input); }, - async sendDirectAttachment(input: { profile?: string; thread?: string | number; path: string; caption?: string }) { - const targetProfile = input.profile ? normalizeTelegramProfileName(input.profile) : activeProfile; + async sendDirectAttachment(input: { + profile?: string; + thread?: string | number; + path: string; + caption?: string; + }) { + const targetProfile = input.profile + ? normalizeTelegramProfileName(input.profile) + : activeProfile; return serviceFor(targetProfile).sendDirectAttachment(input); }, - async sendDirectVoice(input: { profile?: string; thread?: string | number; text: string; lang?: string; rate?: string }) { - const targetProfile = input.profile ? normalizeTelegramProfileName(input.profile) : activeProfile; + async sendDirectVoice(input: { + profile?: string; + thread?: string | number; + text: string; + lang?: string; + rate?: string; + }) { + const targetProfile = input.profile + ? normalizeTelegramProfileName(input.profile) + : activeProfile; return serviceFor(targetProfile).sendDirectVoice(input); }, }; diff --git a/main/services/tools.ts b/main/services/tools.ts index d07232dc..6a864cdb 100644 --- a/main/services/tools.ts +++ b/main/services/tools.ts @@ -33,6 +33,8 @@ import { selectedMcpServers } from "./mcp-selection.js"; import { assertScheduledMcpServerBindings } from "./schedule-mcp-binding.js"; import { declarePiRuntimeReplay } from "./pi-runtime-tool.js"; import { buildTelegramAgentTools } from "./telegram/telegram-agent-tools.js"; +import { createShareImageTool } from "./share-image-tool.js"; +import type { Attachment } from "./types.js"; const EXA_ENDPOINT = "https://api.exa.ai/search"; @@ -131,6 +133,8 @@ export interface ToolContext { interactionSurface?: "telegram"; /** Explicitly expose cross-target Telegram delivery to attended local agents. */ allowTelegramDirect?: boolean; + /** Generation-scoped sink for assistant images that become durable with the final response. */ + shareImage?: (attachment: Attachment) => void; } export function buildSchedulingTools( @@ -234,6 +238,9 @@ export async function buildAgentTools(ctx: ToolContext): Promise { // Withheld entirely when permission is "none" or no folder is bound. if (ctx.workspaceRoot && ctx.permission !== "none") { tools.push(...buildCodingTools(ctx.workspaceRoot)); + if (ctx.shareImage) { + tools.push(createShareImageTool({ workspaceRoot: ctx.workspaceRoot, share: ctx.shareImage })); + } } const settings = await configStore.getSettings(); @@ -261,4 +268,3 @@ export async function buildAgentTools(ctx: ToolContext): Promise { return tools; } - diff --git a/main/services/types.ts b/main/services/types.ts index 005b09eb..edfa3ecd 100644 --- a/main/services/types.ts +++ b/main/services/types.ts @@ -11,6 +11,7 @@ import type { SubagentMessageReferenceV1 } from "../../renderer/shared/subagent- import type { SkillProvenanceV1 } from "../../renderer/shared/slash-commands.js"; import type { ProviderFailureV1 } from "../../renderer/shared/provider-failure.js"; import type { AssistantMessage } from "@earendil-works/pi-ai"; +import type { ProviderArtwork } from "../../renderer/shared/provider-artwork.js"; export type ProviderKind = "openai" | "anthropic"; @@ -40,6 +41,8 @@ export interface StoredProvider { id: string; kind: ProviderKind; label: string; + /** Optional normalized artwork for custom providers. Never contains a filesystem path. */ + artwork?: ProviderArtwork; /** Base URL including the version segment, e.g. https://api.openai.com/v1 */ baseUrl: string; /** Suggested / cached model ids for the picker. */ @@ -168,7 +171,7 @@ export type ChatRole = "user" | "assistant" | "system"; export type AttachmentKind = "image" | "text"; -/** A file attached to a user message. Images carry base64 `data`; text files carry inlined `text`. */ +/** A durable chat attachment. Images carry base64 `data`; text files carry inlined `text`. */ export interface Attachment { id: string; name: string; @@ -194,7 +197,7 @@ export interface ChatMessage { pi?: Omit; /** Closed, renderer-safe terminal provider outcome. */ providerFailure?: ProviderFailureV1; - /** Files attached to a user message. */ + /** Files attached to a user or assistant message. */ attachments?: Attachment[]; /** Safe display-only provenance for an explicitly invoked skill. */ skill?: SkillProvenanceV1; @@ -215,7 +218,11 @@ export interface ModelRanking { } export type ModelMetadataSource = - "local" | "provider" | "artificial-analysis" | "models-dev" | "fallback"; + | "local" + | "provider" + | "artificial-analysis" + | "models-dev" + | "fallback"; /** Normalized model metadata after applying local and bundled-source precedence. */ export interface ModelInfo { @@ -394,8 +401,7 @@ export interface DiscoveredSkill { export type VoiceProvider = "openai" | "gemini" | "local"; -export type ChatTitleProviderId = - "automatic" | "apple-foundation-models" | "chat-model"; +export type ChatTitleProviderId = "automatic" | "apple-foundation-models" | "chat-model"; export type FoundationModelsConnectionState = | "ready" @@ -458,6 +464,8 @@ export interface AssistantConfigSnapshot { export interface AppSettings { lastProviderId?: string; lastModel?: string; + /** Presentation-only chat models hidden from Mac and paired mobile selection UI. */ + hiddenModelsByProvider?: Record; exaEnabled?: boolean; voiceProvider?: VoiceProvider; voiceModel?: string; @@ -480,6 +488,7 @@ export interface AppSettings { codexThinkingByModel?: Record; /** Last explicit Anthropic/Claude thinking effort, keyed by exact model id. */ anthropicThinkingByModel?: Record; + providerThinkingByModel?: Record>; /** Presentation-only Pi thinking visibility for models running on a local deployment. */ showLocalModelReasoning?: boolean; /** Global opt-in for the external cua-driver Computer Use beta. */ @@ -495,6 +504,8 @@ export interface AppSettings { assistant?: AssistantConfig; /** Device-local display name used by the private usage profile. */ profileName?: string; + /** Main-owned first-run progress. Secrets and prompt drafts are never stored here. */ + onboarding?: import("../../renderer/shared/onboarding.js").OnboardingState; /** Telegram remote-control enable flag; gates long-poll polling. */ telegramEnabled?: boolean; /** Paired Telegram owner chat id; undefined until first /start pairs. */ diff --git a/main/services/workspace-application-service-main.ts b/main/services/workspace-application-service-main.ts new file mode 100644 index 00000000..b5a0f024 --- /dev/null +++ b/main/services/workspace-application-service-main.ts @@ -0,0 +1,29 @@ +import * as fs from "node:fs/promises"; +import { logger } from "../platform.js"; +import { configStore } from "./config-store.js"; +import { llmClient } from "./llm-client.js"; +import { scheduleService } from "./schedule-service.js"; +import { createScratchWorkspaceDirectory } from "./scratch-workspace.js"; +import { terminalService } from "./terminal.js"; +import { workspaceMutationGate } from "./workspace-mutation-gate.js"; +import { workspaceOperationRegistry } from "./workspace-operation-registry.js"; +import { + createWorkspaceApplicationService, + defaultWorkspaceId, +} from "./workspace-application-service.js"; + +export const workspaceApplicationService = createWorkspaceApplicationService({ + configStore, + llmClient, + scheduleService, + terminalService, + workspaceMutationGate, + workspaceOperationRegistry, + createScratchWorkspaceDirectory, + realpath: (value) => fs.realpath(value), + stat: (value) => fs.stat(value), + removeEmptyDirectory: (value) => fs.rmdir(value), + createId: defaultWorkspaceId, + now: Date.now, + logError: (area, message, error) => logger.error(area, message, error), +}); diff --git a/main/services/workspace-application-service.test.ts b/main/services/workspace-application-service.test.ts new file mode 100644 index 00000000..8b5547fa --- /dev/null +++ b/main/services/workspace-application-service.test.ts @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Workspace } from "./types.js"; +import { + createWorkspaceApplicationService, + type WorkspaceApplicationDependencies, +} from "./workspace-application-service.js"; +import { WorkspaceMutationGate } from "./workspace-mutation-gate.js"; +import { WorkspaceOperationRegistry } from "./workspace-operation-registry.js"; + +function workspace(overrides: Partial = {}): Workspace { + return { + id: "workspace-1", + name: "Workspace", + permission: "ask", + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +function fixture(options: { existing?: Workspace | null; saveError?: Error } = {}) { + const events: string[] = []; + const saved: Workspace[] = []; + let existing = options.existing === undefined ? workspace() : options.existing; + const deps = { + configStore: { + listWorkspaces: async () => existing ? [existing] : [], + getWorkspace: async () => existing, + saveWorkspace: async (value: Workspace) => { + events.push("save"); + if (options.saveError) throw options.saveError; + existing = value; + saved.push(value); + return value; + }, + removeWorkspace: async () => { events.push("remove-record"); existing = null; }, + }, + llmClient: { + cancelWorkspaceAndSettle: async () => { events.push("cancel-generations"); }, + }, + scheduleService: { + cancelWorkspace: async () => { events.push("cancel-schedules"); }, + resumeWorkspace: async () => { events.push("resume-schedules"); }, + }, + terminalService: { + closeForWorkspace: () => { events.push("close-terminal"); }, + }, + workspaceMutationGate: new WorkspaceMutationGate(), + workspaceOperationRegistry: new WorkspaceOperationRegistry(), + createScratchWorkspaceDirectory: async () => ({ + name: "Scratch", + folderPath: "/tmp/aiden-scratch-test", + }), + realpath: async (value: string) => value, + stat: async () => ({ isDirectory: () => true }), + removeEmptyDirectory: async () => { events.push("remove-empty-directory"); }, + createId: () => "workspace-new", + now: () => 123, + logError: () => undefined, + } as unknown as WorkspaceApplicationDependencies; + return { + service: createWorkspaceApplicationService(deps), + deps, + events, + saved, + }; +} + +test("shared workspace creation preserves defaults and rejects path authority", async () => { + const application = fixture({ existing: null }); + const created = await application.service.create({ name: " Project ", permission: "invalid" }); + assert.deepEqual(created, { + id: "workspace-new", + name: "Project", + permission: "ask", + createdAt: 123, + updatedAt: 123, + }); + assert.throws( + () => application.service.create({ folderPath: "/private/escape" }), + /folder picker/u, + ); +}); + +test("shared workspace permission updates preserve cancellation and schedule restoration gates", async () => { + const application = fixture({ existing: workspace({ permission: "full" }) }); + const updated = await application.service.update("workspace-1", { + name: " Renamed ", + permission: "none", + }); + assert.equal(updated.name, "Renamed"); + assert.equal(updated.permission, "none"); + assert.deepEqual(application.events, [ + "close-terminal", + "cancel-generations", + "cancel-schedules", + "save", + ]); + + // The mutation lease must have released after the first update. + await application.service.update("workspace-1", { name: "Again" }); + assert.equal(application.saved[application.saved.length - 1]?.name, "Again"); +}); + +test("shared workspace mutations assert the current revision inside their mutation lease", async () => { + const application = fixture(); + await assert.rejects( + application.service.update( + "workspace-1", + { name: "Must not save" }, + { assertCurrent: () => { throw new Error("revision changed"); } }, + ), + /revision changed/u, + ); + assert.deepEqual(application.events, []); + + await assert.rejects( + application.service.remove( + "workspace-1", + { assertCurrent: () => { throw new Error("revision changed"); } }, + ), + /revision changed/u, + ); + assert.deepEqual(application.events, []); +}); + +test("shared workspace removal never unregisters a managed worktree", async () => { + const application = fixture({ + existing: workspace({ + folderPath: "/repo/worktree", + managedWorktree: { + repositoryPath: "/repo", + worktreePath: "/repo/worktree", + branch: "feature", + createdFromHead: "abc123", + }, + }), + }); + await assert.rejects(application.service.remove("workspace-1"), /Delete worktree/u); + assert.deepEqual(application.events, ["close-terminal"]); + + // The mutation lease releases even when removal is refused. + await assert.rejects(application.service.remove("workspace-1"), /Delete worktree/u); +}); + +test("shared scratch creation removes an empty directory when persistence fails", async () => { + const application = fixture({ + existing: null, + saveError: new Error("persistence failed"), + }); + await assert.rejects(application.service.createScratch(), /persistence failed/u); + assert.deepEqual(application.events, ["save", "remove-empty-directory"]); +}); + +test("shared folder creation serializes duplicate registration and accepts a reviewed display name", async () => { + const application = fixture({ existing: null }); + const [first, second] = await Promise.allSettled([ + application.service.createFromFolder("/approved/project", "Remote Project"), + application.service.createFromFolder("/approved/project", "Duplicate"), + ]); + assert.equal(first.status, "fulfilled"); + assert.equal(first.status === "fulfilled" ? first.value.name : "", "Remote Project"); + assert.equal(second.status, "rejected"); + assert.match( + second.status === "rejected" ? String(second.reason) : "", + /already registered/u, + ); + assert.equal(application.saved.length, 1); +}); + +test("shared folder creation revalidates selected identity before persistence", async () => { + const application = fixture({ existing: null }); + await assert.rejects( + application.service.createFromFolder( + "/approved/project", + "Project", + { + assertCurrent: ({ canonicalPath }) => { + assert.equal(canonicalPath, "/approved/project"); + throw new Error("selected folder changed"); + }, + }, + ), + /selected folder changed/u, + ); + assert.equal(application.saved.length, 0); +}); diff --git a/main/services/workspace-application-service.ts b/main/services/workspace-application-service.ts new file mode 100644 index 00000000..e7c66a28 --- /dev/null +++ b/main/services/workspace-application-service.ts @@ -0,0 +1,268 @@ +import * as path from "node:path"; +import type { configStore } from "./config-store.js"; +import type { llmClient } from "./llm-client.js"; +import type { scheduleService } from "./schedule-service.js"; +import type { createScratchWorkspaceDirectory } from "./scratch-workspace.js"; +import type { terminalService } from "./terminal.js"; +import type { Workspace, WorkspacePermission } from "./types.js"; +import type { workspaceMutationGate } from "./workspace-mutation-gate.js"; +import type { workspaceOperationRegistry } from "./workspace-operation-registry.js"; +import { assertWorkspaceRecordRemovalAllowed } from "./workspace-record-removal.js"; +import { withWorkspaceScheduleRestoration } from "./workspace-schedule-restoration.js"; + +const PERMISSIONS: readonly WorkspacePermission[] = ["full", "ask", "none"]; + +function nonEmptyString(value: unknown, name: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Expected non-empty string for "${name}".`); + } + return value; +} + +function defaultWorkspaceId(): string { + return `w-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +export interface WorkspaceApplicationDependencies { + configStore: Pick< + typeof configStore, + "listWorkspaces" | "getWorkspace" | "saveWorkspace" | "removeWorkspace" + >; + llmClient: Pick; + scheduleService: Pick< + typeof scheduleService, + "cancelWorkspace" | "resumeWorkspace" + >; + terminalService: Pick; + workspaceMutationGate: Pick; + workspaceOperationRegistry: Pick; + createScratchWorkspaceDirectory: typeof createScratchWorkspaceDirectory; + realpath(value: string): Promise; + stat(value: string): Promise<{ + isDirectory(): boolean; + dev?: number | bigint; + ino?: number | bigint; + }>; + removeEmptyDirectory(value: string): Promise; + createId(): string; + now(): number; + logError(area: string, message: string, error: unknown): void; +} + +export interface WorkspaceApplicationMutationOptions { + assertCurrent?: (workspace: Workspace) => void; +} + +export interface WorkspaceFolderCreationOptions { + assertCurrent?: (identity: { + canonicalPath: string; + filesystemDevice?: string; + filesystemInode?: string; + }) => Promise | void; +} + +export function createWorkspaceApplicationService(deps: WorkspaceApplicationDependencies) { + let folderCreationTail: Promise = Promise.resolve(); + + const serializeFolderCreation = (operation: () => Promise): Promise => { + const result = folderCreationTail.then(operation, operation); + folderCreationTail = result.then(() => undefined, () => undefined); + return result; + }; + + const saveFolderWorkspace = async ( + folderPath: string, + permission: WorkspacePermission, + name?: string, + options: WorkspaceFolderCreationOptions = {}, + ): Promise => { + return serializeFolderCreation(async () => { + const canonicalPath = await deps.realpath(folderPath); + const identity = await deps.stat(canonicalPath); + if (!identity.isDirectory()) { + throw new Error("Choose a folder for this workspace."); + } + await options.assertCurrent?.({ + canonicalPath, + ...(identity.dev === undefined ? {} : { filesystemDevice: String(identity.dev) }), + ...(identity.ino === undefined ? {} : { filesystemInode: String(identity.ino) }), + }); + const existing = await deps.configStore.listWorkspaces(); + if (existing.some((workspace) => workspace.folderPath === canonicalPath)) { + throw new Error("That folder is already registered as an Aiden workspace."); + } + const now = deps.now(); + return deps.configStore.saveWorkspace({ + id: deps.createId(), + name: name?.trim() || path.basename(canonicalPath) || "Workspace", + folderPath: canonicalPath, + permission, + createdAt: now, + updatedAt: now, + }); + }); + }; + + return { + list() { + return deps.configStore.listWorkspaces(); + }, + + async get(workspaceId: string) { + return (await deps.configStore.getWorkspace(workspaceId)) ?? null; + }, + + create(input: unknown) { + const fields = ( + typeof input === "object" && input !== null ? input : {} + ) as Record; + if ("folderPath" in fields) { + throw new Error("Choose workspace folders through Aiden's folder picker."); + } + const name = + (typeof fields.name === "string" && fields.name.trim()) || "Workspace"; + const permission = PERMISSIONS.includes(fields.permission as WorkspacePermission) + ? (fields.permission as WorkspacePermission) + : "ask"; + const now = deps.now(); + return deps.configStore.saveWorkspace({ + id: deps.createId(), + name, + permission, + createdAt: now, + updatedAt: now, + }); + }, + + createFromFolder( + folderPath: string, + name?: string, + options: WorkspaceFolderCreationOptions = {}, + ) { + return saveFolderWorkspace( + nonEmptyString(folderPath, "folderPath"), + "ask", + name, + options, + ); + }, + + async createScratch() { + const scratch = await deps.createScratchWorkspaceDirectory(); + const now = deps.now(); + const workspace: Workspace = { + id: deps.createId(), + name: scratch.name, + folderPath: scratch.folderPath, + permission: "ask", + createdAt: now, + updatedAt: now, + }; + try { + return await deps.configStore.saveWorkspace(workspace); + } catch (error) { + await deps.removeEmptyDirectory(scratch.folderPath).catch(() => undefined); + throw error; + } + }, + + async update( + workspaceId: string, + patch: unknown, + options: WorkspaceApplicationMutationOptions = {}, + ) { + const id = nonEmptyString(workspaceId, "id"); + const finishMutation = deps.workspaceMutationGate.begin(id); + try { + await deps.workspaceOperationRegistry.cancelAndSettle(id); + const existing = await deps.configStore.getWorkspace(id); + if (!existing) throw new Error(`Workspace ${id} not found.`); + options.assertCurrent?.(existing); + const fields = ( + typeof patch === "object" && patch !== null ? patch : {} + ) as Record; + if ("folderPath" in fields) { + throw new Error("Workspace folders cannot be changed from renderer input."); + } + const next: Workspace = { + ...existing, + name: + typeof fields.name === "string" && fields.name.trim() + ? fields.name.trim() + : existing.name, + permission: PERMISSIONS.includes(fields.permission as WorkspacePermission) + ? (fields.permission as WorkspacePermission) + : existing.permission, + }; + if (next.permission === existing.permission) { + return await deps.configStore.saveWorkspace(next); + } + return await withWorkspaceScheduleRestoration( + { + restoreOnExit: existing.permission !== "none", + resume: () => deps.scheduleService.resumeWorkspace(existing.id), + onResumeError: (error) => { + deps.logError( + "schedule", + "Could not restore scheduled tasks after workspace update failed.", + error, + ); + }, + }, + async ({ ensureResumedOnExit, keepPaused }) => { + deps.terminalService.closeForWorkspace(existing.id); + await deps.llmClient.cancelWorkspaceAndSettle(existing.id); + await deps.scheduleService.cancelWorkspace(existing.id); + const saved = await deps.configStore.saveWorkspace(next); + if (saved.permission !== "none") { + ensureResumedOnExit(); + await deps.scheduleService.resumeWorkspace(saved.id); + } + keepPaused(); + return saved; + }, + ); + } finally { + finishMutation(); + } + }, + + async remove( + workspaceId: string, + options: WorkspaceApplicationMutationOptions = {}, + ): Promise { + const id = nonEmptyString(workspaceId, "id"); + const finishMutation = deps.workspaceMutationGate.begin(id); + try { + await deps.workspaceOperationRegistry.cancelAndSettle(id); + const existing = await deps.configStore.getWorkspace(id); + if (existing) options.assertCurrent?.(existing); + deps.terminalService.closeForWorkspace(id); + assertWorkspaceRecordRemovalAllowed(existing); + await withWorkspaceScheduleRestoration( + { + restoreOnExit: existing?.permission !== "none", + resume: () => deps.scheduleService.resumeWorkspace(id), + onResumeError: (error) => { + deps.logError( + "schedule", + "Could not restore scheduled tasks after workspace removal failed.", + error, + ); + }, + }, + async ({ keepPaused }) => { + await deps.llmClient.cancelWorkspaceAndSettle(id); + await deps.scheduleService.cancelWorkspace(id); + await deps.configStore.removeWorkspace(id); + keepPaused(); + }, + ); + } finally { + finishMutation(); + } + }, + }; +} + +export { defaultWorkspaceId }; diff --git a/main/services/workspace-environment-application-service-main.ts b/main/services/workspace-environment-application-service-main.ts new file mode 100644 index 00000000..a862a9a5 --- /dev/null +++ b/main/services/workspace-environment-application-service-main.ts @@ -0,0 +1,16 @@ +import * as fs from "node:fs/promises"; +import { configStore } from "./config-store.js"; +import { assertManagedWorktreeAdmission } from "./managed-worktree-admission.js"; +import { createWorkspaceEnvironmentApplicationService } from "./workspace-environment-application-service.js"; +import { workspaceMutationGate } from "./workspace-mutation-gate.js"; +import { workspaceOperationRegistry } from "./workspace-operation-registry.js"; + +export const workspaceEnvironmentApplicationService = + createWorkspaceEnvironmentApplicationService({ + configStore, + workspaceMutationGate, + workspaceOperationRegistry, + assertManagedWorktreeAdmission, + realpath: fs.realpath, + stat: fs.stat, + }); diff --git a/main/services/workspace-environment-application-service.test.ts b/main/services/workspace-environment-application-service.test.ts new file mode 100644 index 00000000..12d7c963 --- /dev/null +++ b/main/services/workspace-environment-application-service.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import type { Workspace } from "./types.js"; +import { createWorkspaceEnvironmentApplicationService } from "./workspace-environment-application-service.js"; +import { WorkspaceMutationGate } from "./workspace-mutation-gate.js"; +import { + WorkspaceOperationRegistry, + type WorkspaceOperationDocumentOwner, +} from "./workspace-operation-registry.js"; + +class Owner implements WorkspaceOperationDocumentOwner { + private destroyed = false; + private listeners = new Set<() => void>(); + isDestroyed(): boolean { return this.destroyed; } + onInvalidated(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + invalidate(): void { + this.destroyed = true; + for (const listener of this.listeners) listener(); + this.listeners.clear(); + } +} + +test("workspace environment operations share persisted resolution and owner cancellation", async () => { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-environment-service-")); + const root = path.join(temporary, "workspace"); + await fs.mkdir(root); + const workspace: Workspace = { + id: "workspace-1", + name: "Project", + folderPath: root, + permission: "ask", + createdAt: 1, + updatedAt: 2, + }; + const mutationGate = new WorkspaceMutationGate(); + const operationRegistry = new WorkspaceOperationRegistry(); + const service = createWorkspaceEnvironmentApplicationService({ + configStore: { getWorkspace: async (id) => id === workspace.id ? workspace : undefined }, + workspaceMutationGate: mutationGate, + workspaceOperationRegistry: operationRegistry, + assertManagedWorktreeAdmission: async () => undefined, + realpath: fs.realpath, + stat: fs.stat, + }); + + try { + const owner = new Owner(); + const resolved = await service.run(owner, workspace.id, async (context) => context); + assert.equal(resolved.folderPath, await fs.realpath(root)); + assert.equal(resolved.workspace.id, workspace.id); + + const cancellableOwner = new Owner(); + let markStarted!: () => void; + const started = new Promise((resolve) => { markStarted = resolve; }); + const pending = service.run(cancellableOwner, workspace.id, async (_context, signal) => { + markStarted(); + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + if (signal.aborted) reject(signal.reason); + }); + return "unreachable"; + }); + await started; + cancellableOwner.invalidate(); + await assert.rejects(pending, /renderer document is no longer active/u); + + const finishMutation = mutationGate.begin(workspace.id); + await assert.rejects( + () => service.run(new Owner(), workspace.id, async () => undefined), + /workspace is changing/u, + ); + finishMutation(); + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } +}); diff --git a/main/services/workspace-environment-application-service.ts b/main/services/workspace-environment-application-service.ts new file mode 100644 index 00000000..7465172e --- /dev/null +++ b/main/services/workspace-environment-application-service.ts @@ -0,0 +1,151 @@ +import type { Workspace } from "./types.js"; +import type { + WorkspaceOperationDocumentOwner, + workspaceOperationRegistry, +} from "./workspace-operation-registry.js"; +import { admitOwnedWorkspaceOperation } from "./workspace-operation-registry.js"; +import type { workspaceMutationGate } from "./workspace-mutation-gate.js"; + +export interface WorkspaceEnvironmentDirectory { + folderPath: string; + workspace: Workspace; +} + +export interface WorkspaceEnvironmentApplicationDependencies { + configStore: { + getWorkspace(id: string): Promise; + }; + workspaceMutationGate: Pick; + workspaceOperationRegistry: Pick; + assertManagedWorktreeAdmission(workspace: Workspace): Promise; + realpath(value: string): Promise; + stat(value: string): Promise<{ isDirectory(): boolean }>; +} + +function workspaceId(value: string): string { + if (!/^[A-Za-z0-9_-]{1,128}$/u.test(value)) { + throw new Error("The workspace identifier is invalid."); + } + return value; +} + +/** + * Shared ownership and persisted-workspace resolution for renderer and remote + * Files/Git operations. Transport callers never supply a filesystem path. + */ +export function createWorkspaceEnvironmentApplicationService( + dependencies: WorkspaceEnvironmentApplicationDependencies, +) { + const resolve = async ( + workspaceIdValue: string, + required: boolean, + allowNoAccess = false, + ): Promise => { + const id = workspaceId(workspaceIdValue); + const workspace = await dependencies.configStore.getWorkspace(id); + if (!workspace) throw new Error(`Workspace ${id} was not found.`); + if (workspace.permission === "none" && !allowNoAccess) { + if (!required) return undefined; + throw new Error(`${workspace.name} does not allow local file access.`); + } + if (!workspace.folderPath) { + if (!required) return undefined; + throw new Error(`${workspace.name} does not have a folder.`); + } + await dependencies.assertManagedWorktreeAdmission(workspace); + try { + const folderPath = await dependencies.realpath(workspace.folderPath); + const stats = await dependencies.stat(folderPath); + if (!stats.isDirectory()) throw new Error("not a directory"); + return { folderPath, workspace }; + } catch { + if (!required) return undefined; + throw new Error(`${workspace.name}'s folder is no longer available.`); + } + }; + + const run = async ( + owner: WorkspaceOperationDocumentOwner, + workspaceIdValue: string, + operation: ( + resolved: WorkspaceEnvironmentDirectory, + signal: AbortSignal, + ) => Promise, + options: { allowNoAccess?: boolean } = {}, + ): Promise => { + const id = workspaceId(workspaceIdValue); + if (dependencies.workspaceMutationGate.isChanging(id)) { + throw new Error("The workspace is changing. Try again in a moment."); + } + const admission = admitOwnedWorkspaceOperation( + dependencies.workspaceOperationRegistry, + owner, + id, + ); + try { + const resolved = await resolve(id, true, options.allowNoAccess === true); + if ( + !resolved || + owner.isDestroyed() || + admission.signal.aborted || + dependencies.workspaceMutationGate.isChanging(id) + ) { + throw new Error("The workspace changed before the operation could start."); + } + return await operation(resolved, admission.signal); + } finally { + admission.release(); + } + }; + + const runRecord = async ( + owner: WorkspaceOperationDocumentOwner, + workspaceIdValue: string, + operation: (workspace: Workspace, signal: AbortSignal) => Promise, + ): Promise => { + const id = workspaceId(workspaceIdValue); + if (dependencies.workspaceMutationGate.isChanging(id)) { + throw new Error("The workspace is changing. Try again in a moment."); + } + const admission = admitOwnedWorkspaceOperation( + dependencies.workspaceOperationRegistry, + owner, + id, + ); + try { + const workspace = await dependencies.configStore.getWorkspace(id); + if ( + !workspace || + owner.isDestroyed() || + admission.signal.aborted || + dependencies.workspaceMutationGate.isChanging(id) + ) { + throw new Error("The workspace changed before the operation could start."); + } + return await operation(workspace, admission.signal); + } finally { + admission.release(); + } + }; + + const runOptional = async ( + owner: WorkspaceOperationDocumentOwner, + workspaceIdValue: string, + operation: ( + resolved: WorkspaceEnvironmentDirectory | undefined, + signal: AbortSignal, + ) => Promise, + ): Promise => runRecord(owner, workspaceIdValue, async (_workspace, signal) => { + const resolved = await resolve(workspaceIdValue, false); + if (signal.aborted || dependencies.workspaceMutationGate.isChanging(workspaceIdValue)) { + throw new Error("The workspace changed before the operation could start."); + } + return operation(resolved, signal); + }); + + return { resolve, run, runRecord, runOptional }; +} + +export type WorkspaceEnvironmentApplicationService = ReturnType< + typeof createWorkspaceEnvironmentApplicationService +>; diff --git a/main/services/workspace-operation-registry.ts b/main/services/workspace-operation-registry.ts index 8f13b5d4..2cb62307 100644 --- a/main/services/workspace-operation-registry.ts +++ b/main/services/workspace-operation-registry.ts @@ -93,8 +93,8 @@ export class WorkspaceOperationRegistry { * it. A navigation, reload, renderer crash, or WebContents destruction aborts * the operation even when the surrounding window survives. */ -export function admitRendererOwnedWorkspaceOperation( - registry: WorkspaceOperationRegistry, +export function admitOwnedWorkspaceOperation( + registry: Pick, owner: WorkspaceOperationDocumentOwner, workspaceId: string, ): WorkspaceOperationAdmission { @@ -127,4 +127,7 @@ export function admitRendererOwnedWorkspaceOperation( }; } +/** Backward-compatible name for renderer-only call sites during extraction. */ +export const admitRendererOwnedWorkspaceOperation = admitOwnedWorkspaceOperation; + export const workspaceOperationRegistry = new WorkspaceOperationRegistry(); diff --git a/main/services/workspace-worktree-application-service-main.ts b/main/services/workspace-worktree-application-service-main.ts new file mode 100644 index 00000000..86cb1bb8 --- /dev/null +++ b/main/services/workspace-worktree-application-service-main.ts @@ -0,0 +1,88 @@ +import * as fs from "node:fs/promises"; +import { ipcMain, logger } from "../platform.js"; +import { configStore } from "./config-store.js"; +import { ensureUserDataDir } from "./data-store.js"; +import { + gitCreateWorktree, + gitDeleteManagedWorktree, + gitFinalizeManagedWorktreeDeletion, + gitManagedWorktreeDeletionPending, + gitManagedWorktreeRegistered, + gitManagedWorktreeUsable, + gitRollbackWorktree, +} from "./git.js"; +import { llmClient } from "./llm-client.js"; +import { scheduleService } from "./schedule-service.js"; +import { terminalService } from "./terminal.js"; +import { defaultWorkspaceId } from "./workspace-application-service.js"; +import { workspaceEnvironmentApplicationService } from "./workspace-environment-application-service-main.js"; +import { workspaceMutationGate } from "./workspace-mutation-gate.js"; +import { workspaceOperationRegistry } from "./workspace-operation-registry.js"; +import { createWorkspaceWorktreeApplicationService } from "./workspace-worktree-application-service.js"; + +export const workspaceWorktreeApplicationService = createWorkspaceWorktreeApplicationService({ + environment: workspaceEnvironmentApplicationService, + ensureWorktreeRoot: () => ensureUserDataDir("worktrees"), + createWorktree: gitCreateWorktree, + rollbackWorktree: gitRollbackWorktree, + deleteManagedWorktree: (managed, signal) => gitDeleteManagedWorktree( + managed.repositoryPath, + managed.worktreePath, + managed.branch, + managed.createdFromHead, + signal, + managed.worktreeGitDir, + managed.ownershipToken, + managed.worktreeDevice, + managed.worktreeInode, + ), + managedWorktreeDeletionPending: (managed) => gitManagedWorktreeDeletionPending( + managed.worktreePath, + managed.worktreeGitDir!, + managed.ownershipToken!, + ), + managedWorktreeRegistered: (managed) => gitManagedWorktreeRegistered( + managed.repositoryPath, + managed.worktreePath, + managed.branch, + managed.worktreeGitDir, + managed.ownershipToken, + ), + managedWorktreeUsable: (managed) => gitManagedWorktreeUsable( + managed.repositoryPath, + managed.worktreePath, + managed.branch, + managed.worktreeGitDir, + managed.ownershipToken, + managed.worktreeDevice, + managed.worktreeInode, + ), + finalizeManagedWorktreeDeletion: (managed) => gitFinalizeManagedWorktreeDeletion( + managed.worktreePath, + managed.worktreeGitDir!, + managed.ownershipToken!, + ), + workspacePathExists: async (worktreePath) => { + try { + await fs.stat(worktreePath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }, + saveWorkspace: (workspace) => configStore.saveWorkspace(workspace), + removeWorkspace: (workspaceId) => configStore.removeWorkspace(workspaceId), + beginWorkspaceMutation: (workspaceId) => workspaceMutationGate.begin(workspaceId), + workspaceIsChanging: (workspaceId) => workspaceMutationGate.isChanging(workspaceId), + cancelWorkspaceOperations: (workspaceId, exceptSignal) => + workspaceOperationRegistry.cancelAndSettle(workspaceId, { exceptSignal }), + closeWorkspaceTerminals: (workspaceId) => terminalService.closeForWorkspace(workspaceId), + cancelWorkspaceGeneration: (workspaceId) => llmClient.cancelWorkspaceAndSettle(workspaceId), + cancelWorkspaceSchedules: (workspaceId) => scheduleService.cancelWorkspace(workspaceId), + resumeWorkspaceSchedules: (workspaceId) => scheduleService.resumeWorkspace(workspaceId), + createWorkspaceId: defaultWorkspaceId, + now: Date.now, + notifyChanged: () => ipcMain.broadcast("workspaces:changed", {}), + logError: (area, message, error) => logger.error(area, message, error), +}); diff --git a/main/services/workspace-worktree-application-service.test.ts b/main/services/workspace-worktree-application-service.test.ts new file mode 100644 index 00000000..bbaac050 --- /dev/null +++ b/main/services/workspace-worktree-application-service.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { Workspace } from "./types.js"; +import { createWorkspaceWorktreeApplicationService } from "./workspace-worktree-application-service.js"; + +const owner = { + isDestroyed: () => false, + onInvalidated: () => () => undefined, +}; + +test("shared managed-worktree workflow preserves creation rollback gates and destructive deletion ordering", async () => { + const events: string[] = []; + const source: Workspace = { + id: "workspace-source", + name: "Source", + folderPath: "/canonical/source", + permission: "ask", + createdAt: 1, + updatedAt: 2, + }; + let managed: Workspace | undefined; + const signal = new AbortController().signal; + const service = createWorkspaceWorktreeApplicationService({ + environment: { + resolve: async (id) => id === source.id + ? { folderPath: source.folderPath!, workspace: source } + : managed + ? { folderPath: managed.folderPath!, workspace: managed } + : undefined, + run: async (_owner, id, operation) => { + assert.equal(id, source.id); + return operation({ folderPath: source.folderPath!, workspace: source }, signal); + }, + runRecord: async (_owner, id, operation) => { + assert.equal(id, managed?.id); + return operation(managed!, signal); + }, + }, + ensureWorktreeRoot: async () => "/aiden/worktrees", + createWorktree: async (folderPath, root, branch) => { + events.push(`create:${folderPath}:${root}:${branch}`); + return { + path: "/aiden/worktrees/mobile", + workspacePath: "/aiden/worktrees/mobile", + repositoryPath: "/canonical/source", + worktreeGitDir: "/canonical/source/.git/worktrees/mobile", + ownershipToken: "a".repeat(64), + worktreeDevice: 1, + worktreeInode: 2, + createdFromHead: "b".repeat(40), + head: "b".repeat(40), + branch, + bare: false, + detached: false, + current: false, + }; + }, + rollbackWorktree: async () => { events.push("rollback"); }, + deleteManagedWorktree: async () => { + events.push("delete-git"); + return { branchDeleted: true }; + }, + managedWorktreeDeletionPending: async () => false, + managedWorktreeRegistered: async () => false, + managedWorktreeUsable: async () => false, + finalizeManagedWorktreeDeletion: async () => { events.push("finalize"); }, + workspacePathExists: async () => false, + saveWorkspace: async (workspace) => { + events.push("save"); + managed = workspace; + return workspace; + }, + removeWorkspace: async (id) => { + events.push(`remove:${id}`); + managed = undefined; + }, + beginWorkspaceMutation: () => { + events.push("begin-mutation"); + return () => { events.push("finish-mutation"); }; + }, + workspaceIsChanging: () => false, + cancelWorkspaceOperations: async () => { events.push("cancel-operations"); }, + closeWorkspaceTerminals: () => { events.push("close-terminals"); }, + cancelWorkspaceGeneration: async () => { events.push("cancel-generation"); }, + cancelWorkspaceSchedules: async () => { events.push("cancel-schedules"); }, + resumeWorkspaceSchedules: async () => { events.push("resume-schedules"); }, + createWorkspaceId: () => "workspace-managed", + now: () => 3, + notifyChanged: () => { events.push("notify"); }, + logError: () => undefined, + }); + + const created = await service.create(owner, source.id, "feature/mobile", "Mobile Workspace"); + assert.equal(created.id, "workspace-managed"); + assert.equal(created.name, "Mobile Workspace"); + assert.equal(created.permission, source.permission); + assert.deepEqual(events, [ + "create:/canonical/source:/aiden/worktrees:feature/mobile", + "save", + "notify", + ]); + + events.length = 0; + const removed = await service.remove(owner, created.id, (workspace) => { + events.push(`validate:${workspace.id}`); + }); + assert.equal(removed.branchDeleted, true); + assert.deepEqual(events, [ + "validate:workspace-managed", + "begin-mutation", + "cancel-operations", + "close-terminals", + "cancel-generation", + "cancel-schedules", + "delete-git", + "remove:workspace-managed", + "finalize", + "notify", + "finish-mutation", + ]); +}); diff --git a/main/services/workspace-worktree-application-service.ts b/main/services/workspace-worktree-application-service.ts new file mode 100644 index 00000000..661f4815 --- /dev/null +++ b/main/services/workspace-worktree-application-service.ts @@ -0,0 +1,187 @@ +import path from "node:path"; +import type { GitCreatedWorktree, GitDeleteWorktreeResult } from "./git.js"; +import { GitManagedWorktreeDeleteError } from "./git.js"; +import { + commitManagedWorktreeCreation, + ManagedWorktreeCreationError, +} from "./managed-worktree-creation-core.js"; +import { removeManagedWorkspace } from "./managed-worktree-removal-core.js"; +import type { Workspace, ManagedWorktree } from "./types.js"; +import type { WorkspaceEnvironmentApplicationService } from "./workspace-environment-application-service.js"; +import type { WorkspaceOperationDocumentOwner } from "./workspace-operation-registry.js"; +import { withWorkspaceScheduleRestoration } from "./workspace-schedule-restoration.js"; + +export interface WorkspaceWorktreeApplicationDependencies { + environment: Pick; + ensureWorktreeRoot(): Promise; + createWorktree( + folderPath: string, + root: string, + branch: string, + signal?: AbortSignal, + ): Promise; + rollbackWorktree(folderPath: string, created: GitCreatedWorktree): Promise; + deleteManagedWorktree(managed: ManagedWorktree, signal: AbortSignal): Promise; + managedWorktreeDeletionPending(managed: ManagedWorktree): Promise; + managedWorktreeRegistered(managed: ManagedWorktree): Promise; + managedWorktreeUsable(managed: ManagedWorktree): Promise; + finalizeManagedWorktreeDeletion(managed: ManagedWorktree): Promise; + workspacePathExists(worktreePath: string): Promise; + saveWorkspace(workspace: Workspace): Promise; + removeWorkspace(workspaceId: string): Promise; + beginWorkspaceMutation(workspaceId: string): () => void; + workspaceIsChanging(workspaceId: string): boolean; + cancelWorkspaceOperations(workspaceId: string, exceptSignal: AbortSignal): Promise; + closeWorkspaceTerminals(workspaceId: string): void; + cancelWorkspaceGeneration(workspaceId: string): Promise; + cancelWorkspaceSchedules(workspaceId: string): Promise; + resumeWorkspaceSchedules(workspaceId: string): Promise; + createWorkspaceId(): string; + now(): number; + notifyChanged(): void; + logError(area: string, message: string, error: unknown): void; +} + +function displayName(source: Workspace, branch: string, requested?: string): string { + const trimmed = requested?.trim(); + if (trimmed) return [...trimmed].slice(0, 120).join(""); + const base = path.basename(source.folderPath ?? source.name); + return [...`${base} · ${branch}`].slice(0, 120).join(""); +} + +/** + * Shared renderer/remote orchestration for Aiden-owned Git worktrees. All + * filesystem and Git-admin identity is reloaded from persisted Mac state. + */ +export function createWorkspaceWorktreeApplicationService( + dependencies: WorkspaceWorktreeApplicationDependencies, +) { + const create = async ( + owner: WorkspaceOperationDocumentOwner, + sourceWorkspaceId: string, + branch: string, + requestedName?: string, + ): Promise => dependencies.environment.run( + owner, + sourceWorkspaceId, + async (resolved, signal) => { + const worktree = await dependencies.createWorktree( + resolved.folderPath, + await dependencies.ensureWorktreeRoot(), + branch, + signal, + ); + const now = dependencies.now(); + const workspace: Workspace = { + id: dependencies.createWorkspaceId(), + name: displayName(resolved.workspace, branch, requestedName), + folderPath: worktree.workspacePath, + permission: resolved.workspace.permission, + managedWorktree: { + repositoryPath: worktree.repositoryPath, + worktreePath: worktree.path, + branch, + worktreeGitDir: worktree.worktreeGitDir, + ownershipToken: worktree.ownershipToken, + worktreeDevice: worktree.worktreeDevice, + worktreeInode: worktree.worktreeInode, + createdFromHead: worktree.createdFromHead, + }, + createdAt: now, + updatedAt: now, + }; + try { + const saved = await commitManagedWorktreeCreation({ + validateBeforeSave: async () => { + const latest = await dependencies.environment.resolve(sourceWorkspaceId, true); + if ( + !latest || + signal.aborted || + latest.folderPath !== resolved.folderPath || + latest.workspace.permission !== resolved.workspace.permission + ) { + throw new Error("The source workspace changed while Aiden was creating the worktree."); + } + }, + saveWorkspace: () => dependencies.saveWorkspace(workspace), + validateAfterSave: () => { + if (signal.aborted || dependencies.workspaceIsChanging(sourceWorkspaceId)) { + throw new Error("The source workspace changed while Aiden was saving the worktree."); + } + }, + removeWorkspaceRecord: (savedWorkspace) => dependencies.removeWorkspace(savedWorkspace.id), + rollbackWorktree: () => dependencies.rollbackWorktree(resolved.folderPath, worktree), + }); + dependencies.notifyChanged(); + return saved; + } catch (error) { + if (error instanceof ManagedWorktreeCreationError) { + dependencies.logError("git", error.logMessage, error.errors); + } + throw error; + } + }, + ); + + const remove = async ( + owner: WorkspaceOperationDocumentOwner, + workspaceId: string, + validateWorkspace: (workspace: Workspace) => void = () => undefined, + ): Promise => dependencies.environment.runRecord( + owner, + workspaceId, + async (workspace, signal) => { + validateWorkspace(workspace); + const managed = workspace.managedWorktree; + if (!managed) throw new Error("This workspace is not an Aiden-managed worktree."); + const finishMutation = dependencies.beginWorkspaceMutation(workspaceId); + try { + await dependencies.cancelWorkspaceOperations(workspaceId, signal); + const result = await withWorkspaceScheduleRestoration( + { + restoreOnExit: workspace.permission !== "none", + resume: () => dependencies.resumeWorkspaceSchedules(workspaceId), + onResumeError: (error) => dependencies.logError( + "schedule", + "Could not restore scheduled tasks after managed worktree deletion failed.", + error, + ), + }, + async ({ keepPaused }) => { + dependencies.closeWorkspaceTerminals(workspaceId); + await dependencies.cancelWorkspaceGeneration(workspaceId); + await dependencies.cancelWorkspaceSchedules(workspaceId); + const deletion = await removeManagedWorkspace({ + deleteWorktree: () => dependencies.deleteManagedWorktree(managed, signal), + destructiveMutationAttempted: (error) => + error instanceof GitManagedWorktreeDeleteError + ? error.destructiveMutationAttempted + : undefined, + deletionPending: () => dependencies.managedWorktreeDeletionPending(managed), + workspacePathExists: () => dependencies.workspacePathExists(managed.worktreePath), + worktreeRegistered: () => dependencies.managedWorktreeRegistered(managed), + worktreeUsable: () => dependencies.managedWorktreeUsable(managed), + onDestructiveBoundary: keepPaused, + removeWorkspaceRecord: () => dependencies.removeWorkspace(workspaceId), + reconciledResult: () => ({ branchDeleted: false }), + }); + if (managed.worktreeGitDir && managed.ownershipToken) { + await dependencies.finalizeManagedWorktreeDeletion(managed); + } + return deletion; + }, + ); + dependencies.notifyChanged(); + return result; + } finally { + finishMutation(); + } + }, + ); + + return { create, remove }; +} + +export type WorkspaceWorktreeApplicationService = ReturnType< + typeof createWorkspaceWorktreeApplicationService +>; diff --git a/package-lock.json b/package-lock.json index fe05ea55..0dbcfcb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "lucide-react": "^0.542.0", "mdast-util-to-string": "4.0.0", "node-pty": "^1.1.0", + "qrcode": "^1.5.4", "radix-ui": "^1.4.3", "re2-wasm": "1.0.2", "react": "^19.1.1", @@ -51,6 +52,7 @@ "@rolldown/plugin-babel": "^0.2.1", "@tailwindcss/vite": "^4.2.2", "@types/node": "^24.3.1", + "@types/qrcode": "^1.5.5", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", "@typescript-eslint/eslint-plugin": "^8.18.0", @@ -4983,6 +4985,16 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", + "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -5442,7 +5454,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -6355,6 +6366,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001805", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", @@ -6546,7 +6566,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -6559,7 +6578,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -6868,6 +6886,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -7028,6 +7055,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -8687,7 +8720,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -9696,7 +9728,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12206,6 +12237,15 @@ "node": ">=8" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -12275,7 +12315,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12454,6 +12493,15 @@ "node": ">=10.4.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -12703,6 +12751,182 @@ "node": ">=16.0.0" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", @@ -13155,7 +13379,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13170,6 +13393,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -13556,6 +13785,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -15452,6 +15687,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.22", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", diff --git a/package.json b/package.json index 13fa870b..1129e613 100644 --- a/package.json +++ b/package.json @@ -41,15 +41,20 @@ "dev:electron": "wait-on http-get://127.0.0.1:4143/main-window.html && node scripts/prepare-macos-dev-runtime.mjs --run", "dev:brand": "node scripts/prepare-macos-dev-runtime.mjs", "lint": "eslint .", - "pretest": "npm run build:worktree-remover && npm run test:terminal:coverage && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:provider-failure && npm run test:compaction && npm run test:subagents", + "pretest": "npm run build:worktree-remover && npm run test:aiden-remote && npm run test:aiden-service-boundary && npm run test:ios-release && npm run test:terminal:coverage && npm run test:onboarding && npm run test:assistant-automations && npm run test:slash-commands && npm run test:provider-failure && npm run test:compaction && npm run test:subagents && tsx --test main/services/pi-remote-catalog.test.ts main/services/provider-model-info-core.test.ts main/services/aiden-remote-models.test.ts renderer/shared/provider-thinking.test.ts", "pretest:coverage": "npm run build:worktree-remover && npm run build:subagent-run-store && npm run test:preflight && npm run test:scheduled && npm run test:google-provider && npm run test:config-recovery && npm run test:command-system && npm run test:slash-commands && npm run test:compaction && npm run test:subagents", "test:preflight": "npm run test:artificial-analysis && npm run test:model-pad && tsx --test main/services/appearance-preview-core.test.ts main/services/generation-timeline.test.ts main/services/local-runtime-status.test.ts main/services/mcp-tool-result.test.ts main/services/pi-thinking-disclosure.integration.test.ts renderer/components/activity-feed.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/reasoning-block.test.tsx renderer/components/reasoning-visibility-control.test.tsx renderer/components/thinking-control.test.tsx renderer/lib/agent-steps.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/pill-appearance.test.ts renderer/lib/reasoning-disclosure.test.ts renderer/lib/streaming-motion-contract.test.ts renderer/lib/streaming-reveal.test.ts renderer/lib/voice-recorder-core.test.ts renderer/pill-preload-channels.test.ts renderer/shared/anthropic-thinking.test.ts renderer/shared/app-update.test.ts renderer/shared/claim-check.test.ts renderer/shared/codex-thinking.test.ts renderer/shared/google-thinking.test.ts renderer/shared/provider-deployment.test.ts", + "test:aiden-remote": "tsx --test main/handlers/aiden-remote.test.ts main/services/aiden-remote-approved-roots.test.ts main/services/aiden-remote-revocation.test.ts main/services/aiden-remote-chat-http.test.ts main/services/aiden-remote-chats.test.ts main/services/aiden-remote-files.test.ts main/services/aiden-remote-git.test.ts main/services/aiden-remote-models.test.ts main/services/aiden-remote-protocol.test.ts main/services/aiden-remote-opaque-handles.test.ts main/services/aiden-remote-operation-contract.test.ts main/services/aiden-remote-pairing.test.ts main/services/aiden-remote-router.test.ts main/services/aiden-remote-schedules.test.ts main/services/aiden-remote-service.test.ts main/services/aiden-remote-state.test.ts main/services/aiden-remote-streams.test.ts main/services/aiden-remote-tailscale-route.test.ts main/services/aiden-remote-tailscale.test.ts main/services/aiden-remote-tls-identity.test.ts main/services/aiden-remote-workspace-browser.test.ts main/services/aiden-remote-workspace-http.test.ts main/services/aiden-remote-workspaces.test.ts renderer/components/remote-connection-popover.test.tsx renderer/components/settings/remote-access-settings.test.tsx renderer/lib/remote-approval.test.ts renderer/lib/remote-connection-status.test.ts renderer/lib/remote-pairing-lifecycle.test.ts renderer/lib/settings-section.test.ts && node --test scripts/aiden-remote-lan-transport-spike.test.mjs", + "test:aiden-service-boundary": "tsx --test main/services/chat-application-service.test.ts main/services/chat-generation-owner.test.ts main/services/workspace-application-service.test.ts main/services/workspace-environment-application-service.test.ts main/services/workspace-worktree-application-service.test.ts main/services/scheduled-task-application-service.test.ts", + "ios:asc-monitor": "node scripts/ios-asc-monitor.mjs", + "ios:activitykit-process-proof": "node scripts/ios-live-activity-process-proof.mjs", + "test:ios-release": "ruby ios/ci/select_testflight_build_number_test.rb && node --test scripts/check-ios-testflight-policy.test.mjs scripts/check-ios-app-store-metadata.test.mjs scripts/check-ios-shipping-target.test.mjs scripts/ios-asc-monitor.test.mjs scripts/ios-live-activity-process-proof.test.mjs", "test:branding": "tsx --test main/runtime-mode.test.ts main/runtime-profile-core.test.ts main/runtime-profile-bootstrap.test.ts main/services/app-updater-core.test.ts && node --test scripts/prepare-ci-release.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/check-release-consumers.test.mjs scripts/publish-github-release.test.mjs", "test:scheduled": "tsx --test main/handlers/scheduled-tasks-parse.test.ts main/services/assistant/mcp-tool.test.ts main/services/assistant/tool-loop-guard.test.ts main/services/mcp-selection.test.ts main/services/scheduled-settings-core.test.ts main/services/schedule-guard.test.ts main/services/schedule-notification.test.ts main/services/schedule-service-core.test.ts main/services/schedule-store.test.ts main/services/schedule-script.test.ts main/services/schedule-tool.test.ts renderer/lib/scheduled-task-view.test.ts", "test:artificial-analysis": "tsx --test main/services/artificial-analysis-action-core.test.ts main/services/artificial-analysis-cache.test.ts main/services/artificial-analysis-runtime-core.test.ts main/services/artificial-analysis-catalog-core.test.ts main/services/provider-model-info-core.test.ts renderer/lib/artificial-analysis-query-state.test.ts renderer/lib/model-data-control.test.ts renderer/lib/settings-section.test.ts", "test:model-pad": "tsx --test renderer/lib/google-provider-migration.test.ts renderer/lib/model-pad-layout.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/pi-provider-display.test.ts", "test:command-system": "tsx --test main/services/native-menu-command-contract.test.ts main/services/renderer-readiness-core.test.ts main/services/shortcut-registration-core.test.ts main/services/shortcut-transaction-core.test.ts main/services/superseding-task-core.test.ts renderer/lib/appearance-intent.test.ts renderer/lib/command-palette-contract.test.ts renderer/lib/command-palette-recent.test.ts renderer/lib/command-system-core.test.ts renderer/lib/shortcut-settings-contract.test.ts renderer/lib/use-model-selection.test.ts renderer/shared/keybindings.test.ts", - "test:slash-commands": "tsx --test main/handlers/attachments.contract.test.ts main/handlers/chat.parse.test.ts main/handlers/chat-create-params.test.ts main/handlers/chat-session-params.test.ts main/handlers/worktree-create-params.test.ts main/services/chat-workspace-authority.test.ts main/handlers/chats.append.contract.test.ts main/services/attachment-contract.test.ts main/services/attachments.test.ts main/services/chat-append-commit.test.ts main/services/chat-export.test.ts main/services/chat-message-contract.test.ts main/services/chat-session-copy.test.ts main/services/chat-store-core.test.ts main/services/chat-turn-admission.test.ts main/services/generation-messages.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-list-core.test.ts main/services/scheduled-chat-creation.test.ts main/services/skill-invocation-flow.integration.test.ts main/services/skill-invocation-turn.test.ts main/services/skill-registry-core.test.ts main/services/skill-registry.test.ts main/services/skill-tools.test.ts main/services/skills-discovery.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/assistant/use-assistant-chat.test.ts renderer/components/composer.test.tsx renderer/components/message-bubble.test.tsx renderer/lib/chat-copy-view.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/computer-use-control.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/skill-catalog-workspace.test.ts renderer/lib/slash-command-actions.test.ts renderer/lib/slash-command-core.test.ts renderer/lib/slash-command-performance.test.ts renderer/main/chat-transition.test.tsx renderer/shared/attachment-contract.test.ts renderer/shared/chat-message-contract.test.ts renderer/shared/slash-commands.test.ts", + "test:slash-commands": "tsx --test main/services/generation-initialization-terminal.test.ts main/handlers/attachments.contract.test.ts main/handlers/chat.parse.test.ts main/handlers/chat-create-params.test.ts main/handlers/chat-session-params.test.ts main/handlers/worktree-create-params.test.ts main/services/chat-workspace-authority.test.ts main/handlers/chats.append.contract.test.ts main/services/attachment-contract.test.ts main/services/attachments.test.ts main/services/chat-append-commit.test.ts main/services/chat-export.test.ts main/services/chat-message-contract.test.ts main/services/chat-session-copy.test.ts main/services/chat-store-core.test.ts main/services/chat-turn-admission.test.ts main/services/generation-messages.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/scheduled-chat-creation.test.ts main/services/skill-invocation-flow.integration.test.ts main/services/skill-invocation-turn.test.ts main/services/skill-registry-core.test.ts main/services/skill-registry.test.ts main/services/skill-tools.test.ts main/services/skills-discovery.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/assistant/use-assistant-chat.test.ts renderer/components/composer.test.tsx renderer/components/message-bubble.test.tsx renderer/lib/chat-copy-view.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/computer-use-control.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/skill-catalog-workspace.test.ts renderer/lib/slash-command-actions.test.ts renderer/lib/slash-command-core.test.ts renderer/lib/slash-command-performance.test.ts renderer/main/chat-transition.test.tsx renderer/shared/attachment-contract.test.ts renderer/shared/chat-message-contract.test.ts renderer/shared/slash-commands.test.ts", "test:google-provider": "tsx --test main/services/anthropic-provider.test.ts main/services/google-provider.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/provider-config-migration-core.test.ts main/services/chat-store-core.test.ts main/services/schedule-store.test.ts renderer/lib/google-provider-migration.test.ts", "test:config-recovery": "tsx --test main/services/secret-map-core.test.ts main/services/provider-credential-rotation-core.test.ts main/services/legacy-pi-credential-migration-core.test.ts main/services/mcp-credential-cleanup-core.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-oauth-store-core.test.ts", "pretest:subagents": "npm run build:worktree-remover && npm run build:subagent-run-store && node scripts/build-subagent-run-store.mjs --test && npm run build:subagent-file-mutator && node scripts/build-subagent-file-mutator.mjs --test && npm run build:subagent-shell-runner && node scripts/build-subagent-shell-runner.mjs --test && npm run test:subagents:inventory && npm run test:subagents:workspace-write && npm run test:subagents:phase5a && npm run test:subagents:phase5b && npm run test:subagents:phase5c && npm run test:subagents:phase5d && npm run test:subagents:phase5e && npm run test:subagents:phase6a && npm run test:subagents:phase6b && npm run test:subagents:phase7a && npm run test:subagents:soak:contracts", @@ -67,16 +72,17 @@ "test:subagents:soak:contracts": "tsx --test main/services/subagents/subagent-packaged-soak-core.test.ts main/services/subagents/subagent-packaged-soak-main.test.ts && node --test scripts/subagent-packaged-soak.test.mjs", "test:subagents:packaged": "node scripts/subagent-packaged-soak.mjs", "test:assistant-automations": "tsx --test main/handlers/scheduled-tasks-parse.test.ts main/services/assistant/automation-runtime-contract.test.ts main/services/assistant/mcp-tool.test.ts main/services/assistant/project-tool.test.ts main/services/assistant/system-prompt.test.ts main/services/assistant/tool-loop-guard.test.ts main/services/mcp-selection.test.ts main/services/scheduled-settings-core.test.ts main/services/schedule-guard.test.ts main/services/schedule-service-core.test.ts main/services/schedule-store.test.ts main/services/schedule-tool.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/lib/scheduled-mcp-access-contract.test.ts renderer/shared/assistant.test.ts", - "test:onboarding": "tsx --test main/services/onboarding-reset-core.test.ts main/services/onboarding-reset-lifecycle.test.ts renderer/components/onboarding-flow.test.tsx renderer/lib/onboarding-state.test.ts", + "test:onboarding": "tsx --test main/services/onboarding-provider-validation.test.ts main/services/onboarding-reset-core.test.ts main/services/onboarding-reset-lifecycle.test.ts main/services/onboarding-state-core.test.ts renderer/components/onboarding-flow.test.tsx renderer/lib/onboarding-state.test.ts renderer/shared/onboarding.test.ts", "test:e2e": "npm run type-check:e2e && npm run build && playwright test --config=playwright.config.ts --fail-on-flaky-tests", "test:e2e:list": "playwright test --config=playwright.config.ts --list", "test:e2e:live:lmstudio": "npm run type-check:e2e && npm run build && AIDEN_E2E_LIVE_LMSTUDIO=1 playwright test --config=playwright.config.ts", "test:terminal:coverage": "tsx --test --experimental-test-coverage --test-coverage-include=main/services/terminal-spawn-helper.ts --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 main/services/terminal.test.ts && tsx --test --experimental-test-coverage --test-coverage-include=main/services/terminal.ts --test-coverage-lines=95 --test-coverage-branches=80 --test-coverage-functions=90 main/services/terminal.test.ts", "test:provider-failure": "tsx --test main/services/provider-failure.test.ts", + "test:concentrate": "tsx --test main/services/concentrate-provider.test.ts", "test:compaction": "tsx --test main/services/pi-runtime-events.test.ts main/services/pi-agent-runtime-harness.test.ts main/services/pi-compaction-core.test.ts main/services/pi-runtime-effect-store.test.ts main/services/generation-context.test.ts main/services/subagents/agent-compatibility.test.ts renderer/lib/ipc-stream.test.ts renderer/main/chat-transition.test.tsx", "test:telegram": "tsx --test main/services/telegram/telegram-controls.test.ts main/services/telegram/telegram-inbound.test.ts main/services/telegram/telegram-outbound.test.ts main/services/telegram/telegram-queue.test.ts main/services/telegram/telegram-markdown.test.ts main/services/telegram/telegram-bot-api.test.ts main/services/telegram/telegram-turn.test.ts main/services/telegram/telegram-service-core.test.ts main/services/telegram/telegram-workspace-core.test.ts main/services/telegram/telegram-activity.test.ts main/services/telegram/telegram-profile-config.test.ts main/services/telegram/telegram-extension-registry.test.ts main/services/telegram/telegram-thread-store.test.ts main/services/telegram/telegram-ownership.test.ts main/services/telegram/telegram-agent-tools.test.ts renderer/lib/telegram-workspace-options.test.ts", - "test": "tsx --test main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native", - "test:coverage": "tsx --test --experimental-test-coverage main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", + "test": "tsx --test main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native", + "test:coverage": "tsx --test --experimental-test-coverage main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-activity-core.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/provider-artwork-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/chat-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs", "test:computer-use": "tsx --test main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/quit-barrier.test.ts main/services/tool-approval.test.ts scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:computer-use:native", "test:computer-use:packaged": "node scripts/computer-use-packaged-acceptance.mjs", "test:computer-use:native": "cd native/computer-use-broker && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo fmt -- --check && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo test --locked && CARGO_TARGET_DIR=../../build/computer-use-broker-test cargo clippy --locked --all-targets -- -D warnings", @@ -116,6 +122,7 @@ "lucide-react": "^0.542.0", "mdast-util-to-string": "4.0.0", "node-pty": "^1.1.0", + "qrcode": "^1.5.4", "radix-ui": "^1.4.3", "re2-wasm": "1.0.2", "react": "^19.1.1", @@ -140,6 +147,7 @@ "@rolldown/plugin-babel": "^0.2.1", "@tailwindcss/vite": "^4.2.2", "@types/node": "^24.3.1", + "@types/qrcode": "^1.5.5", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", "@typescript-eslint/eslint-plugin": "^8.18.0", diff --git a/protocol/aiden-appearance-v1.json b/protocol/aiden-appearance-v1.json new file mode 100644 index 00000000..0621c0a9 --- /dev/null +++ b/protocol/aiden-appearance-v1.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "presets": [ + { + "id": "aiden", + "label": "Aiden", + "light": { "canvas": "#F6F7F9", "sidebar": "#EEF0F3", "raised": "#FFFFFF", "foreground": "#3D3F41", "secondary": "#6B7280", "accent": "#006AD6", "success": "#30D158", "warning": "#FF9F0A", "danger": "#FF453A" }, + "dark": { "canvas": "#181B21", "sidebar": "#20242C", "raised": "#292E37", "foreground": "#D1D4DA", "secondary": "#9AA3AE", "accent": "#3E97F6", "success": "#32D17A", "warning": "#FFB020", "danger": "#FF5E57" } + }, + { + "id": "slate", + "label": "Slate", + "light": { "canvas": "#F2F5F9", "sidebar": "#E6EBF2", "raised": "#FFFFFF", "foreground": "#3A434E", "secondary": "#637083", "accent": "#087581", "success": "#2DB67D", "warning": "#E0A72E", "danger": "#E24D5B" }, + "dark": { "canvas": "#181E26", "sidebar": "#202833", "raised": "#29323E", "foreground": "#D1D6DE", "secondary": "#94A3BB", "accent": "#21A9BE", "success": "#35C08A", "warning": "#D4A72C", "danger": "#F87171" } + }, + { + "id": "berry", + "label": "Berry", + "light": { "canvas": "#FBF4F7", "sidebar": "#F1E8EE", "raised": "#FFFFFF", "foreground": "#443F4A", "secondary": "#6E6470", "accent": "#B42C70", "success": "#22C7A8", "warning": "#E3A23C", "danger": "#E24C5A" }, + "dark": { "canvas": "#1D1822", "sidebar": "#251D2B", "raised": "#2E2435", "foreground": "#D5CFD6", "secondary": "#A39AA6", "accent": "#E8629F", "success": "#32D1B2", "warning": "#D9A441", "danger": "#F0717A" } + }, + { + "id": "moss", + "label": "Moss", + "light": { "canvas": "#F3F6F4", "sidebar": "#E7ECE8", "raised": "#FFFFFF", "foreground": "#3F4943", "secondary": "#65736B", "accent": "#157862", "success": "#3DBF7D", "warning": "#D4A22A", "danger": "#E05353" }, + "dark": { "canvas": "#18201C", "sidebar": "#202A25", "raised": "#29342E", "foreground": "#D1D6D3", "secondary": "#95A39B", "accent": "#42B596", "success": "#47D18C", "warning": "#D9B43A", "danger": "#EB6B6B" } + } + ] +} diff --git a/protocol/aiden-remote/v1/fixtures/contract.json b/protocol/aiden-remote/v1/fixtures/contract.json new file mode 100644 index 00000000..1f8b5b95 --- /dev/null +++ b/protocol/aiden-remote/v1/fixtures/contract.json @@ -0,0 +1,217 @@ +{ + "contractRevision": 6, + "protocolVersion": 1, + "generated": false, + "notice": "Synthetic fixtures only. Values are not credentials and contain no machine-local paths.", + "capabilities": [ + "server:read", + "chat:read", + "chat:write", + "approval:respond", + "workspace:read", + "workspace:browse", + "workspace:manage", + "files:read", + "files:write", + "git:read", + "git:write", + "schedule:read", + "schedule:write" + ], + "health": { "ok": true, "protocolVersion": 1 }, + "pairingBootstrap": { + "protocolVersion": 1, + "instanceId": "instance_fixture_01", + "endpoint": "https://aiden-fixture.example.test/api/aiden/v1", + "serverSpkiSha256": "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "secret": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "expiresAt": "2026-08-18T19:05:00.000Z" + }, + "pairingExchange": { + "protocolVersion": 1, + "instanceId": "instance_fixture_01", + "deviceId": "device_fixture_01", + "credential": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "displayName": "Fixture Aiden", + "capabilities": ["server:read", "chat:read", "chat:write", "workspace:read", "workspace:browse", "workspace:manage"], + "endpoint": "https://aiden-fixture.example.test/api/aiden/v1", + "serverSpkiSha256": "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "server": { + "protocolVersion": 1, + "instanceId": "instance_fixture_01", + "name": "Fixture Aiden", + "appVersion": "0.30.0", + "capabilities": ["server:read", "chat:read", "chat:write", "workspace:read", "workspace:browse", "workspace:manage"], + "connectionMode": "both", + "minimumClientVersion": "1.0.0", + "serverTime": "2026-08-18T19:00:00.000Z" + }, + "workspaces": [ + { + "id": "workspace_fixture_01", + "name": "Aiden Agent", + "permission": "ask", + "hasFolder": true, + "isManagedWorktree": false, + "git": { "isRepo": true, "branch": "feature/mobile", "uncommitted": 2 }, + "createdAt": "2026-08-18T18:00:00.000Z", + "updatedAt": "2026-08-18T18:30:00.000Z", + "revision": "workspace_revision_7" + }, + { + "id": "workspace_fixture_02", + "name": "Scratch", + "permission": "none", + "hasFolder": true, + "isManagedWorktree": true, + "branchName": "aiden/scratch", + "repositoryName": "Fixture Repository", + "createdAt": "2026-08-18T18:10:00.000Z", + "updatedAt": "2026-08-18T18:10:00.000Z", + "revision": "workspace_revision_1" + } + ], + "browser": { + "roots": [{ "id": "root_fixture_01", "label": "Projects", "location": "loc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "policyRevision": "root_policy_3" }], + "page": { + "rootId": "root_fixture_01", + "label": "Projects", + "breadcrumbs": [{ "label": "Projects", "location": "loc_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" }], + "entries": [ + { "id": "directory_fixture_01", "name": "Aiden", "location": "loc_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" }, + { "id": "directory_fixture_02", "name": "Examples", "location": "loc_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC" } + ], + "nextCursor": "cur_DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD" + }, + "selection": { "selection": "sel_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "displayName": "Aiden", "expiresAt": "2026-08-18T19:05:00.000Z" } + }, + "chat": { + "id": "chat_fixture_01", + "workspaceId": "workspace_fixture_01", + "title": "Remote protocol review", + "providerId": "provider_fixture", + "modelId": "model_fixture", + "messages": [ + { "id": "message_fixture_user_01", "role": "user", "text": "Review the protocol.", "createdAt": "2026-08-18T19:00:00.000Z" }, + { "id": "message_fixture_assistant_01", "role": "assistant", "text": "Starting the review.", "createdAt": "2026-08-18T19:00:01.000Z" } + ], + "createdAt": "2026-08-18T18:59:00.000Z", + "updatedAt": "2026-08-18T19:00:01.000Z", + "revision": "chat_revision_4" + }, + "turnStart": { + "turnId": "turn_fixture_01", + "streamId": "stream_fixture_01", + "status": "accepted", + "message": { "id": "message_fixture_user_02", "role": "user", "text": "Continue.", "createdAt": "2026-08-18T19:01:00.000Z" } + }, + "streamStatus": { + "streamId": "stream_fixture_01", + "chatId": "chat_fixture_01", + "turnId": "turn_fixture_01", + "state": "waiting_for_approval", + "lastSequence": 9, + "updatedAt": "2026-08-18T19:01:09.000Z" + }, + "streamApproval": { + "approval": { + "approvalId": "approval_fixture_01", + "streamId": "stream_fixture_01", + "chatId": "chat_fixture_01", + "summary": "Allow a write operation?", + "toolCallId": "tool_fixture_01", + "toolName": "write", + "expiresAt": "2026-08-18T19:06:08.000Z", + "canAllow": true + } + }, + "events": [ + { "protocolVersion": 1, "streamId": "stream_fixture_01", "sequence": 1, "timestamp": "2026-08-18T19:01:01.000Z", "type": "snapshot", "terminal": false, "payload": { "chatId": "chat_fixture_01", "turnId": "turn_fixture_01", "nextSequence": 2 } }, + { "protocolVersion": 1, "streamId": "stream_fixture_01", "sequence": 2, "timestamp": "2026-08-18T19:01:02.000Z", "type": "status", "terminal": false, "payload": { "state": "running" } }, + { "protocolVersion": 1, "streamId": "stream_fixture_01", "sequence": 3, "timestamp": "2026-08-18T19:01:03.000Z", "type": "reasoning_delta", "terminal": false, "payload": { "text": "Checking invariants." } }, + { "protocolVersion": 1, "streamId": "stream_fixture_01", "sequence": 4, "timestamp": "2026-08-18T19:01:04.000Z", "type": "text_delta", "terminal": false, "payload": { "text": "The protocol " } }, + { "protocolVersion": 1, "streamId": "stream_fixture_01", "sequence": 5, "timestamp": "2026-08-18T19:01:05.000Z", "type": "tool_started", "terminal": false, "payload": { "toolId": "tool_fixture_01", "name": "read" } }, + { "protocolVersion": 1, "streamId": "stream_fixture_01", "sequence": 6, "timestamp": "2026-08-18T19:01:06.000Z", "type": "timeline", "terminal": false, "payload": { "timeline": { "version": 3, "generationId": "stream_fixture_01", "status": "running", "startedAt": 1787079660000, "steps": [{ "id": "tool-1", "order": 0, "kind": "tool", "toolCallId": "call-1", "toolName": "read_file", "label": "Read file", "status": "running", "startedAt": 1787079660000, "updatedAt": 1787079660000, "contentOffset": 0, "target": "README.md" }] } } }, + { "protocolVersion": 1, "streamId": "stream_fixture_01", "sequence": 7, "timestamp": "2026-08-18T19:01:07.000Z", "type": "tool_finished", "terminal": false, "payload": { "toolId": "tool_fixture_01", "status": "succeeded" } }, + { "protocolVersion": 1, "streamId": "stream_fixture_01", "sequence": 8, "timestamp": "2026-08-18T19:01:08.000Z", "type": "approval_required", "terminal": false, "payload": { "approvalId": "approval_fixture_01", "summary": "Allow a write operation?", "expiresAt": "2026-08-18T19:06:08.000Z" } }, + { "protocolVersion": 1, "streamId": "stream_fixture_01", "sequence": 9, "timestamp": "2026-08-18T19:01:09.000Z", "type": "heartbeat", "terminal": false, "payload": {} }, + { "protocolVersion": 1, "streamId": "stream_fixture_01", "sequence": 10, "timestamp": "2026-08-18T19:01:10.000Z", "type": "done", "terminal": true, "payload": { "messageId": "message_fixture_assistant_02" } }, + { "protocolVersion": 1, "streamId": "stream_fixture_error", "sequence": 1, "timestamp": "2026-08-18T19:02:00.000Z", "type": "error", "terminal": true, "payload": { "code": "server_interrupted", "message": "Aiden restarted before the turn completed." } }, + { "protocolVersion": 1, "streamId": "stream_fixture_cancelled", "sequence": 1, "timestamp": "2026-08-18T19:03:00.000Z", "type": "cancelled", "terminal": true, "payload": { "source": "device" } } + ], + "fileIndex": { + "snapshotId": "file_snapshot_fixture_01", + "entries": [ + { "id": "file_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "displayPath": "README.md", "name": "README.md", "kind": "file", "size": 1200, "language": "markdown" }, + { "id": "file_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "displayPath": "src", "name": "src", "kind": "directory" } + ], + "truncated": false, + "maxEntries": 4000, + "maxDepth": 20 + }, + "fileDocument": { + "id": "file_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "displayPath": "README.md", + "content": "# Fixture\n", + "version": "sha256:fixture_version_01", + "truncated": false + }, + "git": { + "operationId": "git_operation_fixture_01", + "status": "snapshot", + "snapshotId": "git_snapshot_fixture_01", + "capability": { "allowed": true }, + "result": { "kind": "review", "branch": "feature/mobile", "uncommitted": 2, "files": [{ "id": "file_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "displayPath": "README.md", "status": "modified" }] } + }, + "scheduledTask": { + "id": "task_fixture_01", + "revision": "task_revision_2", + "name": "Daily review", + "enabled": true, + "schedule": "0 9 * * *", + "timezone": "America/New_York", + "mode": "llm", + "permission": "read-only", + "workspaceId": "workspace_fixture_01", + "providerId": "provider_fixture", + "modelId": "model_fixture", + "mcpServerIds": ["mcp_fixture_01"], + "notify": true, + "running": false, + "createdAt": "2026-08-18T18:00:00.000Z", + "updatedAt": "2026-08-18T18:30:00.000Z" + }, + "scheduleSettings": { + "revision": "schedule_settings_revision_3", + "enabled": true, + "defaultMode": "llm", + "defaultPermission": "read-only", + "defaultMcpEnabled": false, + "defaultNotify": true, + "defaultTimezone": "America/New_York" + }, + "scheduleRunAccepted": { + "taskId": "task_fixture_01", + "runId": "run_fixture_01", + "status": "accepted", + "acceptedAt": "2026-08-18T19:10:00.000Z" + }, + "scheduleRun": { + "id": "run_fixture_01", + "taskId": "task_fixture_01", + "status": "succeeded", + "startedAt": "2026-08-18T19:10:00.000Z", + "finishedAt": "2026-08-18T19:10:03.000Z", + "summary": "Review completed." + }, + "error": { + "error": { + "code": "revision_conflict", + "message": "The workspace changed. Refresh and try again.", + "requestId": "request_fixture_01", + "retryable": false, + "details": { "currentRevision": "workspace_revision_8" } + } + } +} diff --git a/protocol/aiden-remote/v1/fixtures/manual-pairing-vector.json b/protocol/aiden-remote/v1/fixtures/manual-pairing-vector.json new file mode 100644 index 00000000..e11380cd --- /dev/null +++ b/protocol/aiden-remote/v1/fixtures/manual-pairing-vector.json @@ -0,0 +1,14 @@ +{ + "code": "0123-4567-89AB-CDEF-GHJK", + "payload": "{\"kind\":\"aiden-pairing-v1\",\"bootstrap\":{\"protocolVersion\":1,\"instanceId\":\"instance_fixture_01\",\"endpoint\":\"https://aiden-fixture.example.test/api/aiden/v1\",\"serverSpkiSha256\":\"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\",\"secret\":\"AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE\",\"expiresAt\":\"2026-08-21T17:05:00.000Z\"},\"trust\":{\"mode\":\"system\"}}", + "bootstrap": { + "kind": "aiden-manual-pairing-v1", + "protocolVersion": 1, + "sessionId": "pairing_ssssssssssssssssssssssssssssssss", + "expiresAt": "2026-08-21T17:05:00.000Z", + "salt": "AAECAwQFBgcICQoLDA0ODw", + "nonce": "EBESExQVFhcYGRob", + "ciphertext": "rvjdeCbqBsel3faU24lEJ2YZUxyGXuh2qKSyWL_RUK3Hh9JiyfIoYqT3ismISu8duWMCkhKmDneYWswEOGG6l_2OADKUmsPtgdsYQ8UpAGnX_prF044mtYFBmf9-_z7DYQz0d5_uJ7RWg9JnI0K8lAuM-5nQYBV1eWgZZ0aclBLTzIGFP2N4Dd7szHapNNrJia_HoEoQ6wopSZoMAJrnkuJcxLtEVQrRla3YEWR4KAkwrGPP8HyZ5OQ6vQT9e9waInCqWCOq5Se9r8xZ-dL-l6P4XdfUHp74zUIYgNduqd2ZVs-9vIu3Y1WXlpkosTO4ulF4goF-JfiZp-QZlc_uo2299twQVTy2ZCGNuGwaOkF_yixW0i66X-H9ZmPdqDwToq0SahQ3Q8HXR2Aqx64Nz-ZBvJ0ESMz5SVlMIJfXKbuafRQR5Dy0Y42kqlt6rnh18_Jiz9N8N219M54Dqd8", + "tag": "P-ntrj59ZSt45orya5fSvQ" + } +} diff --git a/protocol/aiden-remote/v1/openapi.json b/protocol/aiden-remote/v1/openapi.json new file mode 100644 index 00000000..a7114659 --- /dev/null +++ b/protocol/aiden-remote/v1/openapi.json @@ -0,0 +1,333 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Aiden Remote API", + "version": "1.0.0", + "description": "Normative Aiden On The Go REST/SSE contract. Filesystem paths and credentials are never wire identifiers." + }, + "servers": [{ "url": "https://{host}/api/aiden/v1", "variables": { "host": { "default": "aiden.invalid" } } }], + "security": [{ "deviceBearer": [], "protocolVersion": [] }], + "paths": { + "/health": { + "get": { + "operationId": "health", + "security": [], + "responses": { "200": { "description": "Minimal readiness", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Health" } } } } } + } + }, + "/pairing/manual-bootstrap": { + "post": { + "operationId": "pairingManualBootstrap", + "security": [], + "description": "Returns the active pairing trust envelope encrypted under the locally displayed 100-bit setup code. The code is never sent in this request.", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "maxProperties": 0, "additionalProperties": false } } } }, + "responses": { "200": { "description": "Sealed pairing trust envelope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ManualPairingBootstrap" } } } }, "default": { "$ref": "#/components/responses/Error" } } + } + }, + "/pairing/exchange": { + "post": { + "operationId": "pairingExchange", + "security": [], + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PairingExchangeRequest" } } } }, + "responses": { "200": { "description": "Issued device credential", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PairingExchangeResponse" } } } }, "default": { "$ref": "#/components/responses/Error" } } + } + }, + "/server": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "getServer", "x-aiden-capability": "server:read", "responses": { "200": { "description": "Safe server projection", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Server" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspaces": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "listWorkspaces", "x-aiden-capability": "workspace:read", "responses": { "200": { "description": "Workspace list", "content": { "application/json": { "schema": { "type": "object", "required": ["workspaces"], "properties": { "workspaces": { "type": "array", "items": { "$ref": "#/components/schemas/Workspace" } } }, "additionalProperties": false } } } }, "default": { "$ref": "#/components/responses/Error" } } }, + "post": { "operationId": "createWorkspace", "x-aiden-capability": "workspace:manage", "parameters": [{ "$ref": "#/components/parameters/IdempotencyKey" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceCreate" } } } }, "responses": { "201": { "description": "Workspace created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workspace" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspaces/{workspaceId}": { + "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }, { "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "getWorkspace", "x-aiden-capability": "workspace:read", "responses": { "200": { "description": "Workspace", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workspace" } } } }, "default": { "$ref": "#/components/responses/Error" } } }, + "patch": { "operationId": "updateWorkspace", "x-aiden-capability": "workspace:manage", "parameters": [{ "$ref": "#/components/parameters/IfMatch" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspacePatch" } } } }, "responses": { "200": { "description": "Updated workspace", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workspace" } } } }, "default": { "$ref": "#/components/responses/Error" } } }, + "delete": { "operationId": "unregisterWorkspace", "x-aiden-capability": "workspace:manage", "parameters": [{ "$ref": "#/components/parameters/IfMatch" }], "responses": { "204": { "description": "Registry record removed; folder untouched" }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspace-browser/roots": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "listWorkspaceBrowserRoots", "x-aiden-capability": "workspace:browse", "responses": { "200": { "description": "Approved roots", "content": { "application/json": { "schema": { "type": "object", "required": ["roots"], "properties": { "roots": { "type": "array", "items": { "$ref": "#/components/schemas/BrowserRoot" } } }, "additionalProperties": false } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspace-browser/children": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "listWorkspaceBrowserChildren", "x-aiden-capability": "workspace:browse", "parameters": [{ "name": "location", "in": "query", "required": true, "schema": { "type": "string", "pattern": "^loc_[A-Za-z0-9_-]{43}$" } }, { "name": "cursor", "in": "query", "required": false, "schema": { "type": "string", "pattern": "^cur_[A-Za-z0-9_-]{43}$" } }], "responses": { "200": { "description": "Bounded directory page", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BrowserPage" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspace-browser/selections": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "createWorkspaceSelection", "x-aiden-capability": "workspace:browse", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["location"], "properties": { "location": { "type": "string", "pattern": "^loc_[A-Za-z0-9_-]{43}$" } }, "additionalProperties": false } } } }, "responses": { "201": { "description": "Single-use selection nonce", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceSelection" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/chats": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "listChats", "x-aiden-capability": "chat:read", "parameters": [{ "name": "workspaceId", "in": "query", "required": false, "schema": { "type": "string" } }], "responses": { "200": { "description": "Chat list", "content": { "application/json": { "schema": { "type": "object", "required": ["chats"], "properties": { "chats": { "type": "array", "items": { "$ref": "#/components/schemas/Chat" } } }, "additionalProperties": false } } } }, "default": { "$ref": "#/components/responses/Error" } } }, + "post": { "operationId": "createChat", "x-aiden-capability": "chat:write", "parameters": [{ "$ref": "#/components/parameters/IdempotencyKey" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["workspaceId"], "properties": { "workspaceId": { "type": "string" }, "providerId": { "type": "string" }, "modelId": { "type": "string" } }, "additionalProperties": false } } } }, "responses": { "201": { "description": "Chat created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Chat" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/chats/{chatId}": { + "parameters": [{ "$ref": "#/components/parameters/ChatId" }, { "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "getChat", "x-aiden-capability": "chat:read", "responses": { "200": { "description": "Chat snapshot", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Chat" } } } }, "default": { "$ref": "#/components/responses/Error" } } }, + "patch": { "operationId": "updateChat", "x-aiden-capability": "chat:write", "parameters": [{ "$ref": "#/components/parameters/IfMatch" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["title"], "properties": { "title": { "type": "string", "minLength": 1, "maxLength": 200 } }, "additionalProperties": false } } } }, "responses": { "200": { "description": "Updated chat", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Chat" } } } }, "default": { "$ref": "#/components/responses/Error" } } }, + "delete": { "operationId": "deleteChat", "x-aiden-capability": "chat:write", "parameters": [{ "$ref": "#/components/parameters/IfMatch" }], "responses": { "204": { "description": "Chat removed" }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/chats/{chatId}/move": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "moveChat", "x-aiden-capability": "chat:write", "parameters": [{ "$ref": "#/components/parameters/ChatId" }, { "$ref": "#/components/parameters/IfMatch" }, { "$ref": "#/components/parameters/IdempotencyKey" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["workspaceId", "confirmedForeground"], "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128 }, "confirmedForeground": { "const": true } }, "additionalProperties": false } } } }, "responses": { "200": { "description": "Chat moved to the selected workspace", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Chat" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/chats/{chatId}/turns": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "startTurn", "x-aiden-capability": "chat:write", "parameters": [{ "$ref": "#/components/parameters/ChatId" }, { "$ref": "#/components/parameters/IdempotencyKey" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TurnStartRequest" } } } }, "responses": { "202": { "description": "Turn accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TurnStartResponse" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/chats/{chatId}/attachments": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "uploadChatAttachment", "x-aiden-capability": "chat:write", "parameters": [{ "$ref": "#/components/parameters/ChatId" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AttachmentUpload" } } } }, "responses": { "201": { "description": "Short-lived, single-use attachment reference", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AttachmentReference" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/chats/{chatId}/attachments/{attachmentId}": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "delete": { "operationId": "removeChatAttachment", "x-aiden-capability": "chat:write", "parameters": [{ "$ref": "#/components/parameters/ChatId" }, { "name": "attachmentId", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^att_[A-Za-z0-9_-]{43}$" } }], "responses": { "204": { "description": "Temporary attachment removed" }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/chats/{chatId}/attachments/{attachmentId}/content": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "getChatAttachmentContent", "x-aiden-capability": "chat:read", "parameters": [{ "$ref": "#/components/parameters/ChatId" }, { "name": "attachmentId", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^[A-Za-z0-9._:-]{1,256}$" } }], "responses": { "200": { "description": "Bounded raster image content owned by this chat", "content": { "image/png": { "schema": { "type": "string", "contentEncoding": "binary", "maxLength": 8388608 } }, "image/jpeg": { "schema": { "type": "string", "contentEncoding": "binary", "maxLength": 8388608 } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/streams/{streamId}": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "getStream", "x-aiden-capability": "chat:read", "parameters": [{ "$ref": "#/components/parameters/StreamId" }], "responses": { "200": { "description": "Stream status", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StreamStatus" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/streams/{streamId}/events": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "streamEvents", "x-aiden-capability": "chat:read", "parameters": [{ "$ref": "#/components/parameters/StreamId" }, { "name": "Last-Event-ID", "in": "header", "required": false, "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 } }, { "name": "after", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 } }], "responses": { "200": { "description": "Resumable event stream", "content": { "text/event-stream": { "schema": { "type": "string" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/streams/{streamId}/approval": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "getStreamApproval", "x-aiden-capability": "chat:read", "parameters": [{ "$ref": "#/components/parameters/StreamId" }], "responses": { "200": { "description": "Current bounded approval snapshot for this stream", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StreamApprovalSnapshot" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/streams/{streamId}/cancel": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "cancelStream", "x-aiden-capability": "chat:write", "parameters": [{ "$ref": "#/components/parameters/StreamId" }, { "$ref": "#/components/parameters/IdempotencyKey" }], "responses": { "202": { "description": "Cancellation accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StreamStatus" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/approvals/{approvalId}/respond": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "respondApproval", "x-aiden-capability": "approval:respond", "parameters": [{ "name": "approvalId", "in": "path", "required": true, "schema": { "type": "string" } }, { "$ref": "#/components/parameters/IdempotencyKey" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["decision"], "properties": { "decision": { "enum": ["allow", "deny"] } }, "additionalProperties": false } } } }, "responses": { "200": { "description": "Approval resolved", "content": { "application/json": { "schema": { "type": "object", "required": ["approvalId", "decision", "resolvedAt"], "properties": { "approvalId": { "type": "string" }, "decision": { "enum": ["allow", "deny"] }, "resolvedAt": { "type": "string", "format": "date-time" } }, "additionalProperties": false } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/models": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "listModels", "x-aiden-capability": "chat:read", "responses": { "200": { "description": "Configured provider/model projection", "content": { "application/json": { "schema": { "type": "object", "required": ["providers", "defaults"], "properties": { "providers": { "type": "array", "items": { "$ref": "#/components/schemas/Provider" } }, "defaults": { "type": "object", "additionalProperties": { "type": "string" } } }, "additionalProperties": false } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/usage": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "getUsageSummary", "x-aiden-capability": "server:read", "parameters": [{ "name": "range", "in": "query", "required": true, "schema": { "enum": ["7d", "30d", "90d", "1y", "all"] } }], "responses": { "200": { "description": "Privacy-safe aggregate usage from this Aiden Agent installation", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UsageSummary" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspaces/{workspaceId}/files": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "listWorkspaceFiles", "x-aiden-capability": "files:read", "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }], "responses": { "200": { "description": "Bounded recursive file index", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FileIndex" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspaces/{workspaceId}/files/{fileId}": { + "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }, { "name": "fileId", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^file_[A-Za-z0-9_-]{43}$" } }, { "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "readWorkspaceFile", "x-aiden-capability": "files:read", "responses": { "200": { "description": "Readable text document", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FileDocument" } } } }, "default": { "$ref": "#/components/responses/Error" } } }, + "put": { "operationId": "writeWorkspaceFile", "x-aiden-capability": "files:write", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["content", "expectedVersion"], "properties": { "content": { "type": "string", "maxLength": 5242880 }, "expectedVersion": { "type": "string", "maxLength": 128 } }, "additionalProperties": false } } } }, "responses": { "200": { "description": "Authoritative saved document", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FileDocument" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspaces/{workspaceId}/git/review": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "reviewGitWorkspace", "x-aiden-capability": "git:read", "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }], "responses": { "200": { "description": "Bounded repository review snapshot", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitResult" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspaces/{workspaceId}/git/diff": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "readGitDiff", "x-aiden-capability": "git:read", "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitDiffRequest" } } } }, "responses": { "200": { "description": "Bounded diff from an immutable review snapshot", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitResult" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspaces/{workspaceId}/git/branches": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "listGitBranches", "x-aiden-capability": "git:read", "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }], "responses": { "200": { "description": "Allowlisted branch projection", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitResult" } } } }, "default": { "$ref": "#/components/responses/Error" } } }, + "post": { "operationId": "createGitBranch", "x-aiden-capability": "git:write", "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }, { "$ref": "#/components/parameters/IdempotencyKey" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitCreateBranchRequest" } } } }, "responses": { "202": { "description": "Branch creation accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitResult" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspaces/{workspaceId}/git/checkout": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "checkoutGitBranch", "x-aiden-capability": "git:write", "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }, { "$ref": "#/components/parameters/IdempotencyKey" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitCheckoutRequest" } } } }, "responses": { "202": { "description": "Checkout accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitResult" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspaces/{workspaceId}/git/commit": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "commitGitWorkspace", "x-aiden-capability": "git:write", "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }, { "$ref": "#/components/parameters/IdempotencyKey" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitCommitRequest" } } } }, "responses": { "202": { "description": "Snapshot-bound commit accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitResult" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspaces/{workspaceId}/git/push-capability": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "getGitPushCapability", "x-aiden-capability": "git:read", "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }], "responses": { "200": { "description": "Safe push capability and reason", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitResult" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspaces/{workspaceId}/git/push": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "pushGitWorkspace", "x-aiden-capability": "git:write", "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }, { "$ref": "#/components/parameters/IdempotencyKey" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitPushRequest" } } } }, "responses": { "202": { "description": "Reviewed push accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitResult" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspaces/{workspaceId}/git/compare": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "compareGitBranch", "x-aiden-capability": "git:read", "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitCompareRequest" } } } }, "responses": { "200": { "description": "Immutable comparison snapshot", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitResult" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspaces/{workspaceId}/git/comparison-diff": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "readGitComparisonDiff", "x-aiden-capability": "git:read", "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitComparisonDiffRequest" } } } }, "responses": { "200": { "description": "Bounded comparison diff", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitResult" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspaces/{workspaceId}/git/worktrees": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "listGitWorktrees", "x-aiden-capability": "git:read", "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }], "responses": { "200": { "description": "Worktrees without filesystem or Git-admin paths", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitResult" } } } }, "default": { "$ref": "#/components/responses/Error" } } }, + "post": { "operationId": "createGitWorktree", "x-aiden-capability": "git:write", "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }, { "$ref": "#/components/parameters/IdempotencyKey" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitCreateWorktreeRequest" } } } }, "responses": { "202": { "description": "Managed worktree creation accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitResult" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/workspaces/{workspaceId}/git/managed-worktree": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "delete": { "operationId": "removeManagedGitWorktree", "x-aiden-capability": "git:write", "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }, { "$ref": "#/components/parameters/IfMatch" }, { "$ref": "#/components/parameters/IdempotencyKey" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ForegroundConfirmation" } } } }, "responses": { "202": { "description": "Persisted-ID-bound managed worktree removal accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GitResult" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/scheduled-tasks": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "listScheduledTasks", "x-aiden-capability": "schedule:read", "responses": { "200": { "description": "Task list", "content": { "application/json": { "schema": { "type": "object", "required": ["tasks"], "properties": { "tasks": { "type": "array", "items": { "$ref": "#/components/schemas/ScheduledTask" } } }, "additionalProperties": false } } } }, "default": { "$ref": "#/components/responses/Error" } } }, + "post": { "operationId": "createScheduledTask", "x-aiden-capability": "schedule:write", "parameters": [{ "$ref": "#/components/parameters/IdempotencyKey" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScheduledTaskMutation" } } } }, "responses": { "201": { "description": "Created task", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScheduledTask" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/scheduled-tasks/{taskId}": { + "parameters": [{ "name": "taskId", "in": "path", "required": true, "schema": { "type": "string" } }, { "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "getScheduledTask", "x-aiden-capability": "schedule:read", "responses": { "200": { "description": "Task", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScheduledTask" } } } }, "default": { "$ref": "#/components/responses/Error" } } }, + "patch": { "operationId": "updateScheduledTask", "x-aiden-capability": "schedule:write", "parameters": [{ "$ref": "#/components/parameters/IfMatch" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScheduledTaskMutation" } } } }, "responses": { "200": { "description": "Updated task", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScheduledTask" } } } }, "default": { "$ref": "#/components/responses/Error" } } }, + "delete": { "operationId": "removeScheduledTask", "x-aiden-capability": "schedule:write", "parameters": [{ "$ref": "#/components/parameters/IfMatch" }], "responses": { "204": { "description": "Task removed" }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/scheduled-tasks/{taskId}/pause": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "pauseScheduledTask", "x-aiden-capability": "schedule:write", "parameters": [{ "$ref": "#/components/parameters/TaskId" }, { "$ref": "#/components/parameters/IfMatch" }, { "$ref": "#/components/parameters/IdempotencyKey" }], "responses": { "202": { "description": "Pause accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScheduledTask" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/scheduled-tasks/{taskId}/resume": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "resumeScheduledTask", "x-aiden-capability": "schedule:write", "parameters": [{ "$ref": "#/components/parameters/TaskId" }, { "$ref": "#/components/parameters/IfMatch" }, { "$ref": "#/components/parameters/IdempotencyKey" }], "responses": { "202": { "description": "Resume accepted", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScheduledTask" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/scheduled-tasks/{taskId}/run": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "runScheduledTask", "x-aiden-capability": "schedule:write", "parameters": [{ "$ref": "#/components/parameters/TaskId" }, { "$ref": "#/components/parameters/IdempotencyKey" }], "responses": { "202": { "description": "Durable run accepted; socket loss does not cancel it", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScheduleRunAccepted" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/scheduled-tasks/{taskId}/runs": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "listScheduledTaskRuns", "x-aiden-capability": "schedule:read", "parameters": [{ "name": "taskId", "in": "path", "required": true, "schema": { "type": "string" } }], "responses": { "200": { "description": "Bounded redacted run history", "content": { "application/json": { "schema": { "type": "object", "required": ["runs"], "properties": { "runs": { "type": "array", "items": { "$ref": "#/components/schemas/ScheduleRun" } } }, "additionalProperties": false } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/scheduled-tasks/preview": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "post": { "operationId": "previewSchedule", "x-aiden-capability": "schedule:read", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["cron", "timezone"], "properties": { "cron": { "type": "string" }, "timezone": { "type": "string" }, "count": { "type": "integer", "minimum": 1, "maximum": 20 } }, "additionalProperties": false } } } }, "responses": { "200": { "description": "Next run timestamps", "content": { "application/json": { "schema": { "type": "object", "required": ["dates"], "properties": { "dates": { "type": "array", "items": { "type": "string", "format": "date-time" } } }, "additionalProperties": false } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/scheduled-tasks/scripts": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "listScheduledScripts", "x-aiden-capability": "schedule:read", "parameters": [{ "name": "workspaceId", "in": "query", "required": false, "schema": { "type": "string" } }], "responses": { "200": { "description": "Server-inventoried scripts without paths", "content": { "application/json": { "schema": { "type": "object", "required": ["scripts"], "properties": { "scripts": { "type": "array", "items": { "type": "object", "required": ["id", "name"], "properties": { "id": { "type": "string" }, "name": { "type": "string" } }, "additionalProperties": false } } }, "additionalProperties": false } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/scheduled-tasks/mcp-servers": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "listScheduledMcpServers", "x-aiden-capability": "schedule:read", "responses": { "200": { "description": "Enabled MCP server IDs and display names only", "content": { "application/json": { "schema": { "type": "object", "required": ["servers"], "properties": { "servers": { "type": "array", "items": { "type": "object", "required": ["id", "name"], "properties": { "id": { "type": "string" }, "name": { "type": "string" } }, "additionalProperties": false } } }, "additionalProperties": false } } } }, "default": { "$ref": "#/components/responses/Error" } } } + }, + "/scheduled-tasks/settings": { + "parameters": [{ "$ref": "#/components/parameters/ProtocolVersion" }], + "get": { "operationId": "getScheduledTaskSettings", "x-aiden-capability": "schedule:read", "responses": { "200": { "description": "Settings projection", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScheduleSettings" } } } }, "default": { "$ref": "#/components/responses/Error" } } }, + "patch": { "operationId": "updateScheduledTaskSettings", "x-aiden-capability": "schedule:write", "parameters": [{ "$ref": "#/components/parameters/IfMatch" }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScheduleSettingsMutation" } } } }, "responses": { "200": { "description": "Updated settings", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScheduleSettings" } } } }, "default": { "$ref": "#/components/responses/Error" } } } + } + }, + "components": { + "securitySchemes": { "deviceBearer": { "type": "http", "scheme": "bearer", "bearerFormat": "Aiden device credential" }, "protocolVersion": { "type": "apiKey", "in": "header", "name": "Aiden-Protocol-Version", "description": "Must be exactly 1." } }, + "responses": { "Error": { "description": "Stable safe error envelope", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } }, + "parameters": { + "IdempotencyKey": { "name": "Idempotency-Key", "in": "header", "required": true, "schema": { "type": "string", "minLength": 16, "maxLength": 128 } }, + "ProtocolVersion": { "name": "Aiden-Protocol-Version", "in": "header", "required": true, "schema": { "const": "1" } }, + "IfMatch": { "name": "If-Match", "in": "header", "required": true, "schema": { "type": "string", "minLength": 1, "maxLength": 128 } }, + "WorkspaceId": { "name": "workspaceId", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1, "maxLength": 128 } }, + "ChatId": { "name": "chatId", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1, "maxLength": 128 } }, + "StreamId": { "name": "streamId", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1, "maxLength": 128 } }, + "TaskId": { "name": "taskId", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1, "maxLength": 128 } } + }, + "schemas": { + "Health": { "type": "object", "required": ["ok", "protocolVersion"], "properties": { "ok": { "const": true }, "protocolVersion": { "const": 1 } }, "additionalProperties": false }, + "PairingBootstrap": { "type": "object", "description": "Canonical bootstrap nested inside the locally displayed QR trust envelope.", "required": ["protocolVersion", "instanceId", "endpoint", "serverSpkiSha256", "secret", "expiresAt"], "properties": { "protocolVersion": { "const": 1 }, "instanceId": { "type": "string", "minLength": 1, "maxLength": 128 }, "endpoint": { "type": "string", "format": "uri", "pattern": "^https://(?:\\[(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,6})?|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,5})?|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){1}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,4})?|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){2}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,3})?|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){3}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,2})?|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){4}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,1})?|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){5}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,0})?|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){6}::|(?:[0-9A-Fa-f]{1,4}:){6}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|::(?:[0-9A-Fa-f]{1,4}:){0,5}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0}::(?:[0-9A-Fa-f]{1,4}:){0,4}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){1}::(?:[0-9A-Fa-f]{1,4}:){0,3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){2}::(?:[0-9A-Fa-f]{1,4}:){0,2}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){3}::(?:[0-9A-Fa-f]{1,4}:){0,1}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){4}::(?:[0-9A-Fa-f]{1,4}:){0,0}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})\\]|(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?=[A-Za-z0-9.-]{1,253}(?::(?:[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?/api/aiden/v1$)(?!(?:[A-Za-z0-9-]+\\.)*[0-9]+(?::[0-9]+)?/api/aiden/v1$)(?=[A-Za-z0-9.-]*[A-Za-z-])[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*)(?::(?:[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?/api/aiden/v1$", "maxLength": 2048 }, "serverSpkiSha256": { "type": "string", "pattern": "^sha256/[A-Za-z0-9+/]{43}=$" }, "secret": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" }, "expiresAt": { "type": "string", "format": "date-time" } }, "additionalProperties": false }, + "PairingTrust": { "type": "object", "oneOf": [{ "required": ["mode", "caCertificateDerBase64"], "properties": { "mode": { "const": "private-ca" }, "caCertificateDerBase64": { "type": "string", "contentEncoding": "base64", "maxLength": 5464 } }, "additionalProperties": false }, { "required": ["mode"], "properties": { "mode": { "const": "system" } }, "additionalProperties": false }] }, + "PairingPayload": { "type": "object", "required": ["kind", "bootstrap", "trust"], "properties": { "kind": { "const": "aiden-pairing-v1" }, "bootstrap": { "$ref": "#/components/schemas/PairingBootstrap" }, "trust": { "$ref": "#/components/schemas/PairingTrust" } }, "additionalProperties": false }, + "ManualPairingBootstrap": { "type": "object", "required": ["kind", "protocolVersion", "sessionId", "expiresAt", "salt", "nonce", "ciphertext", "tag"], "properties": { "kind": { "const": "aiden-manual-pairing-v1" }, "protocolVersion": { "const": 1 }, "sessionId": { "type": "string", "pattern": "^pairing_[A-Za-z0-9_-]{32}$" }, "expiresAt": { "type": "string", "format": "date-time" }, "salt": { "type": "string", "pattern": "^[A-Za-z0-9_-]{22}$" }, "nonce": { "type": "string", "pattern": "^[A-Za-z0-9_-]{16}$" }, "ciphertext": { "type": "string", "minLength": 2, "maxLength": 5462, "pattern": "^[A-Za-z0-9_-]+$" }, "tag": { "type": "string", "pattern": "^[A-Za-z0-9_-]{22}$" } }, "additionalProperties": false }, + "PairingExchangeRequest": { "type": "object", "required": ["secret", "deviceName", "deviceType", "clientVersion"], "properties": { "secret": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" }, "deviceName": { "type": "string", "minLength": 1, "maxLength": 80 }, "deviceType": { "enum": ["iphone", "ipad"] }, "clientVersion": { "type": "string", "minLength": 1, "maxLength": 40 }, "acceptsDisplayName": { "type": "boolean", "description": "Opt in to the additive displayName response field; omitted by legacy strict clients." } }, "additionalProperties": false }, + "PairingExchangeResponse": { "type": "object", "required": ["protocolVersion", "instanceId", "deviceId", "credential", "capabilities", "endpoint", "serverSpkiSha256"], "properties": { "protocolVersion": { "const": 1 }, "instanceId": { "type": "string", "minLength": 1, "maxLength": 128 }, "deviceId": { "type": "string", "minLength": 1, "maxLength": 128 }, "credential": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" }, "capabilities": { "type": "array", "items": { "enum": ["server:read", "chat:read", "chat:write", "approval:respond", "workspace:read", "workspace:browse", "workspace:manage", "files:read", "files:write", "git:read", "git:write", "schedule:read", "schedule:write"] }, "uniqueItems": true }, "displayName": { "type": "string", "minLength": 1, "maxLength": 80 }, "endpoint": { "type": "string", "format": "uri", "pattern": "^https://(?:\\[(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,6})?|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,5})?|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){1}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,4})?|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){2}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,3})?|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){3}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,2})?|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){4}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,1})?|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){5}::(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,0})?|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){6}::|(?:[0-9A-Fa-f]{1,4}:){6}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|::(?:[0-9A-Fa-f]{1,4}:){0,5}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0}::(?:[0-9A-Fa-f]{1,4}:){0,4}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){1}::(?:[0-9A-Fa-f]{1,4}:){0,3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){2}::(?:[0-9A-Fa-f]{1,4}:){0,2}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){3}::(?:[0-9A-Fa-f]{1,4}:){0,1}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){4}::(?:[0-9A-Fa-f]{1,4}:){0,0}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})\\]|(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3})|(?=[A-Za-z0-9.-]{1,253}(?::(?:[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?/api/aiden/v1$)(?!(?:[A-Za-z0-9-]+\\.)*[0-9]+(?::[0-9]+)?/api/aiden/v1$)(?=[A-Za-z0-9.-]*[A-Za-z-])[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*)(?::(?:[1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?/api/aiden/v1$", "maxLength": 2048 }, "serverSpkiSha256": { "type": "string", "pattern": "^sha256/[A-Za-z0-9+/]{43}=$" } }, "additionalProperties": false }, + "Server": { "type": "object", "required": ["protocolVersion", "instanceId", "name", "appVersion", "capabilities", "connectionMode", "serverTime"], "properties": { "protocolVersion": { "const": 1 }, "instanceId": { "type": "string", "minLength": 1, "maxLength": 128 }, "name": { "type": "string", "minLength": 1, "maxLength": 80 }, "appVersion": { "type": "string" }, "capabilities": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "connectionMode": { "enum": ["lan", "tailscale", "both"] }, "minimumClientVersion": { "type": "string" }, "serverTime": { "type": "string", "format": "date-time" } }, "additionalProperties": false }, + "UsageTokens": { "type": "object", "required": ["input", "output", "cacheRead", "cacheWrite", "reasoning", "total"], "properties": { "input": { "type": "integer", "minimum": 0 }, "output": { "type": "integer", "minimum": 0 }, "cacheRead": { "type": "integer", "minimum": 0 }, "cacheWrite": { "type": "integer", "minimum": 0 }, "cacheWrite1h": { "type": "integer", "minimum": 0 }, "reasoning": { "type": "integer", "minimum": 0 }, "total": { "type": "integer", "minimum": 0 } }, "additionalProperties": false }, + "UsageSummary": { "type": "object", "required": ["range", "startDate", "endDate", "totals", "days", "models"], "properties": { "range": { "enum": ["7d", "30d", "90d", "1y", "all"] }, "startDate": { "type": "string", "format": "date" }, "endDate": { "type": "string", "format": "date" }, "totals": { "type": "object", "required": ["requests", "completedRequests", "failedRequests", "cancelledRequests", "reportedTokenRequests", "unmeteredRequests", "localRequests", "costedRequests", "unpricedHostedRequests", "hostedCostUsd", "activeDays", "currentStreak", "longestStreak", "tokens"], "properties": { "requests": { "type": "integer", "minimum": 0 }, "completedRequests": { "type": "integer", "minimum": 0 }, "failedRequests": { "type": "integer", "minimum": 0 }, "cancelledRequests": { "type": "integer", "minimum": 0 }, "reportedTokenRequests": { "type": "integer", "minimum": 0 }, "unmeteredRequests": { "type": "integer", "minimum": 0 }, "localRequests": { "type": "integer", "minimum": 0 }, "costedRequests": { "type": "integer", "minimum": 0 }, "unpricedHostedRequests": { "type": "integer", "minimum": 0 }, "hostedCostUsd": { "type": "number", "minimum": 0 }, "activeDays": { "type": "integer", "minimum": 0 }, "currentStreak": { "type": "integer", "minimum": 0 }, "longestStreak": { "type": "integer", "minimum": 0 }, "tokens": { "$ref": "#/components/schemas/UsageTokens" } }, "additionalProperties": false }, "days": { "type": "array", "items": { "type": "object" } }, "models": { "type": "array", "items": { "type": "object" } } }, "additionalProperties": false }, + "Workspace": { "type": "object", "required": ["id", "name", "permission", "hasFolder", "isManagedWorktree", "createdAt", "updatedAt", "revision"], "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "permission": { "enum": ["full", "ask", "none"] }, "hasFolder": { "type": "boolean" }, "isManagedWorktree": { "type": "boolean" }, "branchName": { "type": "string" }, "repositoryName": { "type": "string" }, "git": { "type": "object", "properties": { "isRepo": { "type": "boolean" }, "branch": { "type": "string" }, "uncommitted": { "type": "integer", "minimum": 0 } }, "required": ["isRepo"], "additionalProperties": false }, "createdAt": { "type": "string", "format": "date-time" }, "updatedAt": { "type": "string", "format": "date-time" }, "revision": { "type": "string" } }, "additionalProperties": false }, + "WorkspaceCreate": { "oneOf": [{ "type": "object", "required": ["mode", "name"], "properties": { "mode": { "const": "folderless" }, "name": { "type": "string", "minLength": 1, "maxLength": 120 } }, "additionalProperties": false }, { "type": "object", "required": ["mode"], "properties": { "mode": { "const": "scratch" } }, "additionalProperties": false }, { "type": "object", "required": ["mode", "selection"], "properties": { "mode": { "const": "selected-folder" }, "selection": { "type": "string", "pattern": "^sel_[A-Za-z0-9_-]{43}$" }, "name": { "type": "string", "minLength": 1, "maxLength": 120 } }, "additionalProperties": false }] }, + "ForegroundConfirmation": { "type": "object", "required": ["confirmedForeground"], "properties": { "confirmedForeground": { "const": true } }, "additionalProperties": false }, + "WorkspacePatch": { "type": "object", "required": ["confirmedForeground"], "minProperties": 2, "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 120 }, "permission": { "enum": ["full", "ask", "none"] }, "confirmedForeground": { "const": true } }, "additionalProperties": false }, + "BrowserRoot": { "type": "object", "required": ["id", "label", "location", "policyRevision"], "properties": { "id": { "type": "string", "maxLength": 128 }, "label": { "type": "string", "maxLength": 120 }, "location": { "type": "string", "pattern": "^loc_[A-Za-z0-9_-]{43}$" }, "policyRevision": { "type": "string", "maxLength": 128 } }, "additionalProperties": false }, + "BrowserEntry": { "type": "object", "required": ["id", "name", "location"], "properties": { "id": { "type": "string", "maxLength": 128 }, "name": { "type": "string", "maxLength": 255 }, "location": { "type": "string", "pattern": "^loc_[A-Za-z0-9_-]{43}$" } }, "additionalProperties": false }, + "BrowserPage": { "type": "object", "required": ["rootId", "label", "breadcrumbs", "entries"], "properties": { "rootId": { "type": "string", "maxLength": 128 }, "label": { "type": "string", "maxLength": 120 }, "breadcrumbs": { "type": "array", "maxItems": 20, "items": { "type": "object", "required": ["label", "location"], "properties": { "label": { "type": "string", "maxLength": 255 }, "location": { "type": "string", "pattern": "^loc_[A-Za-z0-9_-]{43}$" } }, "additionalProperties": false } }, "entries": { "type": "array", "maxItems": 200, "items": { "$ref": "#/components/schemas/BrowserEntry" } }, "nextCursor": { "type": "string", "pattern": "^cur_[A-Za-z0-9_-]{43}$" } }, "additionalProperties": false }, + "WorkspaceSelection": { "type": "object", "required": ["selection", "displayName", "expiresAt"], "properties": { "selection": { "type": "string", "pattern": "^sel_[A-Za-z0-9_-]{43}$" }, "displayName": { "type": "string", "maxLength": 255 }, "expiresAt": { "type": "string", "format": "date-time" } }, "additionalProperties": false }, + "MessageAttachment": { "type": "object", "required": ["id", "name", "mimeType", "kind", "size"], "properties": { "id": { "type": "string", "pattern": "^[A-Za-z0-9._:-]{1,256}$" }, "name": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[^/\\\\\\u0000-\\u001F\\u007F]+$" }, "mimeType": { "type": "string", "maxLength": 120 }, "kind": { "enum": ["image", "text"] }, "size": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 } }, "additionalProperties": false }, + "AttachmentReference": { "type": "object", "required": ["id", "name", "mimeType", "kind", "size", "expiresAt"], "properties": { "id": { "type": "string", "pattern": "^att_[A-Za-z0-9_-]{43}$" }, "name": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[^/\\\\\\u0000-\\u001F\\u007F]+$" }, "mimeType": { "type": "string", "maxLength": 120 }, "kind": { "enum": ["image", "text"] }, "size": { "type": "integer", "minimum": 0, "maximum": 8388608 }, "expiresAt": { "type": "string", "format": "date-time" } }, "additionalProperties": false }, + "AttachmentUpload": { "oneOf": [ + { "type": "object", "required": ["name", "mimeType", "kind", "data"], "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[^/\\\\\\u0000-\\u001F\\u007F]+$" }, "mimeType": { "enum": ["image/png", "image/jpeg"] }, "kind": { "const": "image" }, "data": { "type": "string", "contentEncoding": "base64", "maxLength": 11184812 } }, "additionalProperties": false }, + { "type": "object", "required": ["name", "mimeType", "kind", "text"], "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[^/\\\\\\u0000-\\u001F\\u007F]+$" }, "mimeType": { "enum": ["text/plain", "text/markdown", "text/csv", "application/json", "application/xml", "application/yaml", "application/x-yaml", "application/javascript", "application/typescript"] }, "kind": { "const": "text" }, "text": { "type": "string", "maxLength": 100000 } }, "additionalProperties": false } + ] }, + "MessageOutcome": { "type": "object", "required": ["status"], "properties": { "status": { "enum": ["failed", "cancelled"] }, "category": { "enum": ["network", "timeout", "service_unavailable", "rate_limit", "authentication", "quota", "invalid_request", "context_window", "output_limit", "interrupted", "context_management", "unknown"] }, "attempts": { "type": "integer", "minimum": 0, "maximum": 16 }, "retryExhausted": { "type": "boolean" } }, "additionalProperties": false }, + "GenerationLineChanges": { "type": "object", "required": ["additions", "deletions"], "properties": { "additions": { "type": "integer", "minimum": 0, "maximum": 100000000 }, "deletions": { "type": "integer", "minimum": 0, "maximum": 100000000 } }, "additionalProperties": false }, + "GenerationToolStep": { "type": "object", "required": ["id", "order", "kind", "toolCallId", "toolName", "label", "status", "startedAt", "updatedAt"], "properties": { "id": { "type": "string", "pattern": "^tool-[1-9][0-9]*$" }, "order": { "type": "integer", "minimum": 0, "maximum": 199 }, "kind": { "const": "tool" }, "toolCallId": { "type": "string", "pattern": "^call-[1-9][0-9]*$" }, "toolName": { "type": "string", "minLength": 1, "maxLength": 80 }, "label": { "type": "string", "minLength": 1, "maxLength": 120 }, "status": { "enum": ["pending", "awaiting_approval", "running", "completed", "failed", "blocked", "cancelled"] }, "startedAt": { "type": "number", "minimum": 0 }, "updatedAt": { "type": "number", "minimum": 0 }, "finishedAt": { "type": "number", "minimum": 0 }, "contentOffset": { "type": "integer", "minimum": 0 }, "target": { "type": "string", "minLength": 1, "maxLength": 240, "pattern": "^(?![/~])(?!.*(?:^|[/\\\\])\\.\\.(?:[/\\\\]|$)).+$" }, "detail": { "type": "string", "minLength": 1, "maxLength": 120, "pattern": "^[^\\u0000-\\u001F\\u007F]+$" }, "lineChanges": { "$ref": "#/components/schemas/GenerationLineChanges" } }, "additionalProperties": false }, + "GenerationThinkingStep": { "type": "object", "required": ["id", "order", "kind", "startedAt", "updatedAt"], "properties": { "id": { "type": "string", "pattern": "^think-[1-9][0-9]*$" }, "order": { "type": "integer", "minimum": 0, "maximum": 199 }, "kind": { "const": "thinking" }, "startedAt": { "type": "number", "minimum": 0 }, "updatedAt": { "type": "number", "minimum": 0 }, "finishedAt": { "type": "number", "minimum": 0 }, "contentOffset": { "type": "integer", "minimum": 0 }, "durationMs": { "type": "number", "minimum": 0 } }, "additionalProperties": false }, + "GenerationTimeline": { "type": "object", "required": ["version", "generationId", "status", "startedAt", "steps"], "properties": { "version": { "enum": [1, 2, 3] }, "generationId": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$" }, "status": { "enum": ["running", "completed", "failed", "cancelled"] }, "startedAt": { "type": "number", "minimum": 0 }, "finishedAt": { "type": "number", "minimum": 0 }, "steps": { "type": "array", "maxItems": 200, "items": { "oneOf": [{ "$ref": "#/components/schemas/GenerationToolStep" }, { "$ref": "#/components/schemas/GenerationThinkingStep" }] } }, "cancellationOrigin": { "enum": ["user_stop", "chat_deletion", "workspace_authority_change", "computer_use_disabled", "scheduled_task_cancel", "application_shutdown"] }, "claimCheck": { "type": "object", "required": ["kind", "stepIds"], "properties": { "kind": { "const": "unverified_success" }, "stepIds": { "type": "array", "minItems": 1, "maxItems": 20, "uniqueItems": true, "items": { "type": "string" } } }, "additionalProperties": false } }, "additionalProperties": false }, + "Message": { "type": "object", "required": ["id", "role", "text", "createdAt"], "properties": { "id": { "type": "string", "maxLength": 128 }, "role": { "enum": ["user", "assistant"] }, "text": { "type": "string", "maxLength": 200000 }, "attachments": { "type": "array", "maxItems": 20, "items": { "$ref": "#/components/schemas/MessageAttachment" } }, "outcome": { "$ref": "#/components/schemas/MessageOutcome" }, "timeline": { "$ref": "#/components/schemas/GenerationTimeline" }, "createdAt": { "type": "string", "format": "date-time" } }, "additionalProperties": false }, + "Chat": { "type": "object", "required": ["id", "workspaceId", "title", "messages", "createdAt", "updatedAt", "revision"], "properties": { "id": { "type": "string" }, "workspaceId": { "type": "string" }, "title": { "type": "string" }, "titlePending": { "type": "boolean", "enum": [true] }, "providerId": { "type": "string" }, "modelId": { "type": "string" }, "messages": { "type": "array", "items": { "$ref": "#/components/schemas/Message" } }, "createdAt": { "type": "string", "format": "date-time" }, "updatedAt": { "type": "string", "format": "date-time" }, "revision": { "type": "string" } }, "additionalProperties": false }, + "TurnStartRequest": { "type": "object", "required": ["text"], "anyOf": [{ "properties": { "text": { "minLength": 1 } } }, { "required": ["attachmentIds"], "properties": { "attachmentIds": { "minItems": 1 } } }], "properties": { "text": { "type": "string", "maxLength": 200000 }, "providerId": { "type": "string" }, "modelId": { "type": "string" }, "thinkingLevel": { "type": "string" }, "attachmentIds": { "type": "array", "minItems": 1, "maxItems": 10, "uniqueItems": true, "items": { "type": "string", "pattern": "^att_[A-Za-z0-9_-]{43}$" } } }, "additionalProperties": false }, + "TurnStartResponse": { "type": "object", "required": ["turnId", "streamId", "status", "message"], "properties": { "turnId": { "type": "string" }, "streamId": { "type": "string" }, "status": { "const": "accepted" }, "message": { "$ref": "#/components/schemas/Message" } }, "additionalProperties": false }, + "PendingApproval": { "type": "object", "required": ["approvalId", "streamId", "chatId", "summary", "toolCallId", "toolName", "expiresAt", "canAllow"], "properties": { "approvalId": { "type": "string" }, "streamId": { "type": "string" }, "chatId": { "type": "string" }, "summary": { "type": "string" }, "toolCallId": { "type": "string" }, "toolName": { "type": "string" }, "expiresAt": { "type": "string", "format": "date-time" }, "canAllow": { "type": "boolean", "description": "False when exact privileged details are intentionally host-only; mobile clients must offer Deny only." } }, "additionalProperties": false }, + "StreamApprovalSnapshot": { "type": "object", "required": ["approval"], "properties": { "approval": { "oneOf": [{ "$ref": "#/components/schemas/PendingApproval" }, { "type": "null" }] } }, "additionalProperties": false }, + "StreamStatus": { "type": "object", "required": ["streamId", "chatId", "turnId", "state", "lastSequence", "updatedAt"], "properties": { "streamId": { "type": "string" }, "chatId": { "type": "string" }, "turnId": { "type": "string" }, "state": { "enum": ["queued", "running", "waiting_for_approval", "reconciling", "done", "error", "cancelled", "interrupted"] }, "lastSequence": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "updatedAt": { "type": "string", "format": "date-time" } }, "additionalProperties": false }, + "StreamEvent": { + "type": "object", + "required": ["protocolVersion", "streamId", "sequence", "timestamp", "type", "terminal", "payload"], + "properties": { + "protocolVersion": { "const": 1 }, + "streamId": { "type": "string", "minLength": 1, "maxLength": 128 }, + "sequence": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, + "timestamp": { "type": "string", "format": "date-time" }, + "type": { "type": "string", "minLength": 1, "maxLength": 80 }, + "terminal": { "type": "boolean" }, + "payload": { "type": "object", "maxProperties": 32 } + }, + "allOf": [ + { "if": { "properties": { "type": { "const": "snapshot" } }, "required": ["type"] }, "then": { "properties": { "terminal": { "const": false }, "payload": { "type": "object", "required": ["chatId", "turnId", "nextSequence"], "properties": { "chatId": { "type": "string", "maxLength": 128 }, "turnId": { "type": "string", "maxLength": 128 }, "nextSequence": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 } }, "additionalProperties": false } } } }, + { "if": { "properties": { "type": { "const": "status" } }, "required": ["type"] }, "then": { "properties": { "terminal": { "const": false }, "payload": { "type": "object", "required": ["state"], "properties": { "state": { "enum": ["queued", "running", "waiting_for_approval", "reconciling"] } }, "additionalProperties": false } } } }, + { "if": { "properties": { "type": { "enum": ["text_delta", "reasoning_delta"] } }, "required": ["type"] }, "then": { "properties": { "terminal": { "const": false }, "payload": { "type": "object", "required": ["text"], "properties": { "text": { "type": "string", "maxLength": 200000 } }, "additionalProperties": false } } } }, + { "if": { "properties": { "type": { "const": "tool_started" } }, "required": ["type"] }, "then": { "properties": { "terminal": { "const": false }, "payload": { "type": "object", "required": ["toolId", "name"], "properties": { "toolId": { "type": "string", "maxLength": 128 }, "name": { "type": "string", "maxLength": 120 } }, "additionalProperties": false } } } }, + { "if": { "properties": { "type": { "const": "tool_finished" } }, "required": ["type"] }, "then": { "properties": { "terminal": { "const": false }, "payload": { "type": "object", "required": ["toolId", "status"], "properties": { "toolId": { "type": "string", "maxLength": 128 }, "status": { "enum": ["succeeded", "failed", "cancelled"] } }, "additionalProperties": false } } } }, + { "if": { "properties": { "type": { "const": "timeline" } }, "required": ["type"] }, "then": { "properties": { "terminal": { "const": false }, "payload": { "type": "object", "required": ["timeline"], "properties": { "timeline": { "$ref": "#/components/schemas/GenerationTimeline" } }, "additionalProperties": false } } } }, + { "if": { "properties": { "type": { "const": "approval_required" } }, "required": ["type"] }, "then": { "properties": { "terminal": { "const": false }, "payload": { "type": "object", "required": ["approvalId", "summary", "expiresAt"], "properties": { "approvalId": { "type": "string", "maxLength": 128 }, "summary": { "type": "string", "maxLength": 2000 }, "expiresAt": { "type": "string", "format": "date-time" } }, "additionalProperties": false } } } }, + { "if": { "properties": { "type": { "const": "done" } }, "required": ["type"] }, "then": { "properties": { "terminal": { "const": true }, "payload": { "type": "object", "required": ["messageId"], "properties": { "messageId": { "type": "string", "maxLength": 128 } }, "additionalProperties": false } } } }, + { "if": { "properties": { "type": { "const": "error" } }, "required": ["type"] }, "then": { "properties": { "terminal": { "const": true }, "payload": { "type": "object", "required": ["code", "message"], "properties": { "code": { "$ref": "#/components/schemas/ErrorCode" }, "message": { "type": "string", "maxLength": 2000 } }, "additionalProperties": false } } } }, + { "if": { "properties": { "type": { "const": "cancelled" } }, "required": ["type"] }, "then": { "properties": { "terminal": { "const": true }, "payload": { "type": "object", "required": ["source"], "properties": { "source": { "enum": ["device", "server"] } }, "additionalProperties": false } } } }, + { "if": { "properties": { "type": { "const": "heartbeat" } }, "required": ["type"] }, "then": { "properties": { "terminal": { "const": false }, "payload": { "type": "object", "maxProperties": 0, "additionalProperties": false } } } }, + { "if": { "properties": { "type": { "not": { "enum": ["snapshot", "status", "text_delta", "reasoning_delta", "tool_started", "tool_finished", "timeline", "approval_required", "done", "error", "cancelled", "heartbeat"] } } }, "required": ["type"] }, "then": { "properties": { "terminal": { "const": false } } } } + ], + "additionalProperties": true + }, + "ProviderArtwork": { "type": "object", "required": ["mimeType", "dataBase64"], "properties": { "mimeType": { "const": "image/png" }, "dataBase64": { "type": "string", "maxLength": 43696, "pattern": "^iVBORw0KGgo[A-Za-z0-9+/]*={0,2}$" } }, "additionalProperties": false }, + "Provider": { "type": "object", "required": ["id", "label", "models"], "properties": { "id": { "type": "string" }, "label": { "type": "string" }, "artwork": { "$ref": "#/components/schemas/ProviderArtwork" }, "models": { "type": "array", "items": { "type": "object", "required": ["id", "label"], "properties": { "id": { "type": "string" }, "label": { "type": "string" }, "thinkingLevels": { "type": "array", "maxItems": 8, "items": { "enum": ["off", "minimal", "low", "medium", "high", "xhigh", "max"] } }, "defaultThinkingLevel": { "enum": ["off", "minimal", "low", "medium", "high", "xhigh", "max"] }, "thinkingCanDisable": { "type": "boolean" }, "hidden": { "type": "boolean" } }, "additionalProperties": false } } }, "additionalProperties": false }, + "FileEntry": { "type": "object", "required": ["id", "displayPath", "name", "kind"], "properties": { "id": { "type": "string", "pattern": "^file_[A-Za-z0-9_-]{43}$" }, "displayPath": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$", "maxLength": 4096 }, "name": { "type": "string", "maxLength": 255 }, "kind": { "enum": ["file", "directory", "symlink"] }, "size": { "type": "integer", "minimum": 0, "maximum": 5242880 }, "language": { "type": "string", "maxLength": 80 } }, "additionalProperties": false }, + "FileIndex": { "type": "object", "required": ["snapshotId", "entries", "truncated", "maxEntries", "maxDepth"], "properties": { "snapshotId": { "type": "string" }, "entries": { "type": "array", "maxItems": 4000, "items": { "$ref": "#/components/schemas/FileEntry" } }, "truncated": { "type": "boolean" }, "maxEntries": { "const": 4000 }, "maxDepth": { "const": 20 } }, "additionalProperties": false }, + "FileDocument": { "type": "object", "required": ["id", "displayPath", "content", "version", "truncated"], "properties": { "id": { "type": "string", "pattern": "^file_[A-Za-z0-9_-]{43}$" }, "displayPath": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$", "maxLength": 4096 }, "content": { "type": "string", "maxLength": 5242880 }, "version": { "type": "string", "maxLength": 128 }, "truncated": { "type": "boolean" }, "warning": { "type": "string", "maxLength": 500 } }, "additionalProperties": false }, + "GitDiffRequest": { "type": "object", "required": ["snapshotId", "fileId"], "properties": { "snapshotId": { "type": "string" }, "fileId": { "type": "string" } }, "additionalProperties": false }, + "GitCreateBranchRequest": { "type": "object", "required": ["name", "startPoint", "confirmedForeground"], "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 200 }, "startPoint": { "type": "string", "minLength": 1, "maxLength": 500 }, "confirmedForeground": { "const": true } }, "additionalProperties": false }, + "GitCheckoutRequest": { "type": "object", "required": ["branch", "snapshotId", "confirmedForeground"], "properties": { "branch": { "type": "string", "minLength": 1, "maxLength": 500 }, "snapshotId": { "type": "string" }, "confirmedForeground": { "const": true } }, "additionalProperties": false }, + "GitCommitRequest": { "type": "object", "required": ["snapshotId", "message", "scope", "confirmedForeground"], "properties": { "snapshotId": { "type": "string" }, "message": { "type": "string", "minLength": 1, "maxLength": 20000 }, "scope": { "enum": ["all-reviewed", "staged-reviewed"] }, "confirmedForeground": { "const": true } }, "additionalProperties": false }, + "GitPushRequest": { "type": "object", "required": ["snapshotId", "remote", "branch", "confirmedForeground"], "properties": { "snapshotId": { "type": "string", "maxLength": 128 }, "remote": { "type": "string", "pattern": "^[A-Za-z0-9._-]{1,200}$" }, "branch": { "type": "string", "minLength": 1, "maxLength": 500 }, "confirmedForeground": { "const": true } }, "additionalProperties": false }, + "GitCompareRequest": { "type": "object", "required": ["baseRef"], "properties": { "baseRef": { "type": "string", "minLength": 1, "maxLength": 500 } }, "additionalProperties": false }, + "GitComparisonDiffRequest": { "type": "object", "required": ["comparisonId", "fileId"], "properties": { "comparisonId": { "type": "string" }, "fileId": { "type": "string" } }, "additionalProperties": false }, + "GitCreateWorktreeRequest": { "type": "object", "required": ["branch", "name", "confirmedForeground"], "properties": { "branch": { "type": "string", "minLength": 1, "maxLength": 500 }, "name": { "type": "string", "minLength": 1, "maxLength": 120 }, "confirmedForeground": { "const": true } }, "additionalProperties": false }, + "GitFileProjection": { "type": "object", "required": ["id", "displayPath", "status"], "properties": { "id": { "type": "string", "pattern": "^file_[A-Za-z0-9_-]{43}$" }, "displayPath": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$", "maxLength": 4096 }, "status": { "enum": ["added", "modified", "deleted", "renamed", "untracked", "conflicted"] }, "staged": { "type": "boolean" }, "additions": { "type": "integer", "minimum": 0 }, "deletions": { "type": "integer", "minimum": 0 } }, "additionalProperties": false }, + "GitProjection": { "oneOf": [ + { "type": "object", "required": ["kind", "branch", "uncommitted", "files"], "properties": { "kind": { "const": "review" }, "branch": { "type": "string" }, "uncommitted": { "type": "integer", "minimum": 0 }, "files": { "type": "array", "maxItems": 4000, "items": { "$ref": "#/components/schemas/GitFileProjection" } } }, "additionalProperties": false }, + { "type": "object", "required": ["kind", "displayPath", "diff", "truncated"], "properties": { "kind": { "const": "diff" }, "displayPath": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$", "maxLength": 4096 }, "diff": { "type": "string", "maxLength": 2000000 }, "truncated": { "type": "boolean" } }, "additionalProperties": false }, + { "type": "object", "required": ["kind", "current", "branches"], "properties": { "kind": { "const": "branches" }, "current": { "type": "string" }, "branches": { "type": "array", "items": { "type": "string" } } }, "additionalProperties": false }, + { "type": "object", "required": ["kind", "comparisonId", "base", "head", "files"], "properties": { "kind": { "const": "comparison" }, "comparisonId": { "type": "string" }, "base": { "type": "string" }, "head": { "type": "string" }, "files": { "type": "array", "maxItems": 4000, "items": { "$ref": "#/components/schemas/GitFileProjection" } } }, "additionalProperties": false }, + { "type": "object", "required": ["kind", "allowed"], "properties": { "kind": { "const": "push-capability" }, "allowed": { "type": "boolean" }, "reason": { "type": "string", "maxLength": 500 }, "remote": { "type": "string", "pattern": "^[A-Za-z0-9._-]{1,200}$" }, "branch": { "type": "string", "maxLength": 500 } }, "additionalProperties": false }, + { "type": "object", "required": ["kind", "worktrees"], "properties": { "kind": { "const": "worktrees" }, "worktrees": { "type": "array", "items": { "type": "object", "required": ["id", "name", "branch", "managed"], "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "branch": { "type": "string" }, "managed": { "type": "boolean" } }, "additionalProperties": false } } }, "additionalProperties": false }, + { "type": "object", "required": ["kind", "message"], "properties": { "kind": { "const": "mutation" }, "message": { "type": "string" }, "branch": { "type": "string" }, "commitId": { "type": "string" }, "workspaceId": { "type": "string" }, "warning": { "type": "string" } }, "additionalProperties": false } + ] }, + "GitResult": { "type": "object", "required": ["operationId", "status"], "properties": { "operationId": { "type": "string" }, "status": { "enum": ["snapshot", "accepted", "running", "succeeded", "failed", "conflict"] }, "snapshotId": { "type": "string" }, "capability": { "type": "object", "properties": { "allowed": { "type": "boolean" }, "reason": { "type": "string" } }, "required": ["allowed"], "additionalProperties": false }, "result": { "$ref": "#/components/schemas/GitProjection" } }, "additionalProperties": false }, + "ScheduledTask": { "type": "object", "required": ["id", "revision", "name", "enabled", "schedule", "timezone", "mode", "permission", "notify", "running", "createdAt", "updatedAt"], "properties": { "id": { "type": "string" }, "revision": { "type": "string" }, "name": { "type": "string" }, "enabled": { "type": "boolean" }, "schedule": { "type": "string" }, "timezone": { "type": "string" }, "mode": { "enum": ["llm", "script"] }, "permission": { "enum": ["full", "read-only"] }, "workspaceId": { "type": "string" }, "providerId": { "type": "string" }, "modelId": { "type": "string" }, "mcpServerIds": { "type": "array", "items": { "type": "string" } }, "scriptId": { "type": "string" }, "prompt": { "type": "string", "maxLength": 32768 }, "notify": { "type": "boolean" }, "running": { "type": "boolean" }, "nextRunAt": { "type": "string", "format": "date-time" }, "lastRunAt": { "type": "string", "format": "date-time" }, "lastResult": { "enum": ["success", "error", "silent", "blocked"] }, "createdAt": { "type": "string", "format": "date-time" }, "updatedAt": { "type": "string", "format": "date-time" } }, "additionalProperties": false }, + "ScheduledTaskMutation": { "type": "object", "required": ["name", "schedule", "timezone", "mode", "permission", "confirmedForeground"], "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 120 }, "schedule": { "type": "string" }, "timezone": { "type": "string" }, "mode": { "enum": ["llm", "script"] }, "permission": { "enum": ["full", "read-only"] }, "workspaceId": { "type": "string" }, "providerId": { "type": "string" }, "modelId": { "type": "string" }, "mcpServerIds": { "type": "array", "items": { "type": "string" } }, "scriptId": { "type": "string" }, "prompt": { "type": "string" }, "notify": { "type": "boolean" }, "confirmedForeground": { "const": true } }, "additionalProperties": false }, + "ScheduleRunAccepted": { "type": "object", "required": ["taskId", "runId", "status", "acceptedAt"], "properties": { "taskId": { "type": "string" }, "runId": { "type": "string" }, "status": { "enum": ["accepted", "running"] }, "acceptedAt": { "type": "string", "format": "date-time" } }, "additionalProperties": false }, + "ScheduleRun": { "type": "object", "required": ["id", "taskId", "status", "startedAt"], "properties": { "id": { "type": "string" }, "taskId": { "type": "string" }, "status": { "enum": ["accepted", "running", "succeeded", "failed", "cancelled"] }, "startedAt": { "type": "string", "format": "date-time" }, "finishedAt": { "type": "string", "format": "date-time" }, "summary": { "type": "string", "maxLength": 20000 }, "errorCode": { "type": "string" } }, "additionalProperties": false }, + "ScheduleSettings": { "type": "object", "required": ["revision", "enabled", "defaultMode", "defaultPermission", "defaultMcpEnabled", "defaultNotify", "defaultTimezone"], "properties": { "revision": { "type": "string" }, "enabled": { "type": "boolean" }, "defaultMode": { "enum": ["llm", "script"] }, "defaultPermission": { "enum": ["full", "read-only"] }, "defaultMcpEnabled": { "type": "boolean" }, "defaultNotify": { "type": "boolean" }, "defaultTimezone": { "type": "string" } }, "additionalProperties": false }, + "ScheduleSettingsMutation": { "type": "object", "required": ["confirmedForeground"], "minProperties": 2, "properties": { "enabled": { "type": "boolean" }, "defaultMode": { "enum": ["llm", "script"] }, "defaultPermission": { "enum": ["full", "read-only"] }, "defaultMcpEnabled": { "type": "boolean" }, "defaultNotify": { "type": "boolean" }, "defaultTimezone": { "type": "string", "maxLength": 120 }, "confirmedForeground": { "const": true } }, "additionalProperties": false }, + "ErrorCode": { "enum": ["invalid_request", "payload_too_large", "rate_limited", "authentication_required", "credential_revoked", "capability_denied", "pairing_closed", "pairing_expired", "pairing_already_used", "server_identity_changed", "not_found", "already_exists", "revision_conflict", "idempotency_conflict", "idempotency_capacity", "idempotency_in_flight", "workspace_unavailable", "workspace_changing", "permission_confirmation_required", "handle_invalid", "handle_expired", "handle_wrong_device", "root_policy_changed", "filesystem_identity_changed", "path_outside_root", "handle_capacity", "turn_already_active", "stream_gone", "approval_already_resolved", "approval_expired", "operation_in_progress", "operation_stale", "git_capability_denied", "schedule_disabled", "schedule_run_in_progress", "server_interrupted", "internal_error"] }, + "ErrorDetails": { "type": "object", "properties": { "currentRevision": { "type": "string", "maxLength": 128 }, "retryAfterSeconds": { "type": "integer", "minimum": 0, "maximum": 86400 }, "chatId": { "type": "string", "maxLength": 128 }, "minimumClientVersion": { "type": "string", "maxLength": 40 }, "limit": { "type": "integer", "minimum": 0, "maximum": 1000000 }, "field": { "type": "string", "maxLength": 120 } }, "additionalProperties": false }, + "ErrorEnvelope": { "type": "object", "required": ["error"], "properties": { "error": { "type": "object", "required": ["code", "message", "requestId", "retryable"], "properties": { "code": { "$ref": "#/components/schemas/ErrorCode" }, "message": { "type": "string", "maxLength": 2000 }, "requestId": { "type": "string", "maxLength": 128 }, "retryable": { "type": "boolean" }, "details": { "$ref": "#/components/schemas/ErrorDetails" } }, "additionalProperties": false } }, "additionalProperties": false } + } + } +} diff --git a/renderer/assets/onboarding/features/aiden-on-the-go.png b/renderer/assets/onboarding/features/aiden-on-the-go.png new file mode 100644 index 00000000..94a77e35 Binary files /dev/null and b/renderer/assets/onboarding/features/aiden-on-the-go.png differ diff --git a/renderer/assets/provider-logos/concentrate.svg b/renderer/assets/provider-logos/concentrate.svg new file mode 100644 index 00000000..0394c087 --- /dev/null +++ b/renderer/assets/provider-logos/concentrate.svg @@ -0,0 +1,16 @@ + + + Concentrate + An eight-by-eight field of dots that grow toward the upper right. + + + + + + + + + + + + diff --git a/renderer/components/assistant/use-assistant-chat.ts b/renderer/components/assistant/use-assistant-chat.ts index 53edee8c..d77980b9 100644 --- a/renderer/components/assistant/use-assistant-chat.ts +++ b/renderer/components/assistant/use-assistant-chat.ts @@ -14,10 +14,11 @@ import { type StreamCallbacks, } from "../../lib/ipc"; import type { Chat, ChatMeta } from "../../lib/types"; -import { useProviders } from "../../lib/queries"; +import { useProviders, useSettings } from "../../lib/queries"; import { isModelSelectionAvailable, readModelSelection, + resolveVisibleModelSelection, subscribeModelSelection, } from "../../lib/use-model-selection"; import { STREAMING_REVEAL_FALLBACK_MS } from "../../lib/streaming-reveal"; @@ -240,7 +241,9 @@ export function useAssistantChat(): AssistantChat { // seeded once at mount, so the dock would never see the composer switch // models. Read storage at the point of use instead. const providers = useProviders(); + const settings = useSettings(); const [selection, setSelection] = React.useState(readModelSelection); + const [activeChatId, setActiveChatId] = React.useState(null); const [conversationLoading, setConversationLoading] = React.useState(false); const conversationLoadingRef = React.useRef(false); const [turnSaving, setTurnSaving] = React.useState(false); @@ -250,7 +253,16 @@ export function useAssistantChat(): AssistantChat { useAppendReconciliationRequired(); const reloadRequired = appendReconciliationRequired || documentAppendReconciliationRequired; - const modelReady = isModelSelectionAvailable(selection, providers.data); + const newWorkSelection = resolveVisibleModelSelection( + selection, + providers.data, + settings.data?.hiddenModelsByProvider, + ); + const effectiveSelection = activeChatId ? selection : newWorkSelection; + const modelReady = Boolean( + effectiveSelection && + isModelSelectionAvailable(effectiveSelection, providers.data), + ); const ready = modelReady && !conversationLoading && @@ -283,7 +295,6 @@ export function useAssistantChat(): AssistantChat { turnSaving, }) && !reloadRequired; const [threads, setThreads] = React.useState([]); - const [activeChatId, setActiveChatId] = React.useState(null); const handleRef = React.useRef(null); // Every async continuation below checks this before touching state. Without // it, a cancelled turn's late chat:done clears `streaming` for the turn that @@ -440,8 +451,16 @@ export function useAssistantChat(): AssistantChat { (text: string, restoreDraft?: (text: string) => void) => { // Read the selection fresh: the composer may have switched models since // the last focus sync. - const current = readModelSelection(); + const stored = readModelSelection(); + const current = activeChatId + ? stored + : resolveVisibleModelSelection( + stored, + providers.data, + settings.data?.hiddenModelsByProvider, + ); if ( + !current || conversationLoadingRef.current || stoppedPersistingTurnRef.current === turnRef.current || !canSendAssistantMessage(text, { @@ -727,6 +746,7 @@ export function useAssistantChat(): AssistantChat { reloadRequired, fail, providers.data, + settings.data?.hiddenModelsByProvider, refreshThreads, streamComplete, streaming, diff --git a/renderer/components/chat-sidebar.test.tsx b/renderer/components/chat-sidebar.test.tsx index 33d437e7..18a45f90 100644 --- a/renderer/components/chat-sidebar.test.tsx +++ b/renderer/components/chat-sidebar.test.tsx @@ -61,6 +61,19 @@ test("downloaded updates appear immediately above Profile in the sidebar footer" assert.ok(profileIndex < settingsIndex, "Profile and Settings should keep their stable order"); }); +test("sidebar keeps a compact mobile connection surface beside Settings", () => { + const sidebar = source("./chat-sidebar.tsx"); + const connectionPopover = source("./remote-connection-popover.tsx"); + + assert.match(sidebar, /Previous { const sidebar = source("./chat-sidebar.tsx"); const banner = between( diff --git a/renderer/components/chat-sidebar.tsx b/renderer/components/chat-sidebar.tsx index 2995ac9e..f71aeb92 100644 --- a/renderer/components/chat-sidebar.tsx +++ b/renderer/components/chat-sidebar.tsx @@ -67,6 +67,7 @@ import { removeDeletedChatFromCache } from "../lib/chat-deletion-cache"; import { useAppUpdateSnapshot } from "../lib/use-app-update-snapshot"; import type { AppUpdateRestartResult, AppUpdateSnapshot } from "../shared/app-update"; import { useActiveChatIds } from "../lib/use-chat-activity"; +import { RemoteConnectionPopover } from "./remote-connection-popover"; const AIDEN_MARK_URL = new URL("../../resources/app-icon.png", import.meta.url).href; /** Must match aiden-app-update-banner-out in styles.css. */ @@ -456,6 +457,13 @@ export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { }, [navigate, settingsBlockedReason], ); + const openRemoteSettings = React.useCallback(() => { + if (settingsBlockedReason) { + toast.info(settingsBlockedReason); + return; + } + void navigate({ to: "/settings", search: { section: "remoteAccess" } }); + }, [navigate, settingsBlockedReason]); React.useEffect(() => { const clearRevealTimer = () => { @@ -769,13 +777,20 @@ export function ChatSidebar({ activeChatId, titleReveal }: ChatSidebarProps) { disabled={Boolean(settingsBlockedReason)} onClick={() => navigate({ to: "/profile" })} /> - } - title="Settings" - selected={pathname === "/settings"} - disabled={Boolean(settingsBlockedReason)} - onClick={() => navigate({ to: "/settings" })} - /> +
+ } + title="Settings" + selected={pathname === "/settings"} + disabled={Boolean(settingsBlockedReason)} + className="min-w-0 flex-1" + onClick={() => navigate({ to: "/settings" })} + /> + +
} diff --git a/renderer/components/command-palette.tsx b/renderer/components/command-palette.tsx index 115b11e9..b7802477 100644 --- a/renderer/components/command-palette.tsx +++ b/renderer/components/command-palette.tsx @@ -49,7 +49,7 @@ import { readModelSelectionRevision, useModelSelection, } from "../lib/use-model-selection"; -import { createModelEntries, isUsable } from "../lib/model-picker-data"; +import { createModelEntries, isUsable, visibleModelEntries } from "../lib/model-picker-data"; import { SETTINGS_DESTINATIONS } from "../lib/settings-section"; import { createDefaultAppearanceConfig, @@ -112,7 +112,11 @@ export function AppCommandPalette({ const chats = useChats(activeId); const providers = useProviders(); const settings = useSettings(); - const selection = useModelSelection(providers.data); + const selection = useModelSelection( + providers.data, + settings.data?.hiddenModelsByProvider, + settings.data !== undefined, + ); const { binding, canExecute, execute, palette } = useCommandSystem(); const [query, setQuery] = React.useState(""); const [busy, setBusy] = React.useState(false); @@ -127,8 +131,12 @@ export function AppCommandPalette({ } }); const models = React.useMemo( - () => createModelEntries(providers.data ?? []), - [providers.data], + () => + settings.data ? visibleModelEntries( + createModelEntries(providers.data ?? []), + settings.data?.hiddenModelsByProvider, + ) : [], + [providers.data, settings.data?.hiddenModelsByProvider], ); const unavailableModelProviders = React.useMemo( () => (providers.data ?? []).filter((provider) => !isUsable(provider)), @@ -231,9 +239,15 @@ export function AppCommandPalette({ if (busy) return; setBusy(true); try { - const next = await providersApi.refresh(); - queryClient.setQueryData(queryKeys.providers, next); - toast.success("Provider model catalogs refreshed"); + const result = await providersApi.refresh(); + queryClient.setQueryData(queryKeys.providers, result.providers); + if (result.errors.length > 0) { + toast.warning( + `${result.providers.length > 0 ? "Available catalogs refreshed; " : ""}${result.errors.length} provider catalog${result.errors.length === 1 ? "" : "s"} kept cached models.`, + ); + } else { + toast.success("Provider model catalogs refreshed"); + } } catch (error) { toast.error(error instanceof Error ? error.message : "Providers could not be refreshed."); } finally { diff --git a/renderer/components/message-bubble.test.tsx b/renderer/components/message-bubble.test.tsx index ad32380f..d1fd5fe6 100644 --- a/renderer/components/message-bubble.test.tsx +++ b/renderer/components/message-bubble.test.tsx @@ -24,6 +24,56 @@ test("legacy messages render unchanged without provenance", () => { assert.doesNotMatch(markup, /skill/u); }); +test("assistant image attachments render as an accessible full-screen gallery trigger", () => { + const markup = renderToStaticMarkup( + { + const markup = renderToStaticMarkup( + { const content = "Before.\n\nBetween.\n\nAfter."; const tool = ( diff --git a/renderer/components/message-bubble.tsx b/renderer/components/message-bubble.tsx index 1454dda0..029a94a0 100644 --- a/renderer/components/message-bubble.tsx +++ b/renderer/components/message-bubble.tsx @@ -1,8 +1,10 @@ // A single chat message. User messages are right-aligned bubbles; assistant // messages render markdown full-width, native-transcript style. +import * as React from "react"; +import { Dialog as DialogPrimitive } from "radix-ui"; import { Callout, ErrorBoundary, Text } from "./ui"; -import { FileText, Sparkles } from "lucide-react"; +import { ChevronLeft, ChevronRight, FileText, Sparkles, X } from "lucide-react"; import { Markdown } from "./markdown"; import { StreamingMarkdownReveal } from "./streaming-markdown-reveal"; import { CopyButton } from "./copy-button"; @@ -23,6 +25,102 @@ export interface MessageBubbleProps { copyText?: string; } +function MessageAttachments({ attachments }: { attachments: Attachment[] }) { + const images = attachments.filter((attachment) => attachment.kind === "image" && attachment.data); + const files = attachments.filter((attachment) => attachment.kind !== "image" || !attachment.data); + const [selectedIndex, setSelectedIndex] = React.useState(null); + const selected = selectedIndex === null ? undefined : images[selectedIndex]; + + return ( + <> + {images.length > 0 ? ( +
+ {images.map((attachment, index) => ( + + ))} +
+ ) : null} + {files.length > 0 ? ( +
+ {files.map((attachment) => ( +
+ + {attachment.name} +
+ ))} +
+ ) : null} + !open && setSelectedIndex(null)} + > + + + + + {selected?.name ?? "Image preview"} + + + Full-screen image attachment preview + + {selected ? ( + {selected.name} + ) : null} + + + + {images.length > 1 && selectedIndex !== null ? ( + <> + + + + ) : null} + + + + + ); +} + /** Isolate untrusted model-formatting failures to the individual message. */ export function SafeMessageBubble(props: MessageBubbleProps) { return ( @@ -60,28 +158,7 @@ export function MessageBubble({ {skill.source} ) : null} - {attachments && attachments.length > 0 ? ( -
- {attachments.map((a) => - a.kind === "image" && a.data ? ( - {a.name} - ) : ( -
- - {a.name} -
- ), - )} -
- ) : null} + {attachments && attachments.length > 0 ? : null} {content ? (
{content} @@ -111,6 +188,11 @@ export function MessageBubble({ ) : ( )} + {attachments && attachments.length > 0 ? ( +
+ +
+ ) : null} {content && showCopy ? (
) : null} - {content ? ( + {content || (attachments?.length ?? 0) > 0 ? ( ); })} + {lastTextIndex < 0 && (attachments?.length ?? 0) > 0 ? ( + + ) : null} ); } @@ -243,6 +250,7 @@ export function MessageList({ <> void; disabled?: boolean; settingsBlockedReason?: string; + hiddenModelsByProvider?: HiddenModelsByProvider; } function formatTokens(value: number | undefined): string | null { @@ -244,6 +247,7 @@ function ModelHoverDetails({ providerId={model.providerId} providerLabel={model.providerLabel} modelId={model.model} + artwork={model.providerArtwork} className="size-4" /> @@ -317,6 +321,7 @@ export function ModelPicker({ onChange, disabled, settingsBlockedReason, + hiddenModelsByProvider, }: ModelPickerProps) { const navigate = useNavigate(); const [open, setOpen] = React.useState(false); @@ -343,7 +348,8 @@ export function ModelPicker({ } }); - const entries = createModelEntries(providers, infoByValue); + const allEntries = createModelEntries(providers, infoByValue); + const entries = visibleModelEntries(allEntries, hiddenModelsByProvider); const orderedEntries = orderModelEntries(entries, pinned); const detailPositions = positionModels(entries); const positioned = positionSavedModels(entries, modelPadLayout.placements); @@ -352,7 +358,7 @@ export function ModelPicker({ entries.some((entry) => entry.info?.metadataSource === "artificial-analysis") || positioned.some((entry) => entry.confidence === "suggested"); const selectedValue = providerId && model ? encodeSelection(providerId, model) : ""; - const selected = entries.find((entry) => entry.value === selectedValue); + const selected = allEntries.find((entry) => entry.value === selectedValue); const selectedPosition = positioned.find((entry) => entry.value === selectedValue); const detailPosition = detailPositions.find((entry) => entry.value === previewValue); const activePosition = @@ -474,6 +480,7 @@ export function ModelPicker({ providerId={selected.providerId} providerLabel={selected.providerLabel} modelId={selected.model} + artwork={selected.providerArtwork} className="size-4 text-tertiary" /> ) : null} @@ -624,6 +631,7 @@ export function ModelPicker({ providerId={entry.providerId} providerLabel={entry.providerLabel} modelId={entry.model} + artwork={entry.providerArtwork} className="size-4 text-tertiary" /> diff --git a/renderer/components/onboarding-flow.test.tsx b/renderer/components/onboarding-flow.test.tsx index 16b80f11..aaa73e39 100644 --- a/renderer/components/onboarding-flow.test.tsx +++ b/renderer/components/onboarding-flow.test.tsx @@ -31,6 +31,7 @@ const featureAssetPaths = [ "features/themes-accessibility.png", "features/thinking-controls.png", "features/telegram-remote-control.png", + "features/aiden-on-the-go.png", "features/usage-profile.png", "features/voice-dictation.png", "features/web-search.png", @@ -180,11 +181,11 @@ test("onboarding keeps navigation fixed while its content scrolls", () => { assert.match(source, /ref=\{scrollContainerRef\}[\s\S]*?data-onboarding-scroll/u); assert.match( source, - /scrollContainerRef\.current\?\.scrollTo\(\{ top: 0, behavior: "auto" \}\);[\s\S]*?\}, \[index\]\);/u, + /scrollContainerRef\.current\?\.scrollTo\(\{ top: 0, behavior: "auto" \}\);[\s\S]*?\}, \[index, open\]\);/u, ); }); -test("provider setup progressively reveals the complete live Pi catalog", () => { +test("provider setup progressively reveals Pi and uses the dedicated Codex surface", () => { assert.match(source, />\s*Choose from more\s* assert.match(source, /providers\.isError/u); assert.match(source, /providers\.refetch\(\)/u); assert.match(source, /disabled=\{!canChoose \|\| saving\}/u); + assert.match(source, /generic API-key login proves only that a credential was entered/u); + assert.match(source, /function canChooseBuiltinProvider\(_provider: Provider\): boolean \{[\s\S]*?return false;/u); assert.match(source, / { +test("onboarding is an application modal and required setup cannot be skipped", () => { assert.match(source, //u); - assert.match(source, / event\.preventDefault\(\)\}/u); assert.match(source, /Set up Aiden/u); assert.match(source, /if \(!canContinue \|\| savingRef\.current\) return/u); assert.match(source, /aria-busy=\{saving \|\| undefined\}/u); - assert.match(source, /variant="transparent"[\s\S]*?disabled=\{saving\}[\s\S]*?>\s*Skip/u); - assert.ok((source.match(/disabled=\{saving\}/gu) ?? []).length >= 6); + assert.match(source, /Profile and provider setup required/u); + assert.match(source, /aria-current=\{itemIndex === index \? "step" : undefined\}/u); + assert.doesNotMatch(source, />\s*Skip\s*\s*Set up later\s*= 5); +}); + +test("hosted keys validate before selection and endpoint routes require discovered models", () => { + const providerStep = source.slice( + source.indexOf('if (step === "provider")'), + source.indexOf(" return (", source.indexOf('if (step === "provider")')), + ); + const validate = providerStep.indexOf("providersApi.validateOnboardingApiKey"); + const publish = providerStep.indexOf("queryClient.setQueryData", validate); + const select = providerStep.indexOf("persistModelSelection(saved.id", validate); + assert.ok(validate >= 0 && validate < publish && publish < select); + assert.match( + providerStep, + /needsEndpointDiscovery = isLocalRuntime \|\| choice === "tailscale"/u, + ); + assert.match(providerStep, /if \(!defaultModel\)[\s\S]*?no chat models were found/u); + assert.match( + source, + /choice === "openai-key" \|\| choice === "anthropic"[\s\S]*?Validating key…/u, + ); }); test("onboarding presentation stays compact and free of decorative gradients", () => { @@ -220,7 +253,7 @@ test("onboarding presentation stays compact and free of decorative gradients", ( ); }); -test("the final step is a complete grouped bento gallery with hover and keyboard descriptions", () => { +test("the final step is a complete grouped bento gallery with hover descriptions", () => { assert.match(source, /data-onboarding-bento/u); assert.match(source, /data-onboarding-feature-count=\{featureBentos\.length\}/u); assert.match(source, /auto-rows-\[118px\][\s\S]*?grid-cols-6/u); @@ -235,7 +268,8 @@ test("the final step is a complete grouped bento gallery with hover and keyboard source, /Create reusable instructions, then type \$ to attach one to your next message\./u, ); - assert.match(source, /tabIndex=\{0\}/u); + assert.doesNotMatch(source, / { - assert.equal(featureAssetPaths.length, 23); + assert.equal(featureAssetPaths.length, 24); assert.ok(featureAssetPaths.includes("features/telegram-remote-control.png")); + assert.ok(featureAssetPaths.includes("features/aiden-on-the-go.png")); assert.equal(new Set(featureAssetPaths).size, featureAssetPaths.length); for (const assetPath of featureAssetPaths) { const illustration = readFileSync( diff --git a/renderer/components/onboarding-flow.tsx b/renderer/components/onboarding-flow.tsx index fa592ecf..3d3683e7 100644 --- a/renderer/components/onboarding-flow.tsx +++ b/renderer/components/onboarding-flow.tsx @@ -17,6 +17,7 @@ import { GitBranch, Globe2, Lock, + LoaderCircle, MessageSquare, Mic2, MousePointer2, @@ -26,6 +27,7 @@ import { Send, ShieldCheck, SquareTerminal, + Smartphone, UserRound, UsersRound, Wand2, @@ -36,9 +38,14 @@ import * as React from "react"; import { Dialog as DialogPrimitive } from "radix-ui"; import { ProviderIcon } from "./provider-icon"; import { BuiltinProviderEditor } from "./settings/builtin-provider-editor"; +import { CodexProviderSettings } from "./settings/codex-provider-settings"; import { Button, Input, Text, toast } from "./ui"; -import { providersApi, profileApi } from "../lib/ipc"; -import { markOnboardingComplete, shouldShowOnboarding } from "../lib/onboarding-state"; +import { appApi, providersApi, profileApi } from "../lib/ipc"; +import { + clearLegacyOnboardingCompletion, + markOnboardingComplete, + shouldShowOnboarding, +} from "../lib/onboarding-state"; import { discoveredDefaultModel, fieldsAfterProviderChoiceChange, @@ -46,9 +53,10 @@ import { type OnboardingProviderChoice, } from "../lib/onboarding-provider"; import { getOnboardingMoreProviders } from "../lib/pi-provider-display"; -import { queryKeys, useProviders } from "../lib/queries"; +import { queryKeys, useCodexProviderStatus, useProviders } from "../lib/queries"; import { persistModelSelection } from "../lib/use-model-selection"; import type { Provider } from "../lib/types"; +import { onboardingStepIndex, type OnboardingSnapshot } from "../shared/onboarding"; type Step = "profile" | "provider" | "tour"; const steps: Step[] = ["profile", "provider", "tour"]; @@ -87,6 +95,7 @@ const FEATURE_ILLUSTRATIONS = { themes: new URL("../assets/onboarding/features/themes-accessibility.png", import.meta.url).href, telegram: new URL("../assets/onboarding/features/telegram-remote-control.png", import.meta.url) .href, + aidenOnTheGo: new URL("../assets/onboarding/features/aiden-on-the-go.png", import.meta.url).href, } as const; const providerChoices: Array<{ @@ -332,11 +341,21 @@ const featureBentos: FeatureBento[] = [ id: "telegram", group: "control", title: "Aiden in Telegram", - description: "Use models, skills, files, voice, queues, and trusted workspace automation from your paired account.", + description: + "Use models, skills, files, voice, queues, and trusted workspace automation from your paired account.", icon: Send, imageUrl: FEATURE_ILLUSTRATIONS.telegram, size: "standard", }, + { + id: "aidenOnTheGo", + group: "control", + title: "Aiden On The Go", + description: "Pair your iPhone or iPad over pinned local HTTPS or a private Tailscale route.", + icon: Smartphone, + imageUrl: FEATURE_ILLUSTRATIONS.aidenOnTheGo, + size: "standard", + }, { id: "usage", group: "control", @@ -398,6 +417,8 @@ function OnboardingDialogShell({ children }: React.PropsWithChildren) { event.preventDefault()} onPointerDownOutside={(event) => event.preventDefault()} className="fixed inset-0 z-60 grid place-items-center bg-background p-4 outline-none max-[760px]:p-0" @@ -414,22 +435,27 @@ function OnboardingDialogShell({ children }: React.PropsWithChildren) { } function builtinProviderSetupLabel(provider: Provider): string { - if (provider.hasKey) return "Ready on this Mac"; - const methods = (provider.authMethods ?? []) - .filter((method) => method.canLogin) - .map((method) => method.label); - if (methods.length > 0) return methods.slice(0, 2).join(" or "); - return "Requires system credentials"; + return provider.hasKey + ? "Configured; verify in Settings after onboarding" + : "Available in Settings after onboarding"; } -function canChooseBuiltinProvider(provider: Provider): boolean { - return provider.hasKey || (provider.authMethods ?? []).some((method) => method.canLogin); +function canChooseBuiltinProvider(_provider: Provider): boolean { + // Pi's generic API-key login proves only that a credential was entered; it + // does not contact the provider. Until a provider-specific non-generation + // validator exists, it cannot satisfy required first-run setup. + return false; } export function OnboardingFlow() { const queryClient = useQueryClient(); const providers = useProviders(); - const [open, setOpen] = React.useState(() => shouldShowOnboarding()); + const codexStatus = useCodexProviderStatus(); + // Main-owned state is authoritative. Block the workbench until it has been + // checked so a stale legacy renderer marker cannot expose a bypass window. + const [open, setOpen] = React.useState(true); + const [stateReady, setStateReady] = React.useState(false); + const [onboardingLoadError, setOnboardingLoadError] = React.useState(null); const [index, setIndex] = React.useState(0); const [name, setName] = React.useState(""); const [choice, setChoice] = React.useState("openai-signin"); @@ -441,24 +467,79 @@ export function OnboardingFlow() { const [saving, setSaving] = React.useState(false); const [discovering, setDiscovering] = React.useState(false); const [providerError, setProviderError] = React.useState(null); + const onboardingSnapshotRef = React.useRef(null); + const readyProviderIdRef = React.useRef(null); const savingRef = React.useRef(false); const scrollContainerRef = React.useRef(null); + const profileInitializedRef = React.useRef(false); + const loadGenerationRef = React.useRef(0); + + const loadOnboarding = React.useCallback(async (reopen = false) => { + const generation = loadGenerationRef.current + 1; + loadGenerationRef.current = generation; + setStateReady(false); + setOnboardingLoadError(null); + try { + const snapshot = reopen + ? await appApi.setOnboardingOutcome("incomplete") + : await appApi.getOnboardingState(!shouldShowOnboarding()); + if (loadGenerationRef.current !== generation) return; + onboardingSnapshotRef.current = snapshot; + readyProviderIdRef.current = snapshot.selectedProviderId ?? null; + setIndex(onboardingStepIndex(snapshot)); + setOpen(snapshot.outcome !== "completed"); + if (snapshot.profileReady && !profileInitializedRef.current) { + const current = await profileApi.get(); + profileInitializedRef.current = true; + setName(current.name); + queryClient.setQueryData(queryKeys.profile, current); + } + setStateReady(true); + if (reopen) clearLegacyOnboardingCompletion(); + } catch (error) { + if (loadGenerationRef.current !== generation) return; + setOnboardingLoadError( + error instanceof Error ? error.message : "Aiden couldn't load onboarding progress.", + ); + setOpen(true); + } + }, [queryClient]); + + React.useEffect(() => { + void loadOnboarding(); + const reopen = () => void loadOnboarding(true); + window.addEventListener("aiden:show-onboarding", reopen); + return () => window.removeEventListener("aiden:show-onboarding", reopen); + }, [loadOnboarding]); React.useEffect(() => { scrollContainerRef.current?.scrollTo({ top: 0, behavior: "auto" }); - }, [index]); + const frame = requestAnimationFrame(() => { + scrollContainerRef.current?.querySelector("h2")?.focus(); + }); + return () => cancelAnimationFrame(frame); + }, [index, open]); if (!open) return null; const step = steps[index]; const selected = providerChoices.find((item) => item.id === choice); const moreProviders = getOnboardingMoreProviders(providers.data ?? []); - const chatGptProvider = (providers.data ?? []).find( - (provider) => provider.id === "openai-codex" && provider.isBuiltin === true, - ); const selectedBuiltinProvider = moreProviders.find((provider) => provider.id === builtinChoiceId); const hasProviderChoice = Boolean(selected || selectedBuiltinProvider); + const codexReady = + codexStatus.data?.configured === true && + codexStatus.data.needsAttention === false && + codexStatus.data.models.length > 0; const canContinue = - step === "profile" ? name.trim().length > 0 : step === "provider" ? hasProviderChoice : true; + !stateReady + ? false + : step === "profile" + ? name.trim().length > 0 + : step === "provider" + ? choice === "openai-signin" + ? codexReady + : hasProviderChoice + : true; const selectProviderChoice = (nextChoice: OnboardingProviderChoice | null) => { const nextFields = fieldsAfterProviderChoiceChange(choice, nextChoice, { apiKey, baseUrl }); @@ -468,6 +549,13 @@ export function OnboardingFlow() { setProviderError(null); }; + const completeProviderStep = async (providerId: string) => { + const snapshot = await appApi.setOnboardingProgress("provider", providerId); + onboardingSnapshotRef.current = snapshot; + readyProviderIdRef.current = providerId; + setIndex(2); + }; + const next = async () => { if (!canContinue || savingRef.current) return; if (step === "profile") { @@ -475,7 +563,10 @@ export function OnboardingFlow() { setSaving(true); try { const saved = await profileApi.setName(name); + profileInitializedRef.current = true; queryClient.setQueryData(queryKeys.profile, saved); + const snapshot = await appApi.setOnboardingProgress("profile"); + onboardingSnapshotRef.current = snapshot; setIndex(1); } catch (error) { toast.error(error instanceof Error ? error.message : "Couldn't save your profile name."); @@ -487,8 +578,10 @@ export function OnboardingFlow() { } if (step === "provider") { if (selectedBuiltinProvider) { - if (selectedBuiltinProvider.hasKey) { - setIndex(2); + if (selectedBuiltinProvider.hasKey && selectedBuiltinProvider.models.length > 0) { + const model = selectedBuiltinProvider.defaultModel ?? selectedBuiltinProvider.models[0]; + persistModelSelection(selectedBuiltinProvider.id, model); + await completeProviderStep(selectedBuiltinProvider.id); } else { setSettingUpProvider(selectedBuiltinProvider); } @@ -499,18 +592,13 @@ export function OnboardingFlow() { return; } if (choice === "openai-signin") { - if (providers.isLoading) { - toast.info("Aiden is still loading the ChatGPT sign-in option. Try again in a moment."); - return; - } - if (!chatGptProvider) { - toast.error( - "ChatGPT sign-in is unavailable. Refresh the provider catalog or choose another option.", - ); + const model = codexStatus.data?.models[0]?.id; + if (!codexReady || !model) { + setProviderError("Complete ChatGPT sign-in before continuing."); return; } - if (chatGptProvider.hasKey) setIndex(2); - else setSettingUpProvider(chatGptProvider); + persistModelSelection("openai-codex", model); + await completeProviderStep("openai-codex"); return; } if (choice === "tailscale" && !baseUrl.trim()) { @@ -525,7 +613,26 @@ export function OnboardingFlow() { setSaving(true); setProviderError(null); try { + if (choice === "openai-key" || choice === "anthropic") { + setDiscovering(true); + const providerId = choice === "openai-key" ? "openai" : "anthropic"; + const validation = await providersApi.validateOnboardingApiKey(providerId, apiKey.trim()); + const saved = validation.provider; + queryClient.setQueryData(queryKeys.providers, (current) => { + const without = (current ?? []).filter((item) => item.id !== saved.id); + return [...without, saved]; + }); + const model = saved.defaultModel ?? saved.models[0]; + if (!model) + throw new Error("Credentials were accepted, but no chat models are available."); + persistModelSelection(saved.id, model); + await completeProviderStep(saved.id); + if (validation.catalogWarning) toast.warning(validation.catalogWarning); + else toast.success(`${saved.label} credentials accepted.`); + return; + } const isLocalRuntime = choice === "lmstudio" || choice === "ollama"; + const needsEndpointDiscovery = isLocalRuntime || choice === "tailscale"; // Resolve reserved local identities from a fresh main-process snapshot. // The query cache may still be loading or stale when the user clicks Next. const currentProviders = isLocalRuntime @@ -535,7 +642,7 @@ export function OnboardingFlow() { queryClient.setQueryData(queryKeys.providers, currentProviders); } let providerToSave = makeOnboardingProvider(choice, baseUrl.trim(), currentProviders); - if (providerToSave && isLocalRuntime) { + if (providerToSave && needsEndpointDiscovery) { let discovery: Awaited>; try { setDiscovering(true); @@ -572,24 +679,37 @@ export function OnboardingFlow() { const without = (current ?? []).filter((item) => item.id !== saved.id); return [...without, saved]; }); - if (isLocalRuntime) { - persistModelSelection(saved.id, saved.defaultModel ?? providerToSave.defaultModel!); - } + persistModelSelection(saved.id, saved.defaultModel ?? providerToSave.defaultModel!); + await completeProviderStep(saved.id); toast.success(`${saved.label} added.`); } - setIndex(2); } catch (error) { const message = error instanceof Error ? error.message : "Couldn't add that provider."; setProviderError(message); toast.error(message); } finally { + setDiscovering(false); savingRef.current = false; setSaving(false); } return; } - markOnboardingComplete(); - setOpen(false); + savingRef.current = true; + setSaving(true); + try { + const snapshot = await appApi.setOnboardingOutcome( + "completed", + readyProviderIdRef.current ?? onboardingSnapshotRef.current?.selectedProviderId, + ); + onboardingSnapshotRef.current = snapshot; + markOnboardingComplete(); + setOpen(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Aiden couldn't finish onboarding."); + } finally { + savingRef.current = false; + setSaving(false); + } }; return ( @@ -621,6 +741,7 @@ export function OnboardingFlow() { {steps.map((item, itemIndex) => (
  • - + + Profile and provider setup required +
    - {step === "profile" ? ( + {onboardingLoadError ? ( +
    + + {onboardingLoadError} + + +
    + ) : null} + {!stateReady && !onboardingLoadError ? ( +
    +
    +
    +
    + ) : null} + {stateReady && step === "profile" ? (
    - + What should Aiden call you? @@ -706,12 +848,17 @@ export function OnboardingFlow() {
    ) : null} - {step === "provider" ? ( + {stateReady && step === "provider" ? (
    - + Add a model provider @@ -758,6 +905,11 @@ export function OnboardingFlow() { ))}
    + {choice === "openai-signin" ? ( +
    + +
    + ) : null}
    ) : null} - {step === "tour" ? ( + {stateReady && step === "tour" ? (
    - + Everything Aiden brings together Explore all {featureBentos.length} shipped features. Scroll, then hover or focus a tile to learn more. + + Phone and iPad access starts off. After setup, opt in from Settings → Remote + Access; Aiden must stay running, and Tailscale is optional. +
    @@ -1009,14 +1163,20 @@ export function OnboardingFlow() { > - + + +
    +
    +
    + Aiden On The Go + {summary} +
    +
    + {snapshot?.status.error ? ( + + {snapshot.status.error} + + ) : null} +
    + +
    + {settings.isLoading ? ( +

    Checking mobile connections…

    + ) : !snapshot ? ( +

    Connection status is unavailable.

    + ) : snapshot.devices.length === 0 ? ( +

    No devices have been paired with this Mac.

    + ) : ( + <> + + + + {groups.previous.length > 0 ? ( +
    + + Previous + + {groups.previous.length} + + + + {groups.previous.map((device) => ( + + ))} +
    + ) : null} + + )} +
    + +
    + +
    +
    + + ); +} diff --git a/renderer/components/settings/about-settings.tsx b/renderer/components/settings/about-settings.tsx index 288ca949..674fd6cf 100644 --- a/renderer/components/settings/about-settings.tsx +++ b/renderer/components/settings/about-settings.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Download, Github, Loader2, RefreshCw, RotateCcw } from "lucide-react"; +import { Download, Github, Loader2, RefreshCw, RotateCcw, Sparkles } from "lucide-react"; import { AlertDialog, Button, Field, FieldSet, toast } from "../ui"; import { appApi, appUpdatesApi, type AppInfo } from "../../lib/ipc"; import { useAppUpdateSnapshot } from "../../lib/use-app-update-snapshot"; @@ -61,6 +61,7 @@ export function AboutSettings() { const [resetting, setResetting] = React.useState(false); const [resetError, setResetError] = React.useState(null); const [updateActionBusy, setUpdateActionBusy] = React.useState(false); + const [showingOnboarding, setShowingOnboarding] = React.useState(false); const resetButtonRef = React.useRef(null); const updateSnapshot = useAppUpdateSnapshot(); @@ -99,6 +100,19 @@ export function AboutSettings() { } }; + const showOnboarding = async () => { + if (showingOnboarding) return; + setShowingOnboarding(true); + try { + await appApi.setOnboardingOutcome("incomplete"); + window.dispatchEvent(new Event("aiden:show-onboarding")); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Aiden couldn't reopen onboarding."); + } finally { + setShowingOnboarding(false); + } + }; + const checkForUpdates = async () => { if (updateActionBusy || updateSnapshot.status === "checking") return; setUpdateActionBusy(true); @@ -171,6 +185,23 @@ export function AboutSettings() {
    + +
    + +
    +
    (null); + const mountedRef = React.useRef(true); + const openRef = React.useRef(open); const [prompt, setPrompt] = React.useState(null); const [value, setValue] = React.useState(""); const [message, setMessage] = React.useState(null); const [starting, setStarting] = React.useState(false); const [responding, setResponding] = React.useState(false); + const [authLink, setAuthLink] = React.useState(null); const interactiveMethods = (provider.authMethods ?? []).filter( (method): method is { type: PiAuthMethod; label: string; canLogin: true } => method.canLogin, ); @@ -50,9 +57,25 @@ export function BuiltinProviderEditor({ const session = sessionRef.current; sessionRef.current = null; if (!session?.isActive()) return; - void session.cancel().catch(() => session.dispose()); + void session + .cancel() + .then((result) => { + if (result.cancelled) session.dispose(); + }) + .catch(() => session.dispose()); + }, []); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; }, []); + React.useLayoutEffect(() => { + openRef.current = open; + }, [open]); + React.useEffect(() => { if (!open) { releaseSession(); @@ -61,6 +84,7 @@ export function BuiltinProviderEditor({ setMessage(null); setStarting(false); setResponding(false); + setAuthLink(null); } }, [open, provider.id, releaseSession]); @@ -80,40 +104,61 @@ export function BuiltinProviderEditor({ try { const session = createProviderAuthSession(providersApi, provider.id, authType, { onPrompt: (nextPrompt) => { + setAuthLink(null); setPrompt(nextPrompt); setValue(""); setMessage(null); setStarting(false); }, onEvent: (event) => { + if (event.type === "auth_url" || event.type === "browser_open_failed") { + setAuthLink(event.url); + } else if (event.type === "device_code") { + setAuthLink(event.verificationUri); + } setMessage(eventCopy(event)); setStarting(false); }, onDone: async (event) => { sessionRef.current = null; - setPrompt(null); - setStarting(false); if (event.cancelled) return; + if (mountedRef.current && openRef.current) { + setPrompt(null); + setStarting(false); + } try { - const providers = await providersApi.refresh(); - onSaved(); + const providers = await providersApi.list(); const refreshed = providers.find((item) => item.id === provider.id); - toast.success( - refreshed?.hasKey - ? `${provider.label} is ready.` - : `${provider.label} setup completed.`, - ); - onOpenChange(false); + if (!refreshed?.hasKey || refreshed.models.length === 0) { + if (mountedRef.current && openRef.current) { + setMessage( + `${provider.label} is configured, but no usable chat model is available yet.`, + ); + } + return; + } + // A cancellation request can race the provider's irreversible + // credential commit. Reconcile the parent/cache even if this + // editor closed while the session reported `finishing`. + onSaved(); + if (mountedRef.current && openRef.current) { + if (event.warning) toast.warning(event.warning); + else toast.success(`${provider.label} is configured.`); + onOpenChange(false); + } } catch (error) { - setMessage( - error instanceof Error - ? error.message - : "Setup completed, but the model catalog could not refresh.", - ); + if (mountedRef.current && openRef.current) { + setMessage( + error instanceof Error + ? error.message + : "Setup completed, but provider readiness could not be checked.", + ); + } } }, onError: (error) => { sessionRef.current = null; + if (!mountedRef.current || !openRef.current) return; setPrompt(null); setStarting(false); toast.error(error.message); @@ -228,10 +273,18 @@ export function BuiltinProviderEditor({ {message} ) : null} + {authLink ? ( + + ) : null} {provider.models.length} Pi model{provider.models.length === 1 ? "" : "s"} are currently available. +
    ); diff --git a/renderer/components/settings/codex-provider-settings.tsx b/renderer/components/settings/codex-provider-settings.tsx index dffd013d..c46ba531 100644 --- a/renderer/components/settings/codex-provider-settings.tsx +++ b/renderer/components/settings/codex-provider-settings.tsx @@ -2,7 +2,16 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { ExternalLink, LoaderCircle, LogIn, MessageSquareCode, RefreshCw } from "lucide-react"; -import { AlertDialog, Badge, Button, Input, Separator, Text, toast } from "../ui"; +import { + AlertDialog, + Badge, + Button, + Input, + Separator, + Text, + toast, + type DialogLayer, +} from "../ui"; import { CopyButton } from "../copy-button"; import { ProviderIcon } from "../provider-icon"; import { providersApi } from "../../lib/ipc"; @@ -39,7 +48,7 @@ function statusBadge( return Sign in needed; } -export function CodexProviderSettings() { +export function CodexProviderSettings({ layer = "default" }: { layer?: DialogLayer }) { const queryClient = useQueryClient(); const status = useCodexProviderStatus(); const sessionRef = React.useRef(null); @@ -106,13 +115,7 @@ export function CodexProviderSettings() { } }); return () => cancelAnimationFrame(frame); - }, [ - authView?.action?.type, - authView?.action?.type === "auth_url" ? authView.action.url : undefined, - authView?.action?.type === "device_code" ? authView.action.userCode : undefined, - authView?.phase, - authView?.prompt?.promptId, - ]); + }, [authView]); const restoreCardFocus = React.useCallback(() => { requestAnimationFrame(() => cardRef.current?.focus()); @@ -450,7 +453,7 @@ export function CodexProviderSettings() {
    ) : null} - {actionable?.type === "auth_url" ? ( + {actionable?.type === "auth_url" || actionable?.type === "browser_open_failed" ? ( ) : null} @@ -626,6 +631,7 @@ export function CodexProviderSettings() { ) : null} [entry.value, entry])); const placed = entries.filter((entry) => draft.placements[entry.value]); const draftPlacementCount = Object.keys(draft.placements).length; @@ -321,6 +331,7 @@ export function ModelPadSettings() { providerId={entry.providerId} providerLabel={entry.providerLabel} modelId={entry.model} + artwork={entry.providerArtwork} className="size-3.5 text-tertiary" /> diff --git a/renderer/components/settings/provider-editor.tsx b/renderer/components/settings/provider-editor.tsx index e7fc0cce..eef10ebf 100644 --- a/renderer/components/settings/provider-editor.tsx +++ b/renderer/components/settings/provider-editor.tsx @@ -18,7 +18,7 @@ import { toast, } from "../ui"; import { providersApi } from "../../lib/ipc"; -import { useModelInfo } from "../../lib/queries"; +import { useModelInfo, useSettings } from "../../lib/queries"; import { resolveModelDisplay } from "../../lib/model-display"; import type { Provider, @@ -27,6 +27,9 @@ import type { ProviderModelMetadata, } from "../../lib/types"; import { resolveProviderDeployment } from "../../shared/provider-deployment"; +import { ProviderModelVisibility } from "./provider-model-visibility"; +import { ProviderIcon } from "../provider-icon"; +import { isModelHidden } from "../../shared/model-visibility"; /** Compact k-token label, e.g. 128000 → "128K". */ function formatContext(n: number | undefined): string | null { @@ -59,6 +62,8 @@ export function ProviderEditor({ onSaved, returnFocus, }: ProviderEditorProps) { + const artworkInputRef = React.useRef(null); + const artworkBusyRef = React.useRef(false); const [label, setLabel] = React.useState(provider.label); const [baseUrl, setBaseUrl] = React.useState(provider.baseUrl); const [kind, setKind] = React.useState(provider.kind); @@ -74,6 +79,8 @@ export function ProviderEditor({ const [defaultModel, setDefaultModel] = React.useState( provider.defaultModel ?? provider.models[0] ?? "", ); + const [artwork, setArtwork] = React.useState(provider.artwork); + const [artworkBusy, setArtworkBusy] = React.useState(false); const [testing, setTesting] = React.useState(false); const [saving, setSaving] = React.useState(false); const [modelsStale, setModelsStale] = React.useState(false); @@ -82,6 +89,18 @@ export function ProviderEditor({ error: boolean; } | null>(null); const modelInfo = useModelInfo(provider.id, models, provider); + const settings = useSettings(); + const visibleDefaultModels = settings.data + ? models.filter( + (modelId) => + !isModelHidden(settings.data?.hiddenModelsByProvider, provider.id, modelId), + ) + : []; + const defaultModelIsHidden = Boolean( + settings.data && + defaultModel && + isModelHidden(settings.data.hiddenModelsByProvider, provider.id, defaultModel), + ); const usesArtificialAnalysis = models.some( (modelId) => modelInfo.data?.[modelId]?.metadataSource === "artificial-analysis", ); @@ -98,6 +117,7 @@ export function ProviderEditor({ setModels(provider.models); setModelMetadata(provider.modelMetadata ?? {}); setDefaultModel(provider.defaultModel ?? provider.models[0] ?? ""); + setArtwork(provider.artwork); setModelsStale(false); setConnectionNotice(null); } @@ -107,6 +127,7 @@ export function ProviderEditor({ id: provider.id, kind, label: label.trim() || provider.label, + artwork, baseUrl: baseUrl.trim() || provider.baseUrl, models, modelMetadata, @@ -117,6 +138,35 @@ export function ProviderEditor({ isBuiltin: false, }); + const chooseArtwork = async (file: File | undefined) => { + if (!file || artworkBusyRef.current) return; + if (file.size > 512 * 1024) { + toast.error("Provider artwork must be 512 KB or smaller."); + return; + } + artworkBusyRef.current = true; + setArtworkBusy(true); + try { + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => + typeof reader.result === "string" + ? resolve(reader.result) + : reject(new Error("Aiden could not read that image.")); + reader.onerror = () => reject(reader.error ?? new Error("Aiden could not read that image.")); + reader.readAsDataURL(file); + }); + const dataBase64 = dataUrl.slice(dataUrl.indexOf(",") + 1); + setArtwork(await providersApi.normalizeArtwork({ name: file.name, dataBase64 })); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Aiden could not use that provider icon."); + } finally { + artworkBusyRef.current = false; + setArtworkBusy(false); + if (artworkInputRef.current) artworkInputRef.current.value = ""; + } + }; + const applyDiscoveredModels = ( list: string[], metadata: Record, @@ -211,6 +261,51 @@ export function ProviderEditor({ setLabel(e.target.value)} /> + +
    + + + + void chooseArtwork(event.target.files?.[0])} + /> + + {artwork ? ( + + ) : null} +
    +
    + 0 ? (
    - - {models.map((m) => ( + {defaultModelIsHidden ? ( + + {resolveModelDisplay( + defaultModel, + modelMetadata[defaultModel]?.name + ? { name: modelMetadata[defaultModel].name } + : modelInfo.data?.[defaultModel], + ).label} · Hidden + + ) : null} + {visibleDefaultModels.map((m) => ( { resolveModelDisplay( @@ -428,6 +537,7 @@ export function ProviderEditor({
    ) : null} + ); } diff --git a/renderer/components/settings/provider-model-visibility.tsx b/renderer/components/settings/provider-model-visibility.tsx new file mode 100644 index 00000000..1602d55f --- /dev/null +++ b/renderer/components/settings/provider-model-visibility.tsx @@ -0,0 +1,134 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { Button, Input, Switch, Text, toast } from "../ui"; +import { settingsApi } from "../../lib/ipc"; +import { createModelEntries } from "../../lib/model-picker-data"; +import { queryKeys, useSettings } from "../../lib/queries"; +import type { AppSettings, Provider } from "../../lib/types"; +import { isModelHidden } from "../../shared/model-visibility"; + +export function ProviderModelVisibility({ provider }: { provider: Provider }) { + const queryClient = useQueryClient(); + const settings = useSettings(); + const [query, setQuery] = React.useState(""); + const [pendingModel, setPendingModel] = React.useState(); + const [showingAll, setShowingAll] = React.useState(false); + const entries = React.useMemo(() => createModelEntries([provider]), [provider]); + const hidden = settings.data?.hiddenModelsByProvider; + const hiddenCount = entries.filter((entry) => + isModelHidden(hidden, provider.id, entry.model), + ).length; + const normalizedQuery = query.trim().toLocaleLowerCase(); + const filtered = normalizedQuery + ? entries.filter((entry) => + `${entry.label} ${entry.model}`.toLocaleLowerCase().includes(normalizedQuery), + ) + : entries; + + const cache = (saved: AppSettings) => { + queryClient.setQueryData(queryKeys.settings, saved); + }; + + const setVisible = async (modelId: string, visible: boolean) => { + setPendingModel(modelId); + try { + cache(await settingsApi.setModelVisibility(provider.id, modelId, !visible)); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Couldn't update model visibility."); + } finally { + setPendingModel(undefined); + } + }; + + const showAll = async () => { + setShowingAll(true); + try { + cache(await settingsApi.showAllProviderModels(provider.id)); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Couldn't show all models."); + } finally { + setShowingAll(false); + } + }; + + if (entries.length === 0) return null; + + return ( +
    +
    +
    + + Model visibility + + + Hidden models stay configured and keep working in existing chats, but disappear from + model pickers on this Mac and paired Aiden clients. + +
    + {hiddenCount > 0 ? ( + + ) : null} +
    + +
    + setQuery(event.target.value)} + placeholder="Search models" + aria-label={`Search ${provider.label} models`} + /> + + {entries.length - hiddenCount} shown · {hiddenCount} hidden + +
    + +
    + {filtered.length > 0 ? ( + filtered.map((entry, index) => { + const visible = !isModelHidden(hidden, provider.id, entry.model); + return ( + + ); + }) + ) : ( + + No matching models. + + )} +
    +
    + ); +} diff --git a/renderer/components/settings/providers-settings.tsx b/renderer/components/settings/providers-settings.tsx index 910c89c5..8f5ad97c 100644 --- a/renderer/components/settings/providers-settings.tsx +++ b/renderer/components/settings/providers-settings.tsx @@ -85,6 +85,7 @@ function BuiltinProviderRows({
    @@ -162,9 +163,15 @@ export function ProvidersSettings() { const refreshProviders = async () => { setRefreshingProviders(true); try { - const refreshed = await providersApi.refresh(); - qc.setQueryData(queryKeys.providers, refreshed); - toast.success("Pi provider models refreshed."); + const result = await providersApi.refresh(); + qc.setQueryData(queryKeys.providers, result.providers); + if (result.errors.length > 0) { + toast.warning( + `${result.errors.length} provider catalog${result.errors.length === 1 ? "" : "s"} could not refresh; cached models were kept.`, + ); + } else { + toast.success("Pi provider models refreshed."); + } } catch (error) { toast.error(error instanceof Error ? error.message : "Couldn't refresh Pi provider models."); } finally { @@ -438,7 +445,12 @@ export function ProvidersSettings() { {i > 0 ? : null}
    - +
    diff --git a/renderer/components/settings/remote-access-settings.test.tsx b/renderer/components/settings/remote-access-settings.test.tsx new file mode 100644 index 00000000..6a11e00b --- /dev/null +++ b/renderer/components/settings/remote-access-settings.test.tsx @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = readFileSync(new URL("./remote-access-settings.tsx", import.meta.url), "utf8"); + +test("Remote Access settings use the existing semantic form and confirmation primitives", () => { + for (const primitive of ["FieldSet", "Field", "Switch", "Select", "Callout", "Dialog", "AlertDialog"]) { + assert.match(source, new RegExp(`<${primitive}`, "u")); + } + assert.match(source, /onValueChange=/u); + assert.match(source, /aria-label="Enable Aiden Remote Access"/u); + assert.match(source, /max-\[540px\]/u); +}); + +test("pairing offers QR and a distinct IPC-only manual setup code", () => { + assert.match(source, /QRCode\.toDataURL\(pairing\.qrPayload/u); + assert.doesNotMatch(source, /\{pairing\.secret\}/u); + assert.match(source, /\{pairing\.manualCode\}/u); + assert.match(source, /Copy setup code/u); + assert.match(source, /Certificate check/u); + assert.doesNotMatch(source, /manual pairing password/u); + assert.match(source, /expires after five minutes/u); + assert.match(source, /evaluateRemotePairingLifecycle/u); + assert.match(source, /pairingSessionId/u); + assert.match(source, /connected\.`\)/u); + assert.match(source, /Finishing connection/u); + assert.match(source, /data-remote-device-id/u); + assert.match(source, /aria-live="polite"/u); + assert.match(source, /pairingRequestGeneration/u); + assert.match(source, /remotePairingPresentation/u); + assert.match(source, /effectiveRemotePairingLifecycle/u); + assert.match(source, /Copy Mac address/u); + assert.match(source, /Private Tailscale address/u); + assert.match(source, /Code unavailable/u); + assert.match(source, /aria-disabled=\{pairingPresentation\.qrDisabled\}/u); + assert.match(source, /Create new code/u); + assert.match(source, /Consumed Aiden pairing QR code/u); + assert.match(source, /observedPairingSession\.current = nextPairing\.pairingSessionId/u); +}); + +test("Tailscale and folder controls explain their bounded ownership", () => { + assert.match(source, /never enables Funnel, and never resets unrelated routes/u); + assert.match(source, /Paired devices can explore only these roots/u); + assert.match(source, /Existing Aiden workspaces are unchanged/u); +}); + +test("Tailscale takeover is disclosed only for a stale Aiden route with bounded safe copy", () => { + assert.match(source, /status\.tailscaleRouteState === "other_aiden_stale"/u); + assert.match(source, /aidenRemoteApi\.reviewTailscaleTakeover/u); + assert.match(source, /aidenRemoteApi\.takeOverTailscale\(takeoverReview\.token\)/u); + assert.match(source, /replace only \/api\/aiden\/v1/u); + assert.match(source, /preserve every other Serve handler/u); + assert.match(source, /Another running Aiden profile owns this Mac’s mobile route/u); + assert.doesNotMatch(source, /tailscale_route_conflict.*toast\.error/u); +}); + +test("Tailscale setup failures retain typed actionable remediation", () => { + assert.match(source, /status\.tailscaleErrorCode === "not_installed"/u); + assert.match(source, /status\.tailscaleErrorCode === "not_connected"/u); + assert.match(source, /status\.tailscaleErrorCode === "https_unavailable"/u); + assert.match(source, /Open Tailscale and sign in/u); + assert.match(source, /Enable HTTPS for this Tailscale device name/u); + assert.match(source, / { + assert.match(source, /case "reconciliation_required"/u); + assert.match(source, /Verification needed/u); + assert.match(source, /aidenRemoteApi\.reconcileTailscale/u); + assert.match(source, /Verify update/u); +}); + +test("primary connection tasks stay visible while advanced details use progressive disclosure", () => { + assert.match(source, /title="Mobile devices"/u); + assert.match(source, /Add device/u); + assert.match(source, / { + assert.match(source, /label="Mac name"/u); + assert.match(source, /aidenRemoteApi\.setDisplayName/u); + assert.match(source, /Identity remains/u); + assert.match(source, /maxLength=\{80\}/u); +}); + +test("paired endpoint collisions use typed remediation without exposing socket errors", () => { + assert.match(source, /status\.errorCode === "remote_port_in_use"/u); + assert.match(source, /Another local Aiden profile is using this saved endpoint/u); + assert.match(source, /Aiden will not silently move a saved mobile connection to a new port/u); + assert.doesNotMatch(source, /EADDRINUSE/u); +}); diff --git a/renderer/components/settings/remote-access-settings.tsx b/renderer/components/settings/remote-access-settings.tsx new file mode 100644 index 00000000..126db433 --- /dev/null +++ b/renderer/components/settings/remote-access-settings.tsx @@ -0,0 +1,939 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import QRCode from "qrcode"; +import { + CheckCircle2, + ChevronDown, + Folder, + Info, + Loader2, + Network, + Plus, + RefreshCw, + Smartphone, + Trash2, + TriangleAlert, +} from "lucide-react"; +import { + AlertDialog, + Badge, + Button, + Callout, + Dialog, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Field, + FieldSet, + HoverCard, + HoverCardContent, + HoverCardTrigger, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, + Text, + toast, +} from "../ui"; +import { CopyButton } from "../copy-button"; +import { aidenRemoteApi } from "../../lib/ipc"; +import { queryKeys, useAidenRemoteSettings } from "../../lib/queries"; +import type { + AidenRemoteConnectionMode, + AidenRemoteDeviceView, + AidenRemotePairingBootstrapView, + AidenRemoteSettingsSnapshot, + AidenRemoteTailscaleTakeoverReviewView, +} from "../../shared/aiden-remote"; +import { + groupRemoteDevices, + remoteConnectionSummary, + type RemoteDeviceGroups, +} from "../../lib/remote-connection-status"; +import { + effectiveRemotePairingLifecycle, + evaluateRemotePairingLifecycle, + remotePairingPresentation, +} from "../../lib/remote-pairing-lifecycle"; + +const FRIENDLY_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +function friendlyDate(timestamp: number): string { + return FRIENDLY_DATE_FORMATTER.format(new Date(timestamp)); +} + +function pairingVerificationCode(pairing: AidenRemotePairingBootstrapView): string { + return pairing.serverSpkiSha256.slice(-9, -1).toUpperCase(); +} + +function connectionModeLabel(mode: AidenRemoteConnectionMode): string { + switch (mode) { + case "lan": return "Local Network"; + case "tailscale": return "Tailscale"; + case "both": return "Local Network + Tailscale"; + } +} + +function tailscaleRouteCopy(status: AidenRemoteSettingsSnapshot["status"]): { + badge: string; + description: string; +} { + switch (status.tailscaleRouteState) { + case "owned": { + if (status.tailscaleErrorCode === "not_connected") { + return { badge: "Configured", description: "This profile owns the route, but Tailscale is signed out. Sign in to make it reachable." }; + } + if (status.tailscaleErrorCode === "https_unavailable") { + return { badge: "Configured", description: "This profile owns the route, but HTTPS is not available for this Tailscale name." }; + } + return { badge: status.enabled ? "Connected" : "Configured", description: "This Aiden profile owns the mobile route." }; + } + case "available": + return { badge: "Available", description: "The Aiden mobile route is available on this Mac." }; + case "other_aiden_live": + return { badge: "In use", description: "Another running Aiden profile owns this Mac’s mobile route. Stop or disconnect it before connecting here." }; + case "other_aiden_stale": + return { badge: "Previous route found", description: "A previous Aiden profile left this route behind. Review it before taking over." }; + case "unrelated_conflict": + return { badge: "Path unavailable", description: "The Aiden path has an unrecognized Serve configuration. Aiden will not replace it." }; + case "funnel_conflict": + return { badge: "Funnel conflict", description: "Tailscale Funnel is enabled on this HTTPS listener. Disable Funnel yourself before connecting Aiden." }; + case "reconciliation_required": + return { badge: "Verification needed", description: "Tailscale did not confirm the last route update. Verify its exact result before continuing." }; + case "unavailable": + return { + badge: status.tailscaleErrorCode === "not_installed" || !status.tailscaleInstalled + ? "Tailscale not found" + : status.tailscaleErrorCode === "not_connected" + ? "Sign in required" + : status.tailscaleErrorCode === "https_unavailable" + ? "HTTPS unavailable" + : "Unavailable", + description: status.tailscaleErrorCode === "not_installed" || !status.tailscaleInstalled + ? "Install Tailscale to use this connection method." + : status.tailscaleErrorCode === "not_connected" + ? "Open Tailscale and sign in before connecting Aiden’s mobile route." + : status.tailscaleErrorCode === "https_unavailable" + ? "Enable HTTPS for this Tailscale device name, then try again." + : "Aiden couldn’t safely inspect the current Tailscale Serve configuration.", + }; + } +} + +function friendlyTailscaleError(error: unknown): string { + const message = error instanceof Error ? error.message : ""; + if (message.includes("tailscale_route_live")) return "Another Aiden profile is active on this route. Nothing was changed."; + if (message.includes("tailscale_takeover_changed") || message.includes("tailscale_takeover_expired")) return "The route changed or this review expired. Review it again before taking over."; + if (message.includes("tailscale_funnel_conflict")) return "Tailscale Funnel is using this listener. Aiden did not change it."; + if (message.includes("tailscale_route_conflict")) return "This Serve path is already in use. Aiden did not change it."; + if (message.includes("tailscale_ownership_commit_failed")) return "Aiden restored the previous route because it couldn’t save ownership."; + if (message.includes("tailscale_route_recovery_failed")) return "Aiden couldn’t verify route recovery. Check Tailscale Serve before trying again."; + if (message.includes("tailscale_route_outcome_unknown")) return "Tailscale reported an uncertain route update. Aiden did not save ownership; inspect Serve before retrying."; + if (message.includes("tailscale_reconciliation_conflict")) return "The route changed after the uncertain update. Aiden left it untouched; inspect Tailscale Serve."; + if (message.includes("tailscale_reconciliation_unhealthy")) return "The route exists but this Aiden service did not answer its health check. Nothing was claimed."; + if (message.includes("tailscale_reconciliation_required")) return "Verify the previous Tailscale update before starting another route change."; + if (message.includes("tailscale_not_connected")) return "Open Tailscale and sign in before connecting Aiden."; + if (message.includes("tailscale_https_unavailable")) return "Enable HTTPS for this Tailscale device name before connecting Aiden."; + if (message.includes("tailscale_route_busy")) return "Another Aiden profile is updating this Mac’s mobile route. Wait a moment and try again."; + return "Aiden couldn’t safely update the Tailscale route."; +} + +function Disclosure({ + title, + summary, + children, +}: React.PropsWithChildren<{ title: string; summary: string }>) { + return ( +
    + + + {title} + {summary} + + + +
    {children}
    +
    + ); +} + +function RemoteAccessInfo() { + return ( + + + + + + Aiden stays in control + + Aiden On The Go can use only capabilities and folders approved on this Mac. Provider keys never leave Aiden, and the desktop app must be running. Tailscale is optional. + + + + ); +} + +function SettingsDeviceRow({ + device, + state, + onRevoke, + highlighted = false, +}: { + device: AidenRemoteDeviceView; + state: keyof RemoteDeviceGroups; + onRevoke?: () => void; + highlighted?: boolean; +}) { + const timestamp = state === "previous" ? (device.revokedAt ?? device.lastSeenAt) : device.lastSeenAt; + return ( +
    + +
    + {device.name} + + {device.type === "ipad" ? "iPad" : "iPhone"} · {state === "pending" + ? "Finishing connection" + : `${state === "previous" ? "Removed" : "Last seen"} ${friendlyDate(timestamp)}`} + +
    + {state === "active" ? Active : null} + {state === "pending" ? Finishing : null} + {state === "inactive" ? Inactive : null} + {state === "previous" ? Previous : null} + {onRevoke ? : null} +
    + ); +} + +export function RemoteAccessSettings() { + const queryClient = useQueryClient(); + const settingsQuery = useAidenRemoteSettings(); + const [busy, setBusy] = React.useState(null); + const [pairing, setPairing] = React.useState(null); + const completedPairingDeviceId = React.useRef(null); + const pairingRef = React.useRef(null); + const mounted = React.useRef(false); + const pairingRequestGeneration = React.useRef(0); + const observedPairingSession = React.useRef(null); + const [highlightedDeviceId, setHighlightedDeviceId] = React.useState(null); + const [pairingQr, setPairingQr] = React.useState(null); + const [pairingSeconds, setPairingSeconds] = React.useState(0); + const [revokeDevice, setRevokeDevice] = React.useState(null); + const [removeRootId, setRemoveRootId] = React.useState(null); + const [takeoverReview, setTakeoverReview] = React.useState(null); + const [displayNameDraft, setDisplayNameDraft] = React.useState(""); + + React.useEffect(() => aidenRemoteApi.onChanged(() => { + void queryClient.invalidateQueries({ queryKey: queryKeys.aidenRemote }); + }), [queryClient]); + + React.useEffect(() => { + if (settingsQuery.data?.displayName) { + setDisplayNameDraft(settingsQuery.data.displayName); + } + }, [settingsQuery.data?.displayName]); + + React.useEffect(() => { + if (settingsQuery.data?.status.tailscaleRouteState !== "other_aiden_stale") { + setTakeoverReview(null); + } + }, [settingsQuery.data?.status.tailscaleRouteState]); + + React.useEffect(() => { + if (!pairing) { + setPairingQr(null); + setPairingSeconds(0); + return; + } + let current = true; + void QRCode.toDataURL(pairing.qrPayload, { + errorCorrectionLevel: "M", + margin: 2, + width: 512, + color: { dark: "#000000", light: "#ffffff" }, + }).then((url) => current && setPairingQr(url)).catch(() => { + if (current) toast.error("Aiden couldn't draw the pairing code."); + }); + const updateRemaining = () => { + setPairingSeconds(Math.max(0, Math.ceil((Date.parse(pairing.expiresAt) - Date.now()) / 1_000))); + }; + updateRemaining(); + const interval = window.setInterval(updateRemaining, 1_000); + return () => { + current = false; + window.clearInterval(interval); + }; + }, [pairing]); + + React.useEffect(() => { + pairingRef.current = pairing; + }, [pairing]); + + React.useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + pairingRequestGeneration.current += 1; + const current = pairingRef.current; + if (current) void aidenRemoteApi.closePairing(current.pairingSessionId).catch(() => undefined); + }; + }, []); + + const pairingLifecycle = React.useMemo(() => pairing && settingsQuery.data + ? evaluateRemotePairingLifecycle({ + pairingSessionId: pairing.pairingSessionId, + status: settingsQuery.data.pairing, + devices: settingsQuery.data.devices, + }) + : { state: "unrelated" as const }, [ + pairing, + settingsQuery.data, + ]); + + React.useEffect(() => { + if (!pairing || !settingsQuery.data) return; + if (settingsQuery.data.pairing?.sessionId === pairing.pairingSessionId) { + observedPairingSession.current = pairing.pairingSessionId; + } else if ( + observedPairingSession.current === pairing.pairingSessionId && + settingsQuery.data.pairing?.sessionId !== pairing.pairingSessionId && + busy !== "closingPairing" + ) { + setPairing(null); + toast.error(settingsQuery.data.pairing + ? "This pairing code was replaced by a newer pairing window." + : "This pairing window was closed on this Mac."); + return; + } + if (pairingLifecycle.state === "cancelled") { + setPairing(null); + void aidenRemoteApi.closePairing(pairing.pairingSessionId).catch(() => undefined); + toast.error("Pairing was cancelled because this device was revoked."); + return; + } + if (pairingLifecycle.state !== "connected") return; + const connected = pairingLifecycle.device; + if (completedPairingDeviceId.current === connected.id) return; + completedPairingDeviceId.current = connected.id; + setBusy("closingPairing"); + void aidenRemoteApi.closePairing(pairing.pairingSessionId).then(() => { + setPairing(null); + setHighlightedDeviceId(connected.id); + toast.success(`${connected.name} connected.`); + window.requestAnimationFrame(() => { + const row = document.querySelector( + `[data-remote-device-id="${connected.id}"]`, + ); + row?.scrollIntoView({ block: "nearest" }); + row?.focus({ preventScroll: true }); + }); + window.setTimeout(() => setHighlightedDeviceId(null), 3_000); + }).catch(() => { + toast.error("The device connected, but Aiden couldn't close the pairing window."); + }).finally(() => setBusy(null)); + }, [busy, pairing, pairingLifecycle, settingsQuery.data]); + + const commit = React.useCallback((next: AidenRemoteSettingsSnapshot) => { + queryClient.setQueryData(queryKeys.aidenRemote, next); + }, [queryClient]); + + const mutate = async ( + operation: string, + action: () => Promise, + errorMessage?: (error: unknown) => string, + ) => { + if (busy) return; + setBusy(operation); + try { + commit(await action()); + } catch (error) { + toast.error(errorMessage + ? errorMessage(error) + : error instanceof Error ? error.message : "Remote Access couldn't be updated."); + await queryClient.invalidateQueries({ queryKey: queryKeys.aidenRemote }); + } finally { + setBusy(null); + } + }; + + const beginPairing = async (transport: "lan" | "tailscale") => { + if (busy) return; + setBusy("pairing"); + const requestGeneration = ++pairingRequestGeneration.current; + try { + completedPairingDeviceId.current = null; + observedPairingSession.current = null; + const nextPairing = await aidenRemoteApi.beginPairing(transport); + if (!mounted.current || pairingRequestGeneration.current !== requestGeneration) { + await aidenRemoteApi.closePairing(nextPairing.pairingSessionId).catch(() => undefined); + return; + } + observedPairingSession.current = nextPairing.pairingSessionId; + queryClient.setQueryData( + queryKeys.aidenRemote, + (current) => current + ? { + ...current, + pairing: { + sessionId: nextPairing.pairingSessionId, + state: "awaiting_scan", + }, + } + : current, + ); + setPairing(nextPairing); + await queryClient.invalidateQueries({ queryKey: queryKeys.aidenRemote }); + } catch (error) { + if (mounted.current && pairingRequestGeneration.current === requestGeneration) { + toast.error(error instanceof Error ? error.message : "Aiden couldn't open pairing."); + } + } finally { + if (mounted.current && pairingRequestGeneration.current === requestGeneration) { + setBusy(null); + } + } + }; + + const reviewTailscaleTakeover = async () => { + if (busy) return; + setBusy("tailscaleReview"); + try { + setTakeoverReview(await aidenRemoteApi.reviewTailscaleTakeover()); + } catch (error) { + toast.error(friendlyTailscaleError(error)); + await queryClient.invalidateQueries({ queryKey: queryKeys.aidenRemote }); + } finally { + setBusy(null); + } + }; + + const confirmTailscaleTakeover = async () => { + if (!takeoverReview || busy) return; + setBusy("tailscaleTakeover"); + try { + commit(await aidenRemoteApi.takeOverTailscale(takeoverReview.token)); + setTakeoverReview(null); + toast.success("This Aiden profile now owns the mobile route."); + } catch (error) { + toast.error(friendlyTailscaleError(error)); + setTakeoverReview(null); + await queryClient.invalidateQueries({ queryKey: queryKeys.aidenRemote }); + } finally { + setBusy(null); + } + }; + + const closePairing = async (open: boolean) => { + if (open) return; + const current = pairing; + if (!current || busy) return; + setBusy("closingPairing"); + try { + await aidenRemoteApi.closePairing(current.pairingSessionId); + setPairing(null); + } catch { + toast.error("Aiden couldn't close this pairing window."); + } finally { + setBusy(null); + } + }; + + if (settingsQuery.isLoading) { + return ( +
    + + + Checking… + + +
    + ); + } + + if (!settingsQuery.data) { + return ( + + Remote Access is unavailable + + {settingsQuery.error instanceof Error ? settingsQuery.error.message : "Aiden couldn't read its remote settings."} + + + ); + } + + const snapshot = settingsQuery.data; + const { status } = snapshot; + const transportAllowsLan = status.connectionMode === "lan" || status.connectionMode === "both"; + const transportAllowsTailscale = status.connectionMode === "tailscale" || status.connectionMode === "both"; + const groups = groupRemoteDevices(snapshot.devices, { serviceRunning: status.running }); + const tailscalePresentation = tailscaleRouteCopy(status); + const effectivePairingLifecycle = effectiveRemotePairingLifecycle(pairingLifecycle, pairingSeconds); + const pairingPresentation = remotePairingPresentation(effectivePairingLifecycle, pairingSeconds); + const pairingTransport = pairing?.endpoint.includes(".ts.net/") ? "tailscale" : "lan"; + const canPairLan = transportAllowsLan && status.running; + const canPairTailscale = status.enabled && transportAllowsTailscale && status.tailscaleConnected; + const availablePairingTransports = [ + ...(canPairLan ? (["lan"] as const) : []), + ...(canPairTailscale ? (["tailscale"] as const) : []), + ]; + const summary = remoteConnectionSummary({ + enabled: status.enabled, + running: status.running, + error: status.error, + activeDeviceCount: groups.active.length, + }); + + return ( + <> +
    + + This Mac + + + )} + description="Connect Aiden On The Go while Aiden is running." + > +
    + {busy === "enabled" ? : null} + {summary} + void mutate("enabled", () => aidenRemoteApi.setEnabled(enabled))} + disabled={busy !== null} + aria-label="Enable Aiden Remote Access" + /> +
    +
    + +
    { + event.preventDefault(); + void mutate("displayName", () => aidenRemoteApi.setDisplayName(displayNameDraft)); + }} + > + setDisplayNameDraft(event.target.value)} + maxLength={80} + aria-label="Mac display name" + disabled={busy !== null} + /> + +
    +
    + {status.error ? ( +
    + + {status.error} +
    + ) : null} +
    + +
    + +
    + {availablePairingTransports.length <= 1 ? ( + + ) : ( + + + + + + void beginPairing("lan")}> + Local Network + + void beginPairing("tailscale")}> + Tailscale + + + + )} +
    +
    + {groups.active.length === 0 && groups.pending.length === 0 && groups.inactive.length === 0 ? ( +
    No devices are paired with this Mac.
    + ) : ( + <> + {groups.active.map((device) => ( + setRevokeDevice(device)} + /> + ))} + {groups.pending.map((device) => ( + setRevokeDevice(device)} + /> + ))} + {groups.inactive.map((device) => ( + setRevokeDevice(device)} + /> + ))} + + )} + {groups.previous.length > 0 ? ( +
    + + Previous connections + + {groups.previous.length} + + + + {groups.previous.map((device) => ( + + ))} +
    + ) : null} +
    + + + + + + + +
    + {status.running ? ( + + ) : status.error ? ( + + ) : ( + + )} + {status.running ? "Ready" : summary} +
    + {status.lanEndpoint ? ( + + Local: {status.lanEndpoint} + + ) : null} + {status.tailscaleEndpoint ? ( + + Tailscale: {status.tailscaleEndpoint} + + ) : null} + {status.errorCode === "remote_port_in_use" ? ( + + Another local Aiden profile is using this saved endpoint. Stop that profile and try again; Aiden will not silently move a saved mobile connection to a new port. + + ) : status.error ? ( + {status.error} + ) : null} +
    +
    + {transportAllowsTailscale ? ( + + + + {status.tailscaleRoutePreview ?? "Preparing the loopback route…"} + +
    +
    + + {tailscalePresentation.badge} + + + {tailscalePresentation.description} + +
    + {status.tailscaleRouteState === "owned" ? ( + + ) : status.tailscaleRouteState === "available" ? ( + + ) : status.tailscaleRouteState === "other_aiden_stale" ? ( + + ) : status.tailscaleRouteState === "reconciliation_required" ? ( + + ) : null} +
    +
    +
    + ) : null} +
    + + + +
    + +
    +
    + {snapshot.approvedRoots.length === 0 ? ( +
    No folders are approved for remote browsing.
    + ) : snapshot.approvedRoots.map((root) => ( +
    + +
    + {root.label} + {root.folderPath} +
    + +
    + ))} +
    + + void closePairing(open)} + title="Pair Aiden On The Go" + description={effectivePairingLifecycle.state === "finishing" + ? "The code was accepted. Keep Aiden On The Go open while it finishes connecting." + : effectivePairingLifecycle.state === "failed" + ? "This one-time code was consumed, but Aiden couldn't create the connection. Close and try again." + : effectivePairingLifecycle.state === "expired" + ? "This one-time code expired. Create a new code to continue." + : "Scan the QR or enter the one-time setup code in Aiden On The Go. Do not share either one."} + confirmHidden + busy={busy === "closingPairing"} + > + {pairing ? ( +
    + {pairingQr ? ( +
    + {pairingPresentation.qrDisabled + {effectivePairingLifecycle.state === "finishing" ? ( +
    + + Finishing connection +
    + ) : null} +
    + ) : ( +
    + )} + + {pairingPresentation.badge} + +
    + + {pairingTransport === "tailscale" ? "Private Tailscale address" : "Nearby Mac address"} + +
    + {pairing.endpoint} + +
    +
    +
    +
    +
    + Enter this code instead + + {pairingPresentation.qrDisabled ? "Code unavailable" : pairing.manualCode} + +
    + {!pairingPresentation.qrDisabled ? ( + + ) : null} +
    + + {pairingTransport === "tailscale" + ? "Enter this private address and the setup code on your iPhone or iPad." + : "Select this discovered Mac, then enter the setup code on your iPhone or iPad."} + +
    + + Certificate check {pairingVerificationCode(pairing)} · {pairingPresentation.qrDisabled + ? pairingPresentation.badge + : `Expires in ${pairingSeconds} seconds`}. + + {(effectivePairingLifecycle.state === "expired" || effectivePairingLifecycle.state === "failed") ? ( + + ) : null} +
    + ) : null} +
    + + !open && setTakeoverReview(null)} + title="Take over Aiden’s mobile route?" + description="The previous Aiden target did not answer two bounded health checks. Aiden will replace only /api/aiden/v1, preserve every other Serve handler, and leave Funnel unchanged. Local Network access is unaffected." + confirmLabel="Take Over" + busy={busy === "tailscaleTakeover"} + keepOpenOnConfirm + onConfirm={confirmTailscaleTakeover} + /> + + !open && setRevokeDevice(null)} + title="Revoke this device?" + description={revokeDevice ? `“${revokeDevice.name}” will immediately lose Remote Access. Pair it again to restore access.` : undefined} + confirmLabel="Revoke" + confirmVariant="destructive" + busy={busy === "revoke"} + keepOpenOnConfirm + onConfirm={async () => { + if (!revokeDevice) return; + await mutate("revoke", () => aidenRemoteApi.revokeDevice(revokeDevice.id)); + setRevokeDevice(null); + }} + /> + + !open && setRemoveRootId(null)} + title="Remove this approved folder?" + description="Paired devices will no longer be able to explore or add workspaces from this root. Existing Aiden workspaces are unchanged." + confirmLabel="Remove" + confirmVariant="destructive" + busy={busy === "removeRoot"} + keepOpenOnConfirm + onConfirm={async () => { + if (!removeRootId) return; + await mutate("removeRoot", () => aidenRemoteApi.removeApprovedRoot(removeRootId)); + setRemoveRootId(null); + }} + /> + + ); +} diff --git a/renderer/components/settings/telegram-settings.tsx b/renderer/components/settings/telegram-settings.tsx index 45c07c8b..8bde1938 100644 --- a/renderer/components/settings/telegram-settings.tsx +++ b/renderer/components/settings/telegram-settings.tsx @@ -22,6 +22,7 @@ import { telegramApi } from "../../lib/ipc"; import { queryKeys, useProviders, + useSettings, useTelegramSettings, useWorkspaces, } from "../../lib/queries"; @@ -33,11 +34,13 @@ import { GENERATION_THINKING_LEVELS, type GenerationThinkingLevel, } from "../../shared/generation-thinking"; +import { isModelHidden } from "../../shared/model-visibility"; export function TelegramSettings() { const qc = useQueryClient(); const telegram = useTelegramSettings(); const providers = useProviders(); + const settings = useSettings(); const workspaces = useWorkspaces(); const [keyDraft, setKeyDraft] = React.useState(""); const [profileDraft, setProfileDraft] = React.useState(""); @@ -61,13 +64,9 @@ export function TelegramSettings() { const threadedMode = telegram.data?.threadedMode ?? false; const activeProfile = telegram.data?.activeProfile ?? "default"; const profiles = telegram.data?.profiles ?? []; - const workspaceOptions = telegramWorkspaceOptions( - workspaces.data ?? [], - telegramWorkspaceId, - ); + const workspaceOptions = telegramWorkspaceOptions(workspaces.data ?? [], telegramWorkspaceId); const folderWorkspaceCount = workspaceOptions.filter( - (workspace) => - workspace.value !== TELEGRAM_ASSISTANT_ONLY_VALUE && !workspace.unavailable, + (workspace) => workspace.value !== TELEGRAM_ASSISTANT_ONLY_VALUE && !workspace.unavailable, ).length; const saveKey = async () => { @@ -75,7 +74,9 @@ export function TelegramSettings() { await telegramApi.setKey(value); setKeyDraft(""); await invalidate(); - toast.success(value ? "Telegram bot token saved." : "Telegram bot token removed and bridge disabled."); + toast.success( + value ? "Telegram bot token saved." : "Telegram bot token removed and bridge disabled.", + ); }; const toggle = async (value: boolean) => { @@ -183,8 +184,30 @@ export function TelegramSettings() { (p) => p.models.length > 0 && (p.hasKey || !p.needsKey), ); + const visibleModelsForProvider = (provider: (typeof usableProviders)[number]) => + provider.models.filter( + (modelId) => !isModelHidden(settings.data?.hiddenModelsByProvider, provider.id, modelId), + ); + + const selectableProviders = usableProviders.filter( + (provider) => + visibleModelsForProvider(provider).length > 0 || provider.id === telegramProviderId, + ); + const selectedProvider = usableProviders.find((p) => p.id === telegramProviderId); - const selectedModel = telegramModel || selectedProvider?.defaultModel || selectedProvider?.models[0] || ""; + const visibleModels = selectedProvider ? visibleModelsForProvider(selectedProvider) : []; + const currentHiddenModel = + selectedProvider && + telegramModel && + isModelHidden(settings.data?.hiddenModelsByProvider, selectedProvider.id, telegramModel) + ? telegramModel + : undefined; + const selectedModel = + telegramModel || + (selectedProvider?.defaultModel && visibleModels.includes(selectedProvider.defaultModel) + ? selectedProvider.defaultModel + : visibleModels[0]) || + ""; return (
    @@ -201,13 +224,16 @@ export function TelegramSettings() { {profiles.map((profile) => ( - {profile.name}{profile.status.status === "polling" ? " · connected" : ""} + {profile.name} + {profile.status.status === "polling" ? " · connected" : ""} ))} {activeProfile !== "default" && ( - + )}
    @@ -217,7 +243,12 @@ export function TelegramSettings() { placeholder="New profile name" aria-label="New Telegram profile name" /> -
    @@ -305,42 +336,58 @@ export function TelegramSettings() { value={telegramProviderId} onValueChange={(pid) => { const provider = usableProviders.find((p) => p.id === pid); - const model = provider?.defaultModel ?? provider?.models[0] ?? ""; - void saveProvider(pid, model); + if (!provider) return; + const models = visibleModelsForProvider(provider); + const model = + provider.defaultModel && models.includes(provider.defaultModel) + ? provider.defaultModel + : models[0]; + if (model) void saveProvider(pid, model); }} > - {usableProviders.map((p) => ( + {selectableProviders.map((p) => ( {p.label} ))} - {selectedProvider && selectedProvider.models.length > 1 && ( - - )} + {selectedProvider && + visibleModels.length > 0 && + (visibleModels.length > 1 || Boolean(currentHiddenModel)) && ( + + )} + {selectedProvider && visibleModels.length === 0 && currentHiddenModel ? ( +

    + {currentHiddenModel} is hidden. Show a model in Provider Settings before changing + this bot's model. +

    + ) : null}
    ) : (

    - Configure at least one provider in Settings → Providers, then return here to select it for Telegram. + Configure at least one provider in Settings → Providers, then return here to select it + for Telegram.

    )} @@ -378,9 +425,17 @@ export function TelegramSettings() { /> - - void saveExperience({ rendering: value as typeof rendering })} + > + + + Native Rich Markdown Legacy HTML @@ -388,9 +443,17 @@ export function TelegramSettings() { - - void saveExperience({ voiceMode: value as typeof voiceMode })} + > + + + Hidden / explicit only Mirror voice input @@ -399,8 +462,14 @@ export function TelegramSettings() { - - void saveExperience({ threadedMode: checked })} /> + + void saveExperience({ threadedMode: checked })} + />