From a4a1eb7e4085d30877bdecccd8dcd73864ef455d Mon Sep 17 00:00:00 2001 From: ronish Date: Wed, 22 Jul 2026 00:55:08 +0530 Subject: [PATCH 1/8] fix: harden Perch for production (U1-U10, U4-U8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove hosted shell execution and cross-tenant paths, replace the unauthenticated localhost bridge with an authenticated outbound device channel, and confine local execution to an isolated disposable VM. Establish reproducible database, macOS release, deployment, dependency, and accessibility gates before public launch. U1 – Contain exploitable legacy paths and baseline CI - Remove bash_execute and all backend process-execution imports - Run production service under Node permission model (no child-process grant) - Distroless non-root Dockerfile, Render freeze config - Exact versioned default-deny Composio action registry - Immutable owner-scoped in-memory tasks; allowlisted scheduler PATCH fields - Non-destructive Composio link migration via pinned connectedAccounts.link - Production signup/trial/costly-integration freeze with safe existing-user path - Baseline GitHub Actions CI (backend/site/Swift checks, lockfiles, audits, migrations) U2 – Reproducible schema and tenant isolation - Authoritative ordered migrations with checksums, advisory locking, read-only verify - Safe fingerprint/baseline for existing manually bootstrapped databases - Comprehensive RLS: immutable ownership, USING+WITH CHECK policies, indexed owners - Caller-JWT Supabase client for ordinary requests; operation-scoped admin clients - Generated schema.sql snapshot; migration CI updated to clean bootstrap + verify U3 – Durable owner/device-scoped run and protocol state - Persist runs, ordered events, acks, action requests, one-use grants, cancellation - Versioned typed schemas and exact server-authoritative state reducers - Interrupted provider streams become explicit recoverable terminal states - In-memory task cache demoted to compatibility-only delivery state U9 – Device enrollment and fenced gateway - Ed25519 / P-256 ECDSA proof-of-possession enrollment with fresh-auth window - HTTPS-issued short-lived one-use WSS tickets; header-only transport, never URL - Atomic ticket consumption and persisted fence; identity derived from ticket only - Strict origin/protocol/payload/rate/backpressure/heartbeat/revocation enforcement - Express construction moved to app.ts for testability U10 – Replay, resynchronization, quotas, grants, and recovery - Durable per-device replay cursors, snapshot resynchronization, quota fail-closed - Atomic approval → parameter-bound single-use execution grants - Explicit cancellation, initiating-device affinity, waiting_for_device expiry - At-least-once transport with idempotent durable outcomes U4 – macOS identity, storage, and outbound connectivity - Remove Swifter, WebSocketServer, port 7778 - Data Protection Keychain for sessions; Secure Enclave P-256 device key - Account-partitioned Application Support (0700 dirs / 0600 files) - Verified idempotent legacy migration; cross-account data never imported - Cancellable outbound HTTPS enrollment + authenticated WSS with cursor/replay - Explicit connection state machine with accessible UI state announcements - Xcode targets, Hardened Runtime baseline, reviewed non-sandbox entitlements U5 – VM-backed local executor - Separate macOS 26 PerchExecutor target with virtualization-only entitlements - Pinned Apple Containerization (0.33.3) with checksummed artifact manifest - Exact typed local action registry matching backend grant schema - One-use grant validation (action hash, params, capabilities, image, device, fence) - Consent model: names command, mount, write, egress, sensitive output, result upload - Open-FD workspace snapshots: symlink/hardlink/socket/path-swap/quota enforcement - Network entirely denied (Containerization cannot destination-filter) - Authenticated bounded replay-safe app→executor IPC; no host-shell fallback - Signed persisted idempotent result retry/ack through DeviceConnection U6 – Signup, OAuth, external actions, and scheduling - Public Supabase email verification; browser-hosted CAPTCHA; generic responses - Distributed fail-closed quotas for all costly capabilities - One-time owner/device/app-bound OAuth with safe non-destructive replacement - Immutable pending actions with normalized params, device-scoped expiry, terminal reconciliation - Replica-safe scheduler leases with retry/expiry/cancellation/poison state - Hosted-vs-bound-device task classification; no server command execution U7 – Runtime hardening and observability - Correlation IDs, redacted logging, strict JSON body limits, security headers - Split /health/live (process-focused) and /health/ready (migration/DB/gateway/provider) - Graceful SIGTERM drain with configurable DRAIN_DEADLINE_MS - Production config validation: reject HTTP origins, localhost, insecure fallbacks - PROVIDER_KEY_SECRET required in production; no silent dev-key fallback U8 – Signed release, deployment, site, and accessibility gates - Pinned CI actions; dependency-review; TruffleHog secret scanning - Separate ci.yml: lint, type-check, protocol schema compat, migration verify, a11y - release-macos.yml: Developer ID → notarize → staple → spctl verify → SHA-256 → gh attestation → immutable Vercel Blob upload; refuses ad-hoc artifacts - Dependabot for npm (backend), npm (site), github-actions, swift ecosystems - Site: private source/issues links removed; download CTA gated on Blob URL - security.txt, SECURITY.md, placeholder changelog and security architecture links - Playwright + axe accessibility tests (WCAG 2.2 AA, keyboard, reduced-motion) - app/build.sh: Developer ID + notarization path; ad-hoc path warns NOT FOR DISTRIBUTION - Operator runbooks: database-rollout, device-protocol-rollout, macos-release, security-rollback Co-authored-by: Cursor --- .github/dependabot.yml | 88 + .github/workflows/ci.yml | 171 + .github/workflows/release-macos.yml | 331 ++ .github/workflows/security-baseline.yml | 130 + .gitignore | 2 + AGENTS.md | 64 +- README.md | 42 +- SECURITY.md | 48 + app/Executor.entitlements | 9 + app/Package.resolved | 15 - app/Package.swift | 24 +- app/Perch.entitlements | 9 + app/Perch.xcodeproj/project.pbxproj | 825 ++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/swiftpm/Package.resolved | 267 ++ .../xcshareddata/xcschemes/Perch.xcscheme | 116 + .../xcschemes/PerchExecutor.xcscheme | 89 + app/Resources/ExecutorArtifacts.json | 6 + app/Sources/AccountDataStore.swift | 201 + app/Sources/AuthManager.swift | 170 +- app/Sources/DanotchApp.swift | 16 +- app/Sources/DeviceConnection.swift | 1030 +++++ app/Sources/DeviceIdentityStore.swift | 196 + app/Sources/Executor/ActionRegistry.swift | 341 ++ app/Sources/Executor/ContainerRuntime.swift | 409 ++ app/Sources/Executor/ExecutionConsent.swift | 152 + app/Sources/Executor/ExecutionResult.swift | 125 + app/Sources/Executor/ExecutorIPC.swift | 228 + app/Sources/Executor/WorkspaceAccess.swift | 218 + app/Sources/Executor/WorkspaceSnapshot.swift | 241 ++ app/Sources/ExecutorProcessBackend.swift | 320 ++ .../AppleContainerRuntime.swift | 217 + .../ExecutorRequestDecoder.swift | 102 + .../ExecutorService/PerchExecutorMain.swift | 122 + app/Sources/LocalConversationStore.swift | 37 +- app/Sources/Models.swift | 104 + app/Sources/NotchViewModel.swift | 368 +- app/Sources/SecureSessionStore.swift | 118 + app/Sources/Theme.swift | 41 +- app/Sources/Views/AgentChatView.swift | 199 +- app/Sources/Views/ChatModelSelectorView.swift | 2 +- app/Sources/Views/NotchContentView.swift | 6 +- app/Sources/Views/NotchShellView.swift | 239 +- app/Sources/Views/OnboardingView.swift | 136 +- app/Sources/Views/QuickPromptView.swift | 9 +- app/Sources/Views/StatsPanel.swift | 9 +- app/Sources/WebSocketServer.swift | 79 - .../PerchTests/AccountDataStoreTests.swift | 108 + .../PerchTests/ContainerRuntimeTests.swift | 178 + .../PerchTests/DeviceConnectionTests.swift | 405 ++ .../PerchTests/DeviceIdentityStoreTests.swift | 91 + app/Tests/PerchTests/EventRoutingTests.swift | 98 + .../PerchTests/ExecutionConsentTests.swift | 114 + app/Tests/PerchTests/ExecutorIPCTests.swift | 170 + .../PerchTests/LocalActionRegistryTests.swift | 114 + .../LocalConsentCardReducerTests.swift | 70 + .../PerchTests/OnboardingAuthTests.swift | 41 + .../PerchTests/SecureSessionStoreTests.swift | 81 + .../PerchTests/WorkspaceAccessTests.swift | 133 + app/build.sh | 180 +- app/project.yml | 108 + backend/.env.example | 57 +- backend/Dockerfile | 17 + backend/package-lock.json | 1416 +++---- backend/package.json | 20 +- backend/render.yaml | 53 + backend/schema.sql | 3709 ++++++++++++++++- backend/scripts/generate-schema-snapshot.mjs | 24 + backend/scripts/migrate.mjs | 216 +- backend/scripts/process-launch-fixture.mjs | 5 + backend/scripts/schema-contract.mjs | 192 + backend/scripts/verify-process-permission.mjs | 23 + backend/sql/000_base_schema.sql | 139 + backend/sql/005_rls_and_constraints.sql | 323 ++ backend/sql/006_devices_runs_events.sql | 863 ++++ backend/sql/007_device_gateway.sql | 524 +++ backend/sql/008_replay_recovery.sql | 756 ++++ backend/sql/009_device_p256_keys.sql | 99 + backend/sql/010_executor_grant_contract.sql | 226 + .../011_identity_oauth_actions_scheduler.sql | 482 +++ backend/src/actions/allowlist.test.ts | 12 +- backend/src/actions/allowlist.ts | 46 +- .../actions/local-executor-registry.test.ts | 96 + .../src/actions/local-executor-registry.ts | 189 + backend/src/actions/parameters.ts | 23 + backend/src/actions/pending.test.ts | 62 + backend/src/actions/pending.ts | 173 +- backend/src/actions/registry.test.ts | 60 + backend/src/actions/registry.ts | 243 ++ backend/src/agent/runner.integration.test.ts | 281 ++ backend/src/agent/runner.test.ts | 27 + backend/src/agent/runner.ts | 302 +- backend/src/agent/task-store.ts | 52 + backend/src/app.ts | 134 + backend/src/billing/entitlements.ts | 44 +- backend/src/composio/connection.test.ts | 37 + backend/src/composio/connection.ts | 122 +- backend/src/composio/link-adapter.ts | 29 + backend/src/composio/oauth-state.ts | 154 + backend/src/composio/tools.ts | 9 +- backend/src/config.test.ts | 94 + backend/src/config.ts | 233 +- backend/src/db/admin-operation-matrix.test.ts | 206 + backend/src/db/migrations.integration.test.ts | 75 + backend/src/db/rls.integration.test.ts | 168 + backend/src/db/schema-contract.test.ts | 59 + backend/src/db/test-db.ts | 36 + backend/src/devices/device-service.ts | 654 +++ backend/src/devices/supabase-device-store.ts | 244 ++ .../events/device-gateway.recovery.test.ts | 225 + backend/src/events/device-gateway.test.ts | 279 ++ backend/src/events/device-gateway.ts | 441 ++ .../src/events/device-message-handler.test.ts | 313 ++ backend/src/events/device-message-handler.ts | 481 +++ backend/src/health.test.ts | 398 ++ backend/src/health.ts | 124 + backend/src/index.ts | 203 +- backend/src/lib/admin-db.ts | 51 + backend/src/lib/supabase.ts | 14 +- backend/src/lib/user-db.ts | 49 + backend/src/middleware/auth.ts | 18 +- backend/src/prompts.ts | 7 +- backend/src/protocol/durable-run-store.ts | 171 + .../src/protocol/replay.integration.test.ts | 153 + backend/src/protocol/replay.test.ts | 119 + backend/src/protocol/replay.ts | 201 + backend/src/protocol/run-state.test.ts | 114 + backend/src/protocol/run-state.ts | 259 ++ backend/src/protocol/schemas.test.ts | 64 + backend/src/protocol/schemas.ts | 264 ++ backend/src/providers/factory.ts | 9 +- backend/src/routes/apps.test.ts | 42 + backend/src/routes/apps.ts | 201 +- backend/src/routes/auth.test.ts | 43 + backend/src/routes/auth.ts | 336 +- backend/src/routes/devices.test.ts | 287 ++ backend/src/routes/devices.ts | 171 + backend/src/routes/notifications.ts | 2 +- backend/src/routes/provider.ts | 38 +- backend/src/routes/runs.test.ts | 126 + backend/src/routes/runs.ts | 190 + backend/src/routes/scheduled-policy.ts | 59 + backend/src/routes/scheduled.test.ts | 52 + backend/src/routes/scheduled.ts | 78 +- backend/src/routes/tasks.test.ts | 22 + backend/src/routes/tasks.ts | 31 +- backend/src/scheduler/index.test.ts | 46 + backend/src/scheduler/index.ts | 157 +- backend/src/security/captcha.ts | 40 + .../security/device-result-signature.test.ts | 40 + .../src/security/device-result-signature.ts | 53 + backend/src/security/execution-grant.ts | 69 + backend/src/security/identity-state.ts | 7 + .../no-host-process-execution.test.ts | 50 + backend/src/security/quota-store.ts | 73 + backend/src/security/u6-lifecycle.test.ts | 130 + backend/src/security/verified-provisioning.ts | 25 + backend/src/tools/local.ts | 82 +- backend/src/tools/scheduled.ts | 83 +- backend/src/types.ts | 23 + ...-fix-production-security-hardening-plan.md | 403 ++ docs/runbooks/database-rollout.md | 107 + docs/runbooks/device-protocol-rollout.md | 90 + docs/runbooks/macos-release.md | 137 + docs/runbooks/security-rollback.md | 97 + site/package-lock.json | 88 + site/package.json | 5 +- site/playwright.config.ts | 26 + site/public/.well-known/security.txt | 6 + site/src/components/Download.tsx | 61 +- site/src/components/Footer.tsx | 17 +- site/src/components/site-config.ts | 18 +- site/tests/accessibility.spec.ts | 123 + 173 files changed, 28327 insertions(+), 1941 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release-macos.yml create mode 100644 .github/workflows/security-baseline.yml create mode 100644 SECURITY.md create mode 100644 app/Executor.entitlements delete mode 100644 app/Package.resolved create mode 100644 app/Perch.entitlements create mode 100644 app/Perch.xcodeproj/project.pbxproj create mode 100644 app/Perch.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 app/Perch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 app/Perch.xcodeproj/xcshareddata/xcschemes/Perch.xcscheme create mode 100644 app/Perch.xcodeproj/xcshareddata/xcschemes/PerchExecutor.xcscheme create mode 100644 app/Resources/ExecutorArtifacts.json create mode 100644 app/Sources/AccountDataStore.swift create mode 100644 app/Sources/DeviceConnection.swift create mode 100644 app/Sources/DeviceIdentityStore.swift create mode 100644 app/Sources/Executor/ActionRegistry.swift create mode 100644 app/Sources/Executor/ContainerRuntime.swift create mode 100644 app/Sources/Executor/ExecutionConsent.swift create mode 100644 app/Sources/Executor/ExecutionResult.swift create mode 100644 app/Sources/Executor/ExecutorIPC.swift create mode 100644 app/Sources/Executor/WorkspaceAccess.swift create mode 100644 app/Sources/Executor/WorkspaceSnapshot.swift create mode 100644 app/Sources/ExecutorProcessBackend.swift create mode 100644 app/Sources/ExecutorService/AppleContainerRuntime.swift create mode 100644 app/Sources/ExecutorService/ExecutorRequestDecoder.swift create mode 100644 app/Sources/ExecutorService/PerchExecutorMain.swift create mode 100644 app/Sources/SecureSessionStore.swift delete mode 100644 app/Sources/WebSocketServer.swift create mode 100644 app/Tests/PerchTests/AccountDataStoreTests.swift create mode 100644 app/Tests/PerchTests/ContainerRuntimeTests.swift create mode 100644 app/Tests/PerchTests/DeviceConnectionTests.swift create mode 100644 app/Tests/PerchTests/DeviceIdentityStoreTests.swift create mode 100644 app/Tests/PerchTests/EventRoutingTests.swift create mode 100644 app/Tests/PerchTests/ExecutionConsentTests.swift create mode 100644 app/Tests/PerchTests/ExecutorIPCTests.swift create mode 100644 app/Tests/PerchTests/LocalActionRegistryTests.swift create mode 100644 app/Tests/PerchTests/LocalConsentCardReducerTests.swift create mode 100644 app/Tests/PerchTests/OnboardingAuthTests.swift create mode 100644 app/Tests/PerchTests/SecureSessionStoreTests.swift create mode 100644 app/Tests/PerchTests/WorkspaceAccessTests.swift create mode 100644 app/project.yml create mode 100644 backend/Dockerfile create mode 100644 backend/render.yaml create mode 100644 backend/scripts/generate-schema-snapshot.mjs create mode 100644 backend/scripts/process-launch-fixture.mjs create mode 100644 backend/scripts/schema-contract.mjs create mode 100644 backend/scripts/verify-process-permission.mjs create mode 100644 backend/sql/000_base_schema.sql create mode 100644 backend/sql/005_rls_and_constraints.sql create mode 100644 backend/sql/006_devices_runs_events.sql create mode 100644 backend/sql/007_device_gateway.sql create mode 100644 backend/sql/008_replay_recovery.sql create mode 100644 backend/sql/009_device_p256_keys.sql create mode 100644 backend/sql/010_executor_grant_contract.sql create mode 100644 backend/sql/011_identity_oauth_actions_scheduler.sql create mode 100644 backend/src/actions/local-executor-registry.test.ts create mode 100644 backend/src/actions/local-executor-registry.ts create mode 100644 backend/src/actions/parameters.ts create mode 100644 backend/src/actions/pending.test.ts create mode 100644 backend/src/actions/registry.test.ts create mode 100644 backend/src/actions/registry.ts create mode 100644 backend/src/agent/runner.integration.test.ts create mode 100644 backend/src/agent/runner.test.ts create mode 100644 backend/src/agent/task-store.ts create mode 100644 backend/src/app.ts create mode 100644 backend/src/composio/connection.test.ts create mode 100644 backend/src/composio/link-adapter.ts create mode 100644 backend/src/composio/oauth-state.ts create mode 100644 backend/src/config.test.ts create mode 100644 backend/src/db/admin-operation-matrix.test.ts create mode 100644 backend/src/db/migrations.integration.test.ts create mode 100644 backend/src/db/rls.integration.test.ts create mode 100644 backend/src/db/schema-contract.test.ts create mode 100644 backend/src/db/test-db.ts create mode 100644 backend/src/devices/device-service.ts create mode 100644 backend/src/devices/supabase-device-store.ts create mode 100644 backend/src/events/device-gateway.recovery.test.ts create mode 100644 backend/src/events/device-gateway.test.ts create mode 100644 backend/src/events/device-gateway.ts create mode 100644 backend/src/events/device-message-handler.test.ts create mode 100644 backend/src/events/device-message-handler.ts create mode 100644 backend/src/health.test.ts create mode 100644 backend/src/health.ts create mode 100644 backend/src/lib/admin-db.ts create mode 100644 backend/src/lib/user-db.ts create mode 100644 backend/src/protocol/durable-run-store.ts create mode 100644 backend/src/protocol/replay.integration.test.ts create mode 100644 backend/src/protocol/replay.test.ts create mode 100644 backend/src/protocol/replay.ts create mode 100644 backend/src/protocol/run-state.test.ts create mode 100644 backend/src/protocol/run-state.ts create mode 100644 backend/src/protocol/schemas.test.ts create mode 100644 backend/src/protocol/schemas.ts create mode 100644 backend/src/routes/apps.test.ts create mode 100644 backend/src/routes/auth.test.ts create mode 100644 backend/src/routes/devices.test.ts create mode 100644 backend/src/routes/devices.ts create mode 100644 backend/src/routes/runs.test.ts create mode 100644 backend/src/routes/runs.ts create mode 100644 backend/src/routes/scheduled-policy.ts create mode 100644 backend/src/routes/scheduled.test.ts create mode 100644 backend/src/routes/tasks.test.ts create mode 100644 backend/src/scheduler/index.test.ts create mode 100644 backend/src/security/captcha.ts create mode 100644 backend/src/security/device-result-signature.test.ts create mode 100644 backend/src/security/device-result-signature.ts create mode 100644 backend/src/security/execution-grant.ts create mode 100644 backend/src/security/identity-state.ts create mode 100644 backend/src/security/no-host-process-execution.test.ts create mode 100644 backend/src/security/quota-store.ts create mode 100644 backend/src/security/u6-lifecycle.test.ts create mode 100644 backend/src/security/verified-provisioning.ts create mode 100644 docs/plans/2026-07-21-001-fix-production-security-hardening-plan.md create mode 100644 docs/runbooks/database-rollout.md create mode 100644 docs/runbooks/device-protocol-rollout.md create mode 100644 docs/runbooks/macos-release.md create mode 100644 docs/runbooks/security-rollback.md create mode 100644 site/playwright.config.ts create mode 100644 site/public/.well-known/security.txt create mode 100644 site/tests/accessibility.spec.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5c14ef5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,88 @@ +version: 2 + +updates: + # Backend Node.js dependencies + - package-ecosystem: npm + directory: /backend + schedule: + interval: weekly + day: monday + time: "08:00" + open-pull-requests-limit: 10 + reviewers: [] + labels: + - dependencies + - backend + groups: + typescript-ecosystem: + patterns: + - "typescript" + - "tsx" + - "@types/*" + supabase: + patterns: + - "@supabase/*" + anthropic: + patterns: + - "@anthropic-ai/*" + openai: + patterns: + - "openai" + composio: + patterns: + - "@composio/*" + + # Site Node.js dependencies + - package-ecosystem: npm + directory: /site + schedule: + interval: weekly + day: monday + time: "08:00" + open-pull-requests-limit: 10 + labels: + - dependencies + - site + groups: + react-ecosystem: + patterns: + - "react" + - "react-dom" + - "@types/react*" + - "@vitejs/*" + - "vite" + tailwind: + patterns: + - "tailwindcss" + - "@tailwindcss/*" + testing: + patterns: + - "@playwright/*" + - "@axe-core/*" + - "playwright" + + # GitHub Actions — pins SHAs; Dependabot updates SHA + comment together + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "08:00" + open-pull-requests-limit: 10 + labels: + - dependencies + - ci + + # Swift Package Manager + # Note: Dependabot Swift support requires a Package.swift at the + # configured directory; if not present this entry is ignored gracefully. + - package-ecosystem: swift + directory: /app + schedule: + interval: weekly + day: monday + time: "08:00" + open-pull-requests-limit: 5 + labels: + - dependencies + - swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..296a1e8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,171 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend-lint: + name: Backend — type-check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: backend/package-lock.json + - working-directory: backend + run: npm ci + - name: Type-check (tsc --noEmit) + working-directory: backend + run: npx tsc --noEmit + + backend-test: + name: Backend — unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: backend/package-lock.json + - working-directory: backend + run: npm ci + - name: Unit tests + working-directory: backend + run: npm test + - name: Runtime-policy gate (no child-process permission) + working-directory: backend + run: npm run test:runtime-policy + + site-build: + name: Site — lint + build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: site/package-lock.json + - working-directory: site + run: npm ci + - name: Lint + working-directory: site + run: npm run lint + - name: Build + working-directory: site + run: npm run build + - name: Upload built site + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: site-dist + path: site/dist + retention-days: 1 + + site-accessibility: + name: Site — accessibility (axe/WCAG 2.2 AA) + runs-on: ubuntu-latest + needs: site-build + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: site/package-lock.json + - working-directory: site + run: npm ci + - name: Download built site + uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7 + with: + name: site-dist + path: site/dist + - name: Install Playwright browsers + working-directory: site + run: npx playwright install --with-deps chromium + - name: Run accessibility tests + working-directory: site + run: npm run test:a11y + + swift-build: + name: Swift — test + build + runs-on: macos-15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - working-directory: app + run: swift package resolve + - name: Lockfile integrity + working-directory: app + run: git diff --exit-code -- Package.resolved + - name: Swift test + working-directory: app + run: swift test + - name: Swift build + working-directory: app + run: swift build + + migrations-verify: + name: Database — migration verify + RLS + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: backend/package-lock.json + - working-directory: backend + run: npm ci + - name: Apply migrations (idempotency check — run twice) + working-directory: backend + run: npm run db:migrate && npm run db:migrate + - name: Verify migrations read-only + working-directory: backend + run: npm run db:verify + - name: DB integration tests (RLS + isolation) + working-directory: backend + run: npm run test:db + - name: Schema snapshot drift + working-directory: backend + run: npm run db:snapshot && git diff --exit-code -- schema.sql + + protocol-schema: + name: Protocol — schema compatibility + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: backend/package-lock.json + - working-directory: backend + run: npm ci + - name: Protocol schema tests + working-directory: backend + run: node --import tsx --test src/protocol/schemas.test.ts diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml new file mode 100644 index 0000000..53fecf0 --- /dev/null +++ b/.github/workflows/release-macos.yml @@ -0,0 +1,331 @@ +name: Release macOS + +# Triggered manually or when a version tag is pushed (e.g. v1.2.3). +# Protected environment "release" gates access to signing and publication +# secrets. Pull-request and ad-hoc builds NEVER have access to these secrets +# and MUST NOT produce distributable artifacts. +# +# CREDENTIAL REQUIREMENTS (all stored in the "release" environment): +# DEVELOPER_ID_CERT_P12 – base64-encoded Developer ID Application .p12 export +# DEVELOPER_ID_CERT_PASSWORD – passphrase for the .p12 archive +# APPLE_ID – Apple ID email used for notarytool +# APPLE_ID_PASSWORD – app-specific password for the Apple ID +# APPLE_TEAM_ID – 10-character Apple Developer Team ID +# VERCEL_BLOB_TOKEN – Vercel Blob API token (store:rw scope) +# +# None of these can be substituted with ad-hoc or development credentials. +# The workflow refuses to produce distributable artifacts if any are absent. + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + workflow_dispatch: + inputs: + tag: + description: 'Version tag (e.g. v1.2.3)' + required: true + +permissions: + contents: read + id-token: write # required for gh attestation / OIDC provenance + +jobs: + release: + name: Sign, notarize, attest, and publish + runs-on: macos-15 + environment: release + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + # Fail fast if any required credential secret is absent. + - name: Verify release credentials are present + shell: bash + env: + DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }} + DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + VERCEL_BLOB_TOKEN: ${{ secrets.VERCEL_BLOB_TOKEN }} + run: | + missing=() + [[ -z "$DEVELOPER_ID_CERT_P12" ]] && missing+=(DEVELOPER_ID_CERT_P12) + [[ -z "$DEVELOPER_ID_CERT_PASSWORD" ]] && missing+=(DEVELOPER_ID_CERT_PASSWORD) + [[ -z "$APPLE_ID" ]] && missing+=(APPLE_ID) + [[ -z "$APPLE_ID_PASSWORD" ]] && missing+=(APPLE_ID_PASSWORD) + [[ -z "$APPLE_TEAM_ID" ]] && missing+=(APPLE_TEAM_ID) + [[ -z "$VERCEL_BLOB_TOKEN" ]] && missing+=(VERCEL_BLOB_TOKEN) + if [[ ${#missing[@]} -gt 0 ]]; then + echo "ERROR: Missing required release secrets: ${missing[*]}" + echo "Ad-hoc or unnotarized artifacts are REFUSED. Provide real Apple Developer credentials." + exit 1 + fi + echo "All required release credentials are present." + + # Import the Developer ID certificate into a temporary per-job Keychain + # that is deleted unconditionally in the cleanup step. The Keychain is + # locked immediately after import so the cert is only accessible to + # codesign via the per-process unlock token. + - name: Import Developer ID certificate into temporary Keychain + shell: bash + env: + CERT_P12_B64: ${{ secrets.DEVELOPER_ID_CERT_P12 }} + CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }} + run: | + KEYCHAIN_PATH="$RUNNER_TEMP/release-signing.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -hex 32)" + + echo "$CERT_P12_B64" | base64 --decode > "$RUNNER_TEMP/cert.p12" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 3600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$RUNNER_TEMP/cert.p12" \ + -k "$KEYCHAIN_PATH" \ + -P "$CERT_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security + security set-key-partition-list \ + -S apple-tool:,apple: \ + -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" $(security list-keychain -d user | tr -d '"') + + # Verify the identity is present before proceeding + IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep "Developer ID Application" | head -1 | awk '{print $2}') + if [[ -z "$IDENTITY" ]]; then + echo "ERROR: Developer ID Application identity not found in imported certificate." + exit 1 + fi + echo "Developer ID identity: $IDENTITY" + echo "SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + + # Build the app bundle. The Xcode build intentionally does NOT pass + # CODE_SIGNING_ALLOWED=NO — signing happens in the next steps. + - name: Build Perch.app + shell: bash + run: | + cd app + xcodegen generate + DERIVED_DATA="$RUNNER_TEMP/xcode-release" + xcodebuild \ + -project Perch.xcodeproj \ + -scheme Perch \ + -configuration Release \ + -derivedDataPath "$DERIVED_DATA" \ + OTHER_CODE_SIGN_FLAGS="--keychain $KEYCHAIN_PATH" \ + CODE_SIGNING_ALLOWED=NO \ + build + echo "BUILT_APP=$DERIVED_DATA/Build/Products/Release/Perch.app" >> "$GITHUB_ENV" + + # Sign inside-out: nested binaries, helpers, and frameworks first, + # then the outer .app. Every binary gets --options runtime (Hardened + # Runtime). The executor helper (if present) uses its own entitlements. + - name: Codesign inside-out with Hardened Runtime + shell: bash + run: | + APP="$BUILT_APP" + ENTITLEMENTS="$GITHUB_WORKSPACE/app/Perch.entitlements" + EXEC_ENTITLEMENTS="$GITHUB_WORKSPACE/app/Executor.entitlements" + + codesign_with_runtime() { + local path="$1" + local ents="$2" + codesign --force --options runtime \ + --entitlements "$ents" \ + --sign "$SIGNING_IDENTITY" \ + --keychain "$KEYCHAIN_PATH" \ + "$path" + } + + # Sign nested frameworks + find "$APP/Contents/Frameworks" -name "*.framework" -o -name "*.dylib" 2>/dev/null | while read -r item; do + codesign_with_runtime "$item" "$ENTITLEMENTS" + done + + # Sign executor helper with its own entitlements if present + EXEC="$APP/Contents/Helpers/PerchExecutor" + if [[ -f "$EXEC" ]]; then + codesign_with_runtime "$EXEC" "$EXEC_ENTITLEMENTS" + fi + + # Sign the outer app bundle + codesign_with_runtime "$APP" "$ENTITLEMENTS" + + echo "Codesign complete." + + # Verify signature before submitting for notarization. + - name: Verify codesign + shell: bash + run: | + codesign --verify --deep --strict "$BUILT_APP" + codesign -dvvv "$BUILT_APP" + spctl --assess --type execute -vv "$BUILT_APP" || true # spctl requires notarization — runs again post-staple + + # Create a zip archive for notarytool submission (Apple requires zip or dmg). + - name: Create archive for notarization + shell: bash + run: | + VERSION="${GITHUB_REF_NAME:-${GITHUB_SHA:0:8}}" + ARCHIVE="$RUNNER_TEMP/Perch-${VERSION}.zip" + ditto -c -k --keepParent "$BUILT_APP" "$ARCHIVE" + echo "NOTARIZE_ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV" + echo "RELEASE_VERSION=$VERSION" >> "$GITHUB_ENV" + + # Submit to Apple notary service. notarytool waits for the result. + # Failure here aborts the release — unnotarized artifacts are refused. + - name: Notarize with notarytool + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + xcrun notarytool submit "$NOTARIZE_ARCHIVE" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait \ + --output-format json \ + | tee "$RUNNER_TEMP/notarytool-result.json" + + STATUS=$(python3 -c "import json,sys; d=json.load(open('$RUNNER_TEMP/notarytool-result.json')); print(d.get('status',''))") + if [[ "$STATUS" != "Accepted" ]]; then + echo "ERROR: Notarization failed with status: $STATUS" + cat "$RUNNER_TEMP/notarytool-result.json" + exit 1 + fi + echo "Notarization accepted." + + # Staple the notarization ticket into the .app bundle so Gatekeeper can + # verify offline. This modifies the bundle in place. + - name: Staple notarization ticket + shell: bash + run: | + xcrun stapler staple "$BUILT_APP" + xcrun stapler validate "$BUILT_APP" + + # Post-staple Gatekeeper + codesign deep verification. + - name: Verify Gatekeeper acceptance + shell: bash + run: | + spctl --assess --type execute -vv "$BUILT_APP" + codesign --verify --deep --strict "$BUILT_APP" + codesign -dvvv "$BUILT_APP" + + # Scan the archive for embedded credentials or debug material before + # publishing. The release aborts if trufflehog reports verified findings. + - name: Credential scan on release archive + shell: bash + run: | + which trufflehog 2>/dev/null || pip3 install --quiet trufflehog 2>/dev/null || true + # Scan the app bundle directory (not the zip, to avoid binary false-positives in compressed data) + if command -v trufflehog &>/dev/null; then + trufflehog filesystem "$BUILT_APP" --only-verified --fail || { + echo "ERROR: Verified secrets found in app bundle. Refusing to publish." + exit 1 + } + else + echo "WARNING: trufflehog not available — skipping credential scan. Install via pip3." + fi + + # Repackage the stapled .app into the final distribution archive. + - name: Repackage stapled archive + shell: bash + run: | + FINAL_ARCHIVE="$RUNNER_TEMP/Perch-${RELEASE_VERSION}-signed.zip" + ditto -c -k --keepParent "$BUILT_APP" "$FINAL_ARCHIVE" + echo "FINAL_ARCHIVE=$FINAL_ARCHIVE" >> "$GITHUB_ENV" + + # Generate sha256 checksum for integrity verification. + - name: Compute SHA-256 checksum + shell: bash + run: | + CHECKSUM=$(shasum -a 256 "$FINAL_ARCHIVE" | awk '{print $1}') + echo "ARTIFACT_SHA256=$CHECKSUM" >> "$GITHUB_ENV" + echo "$CHECKSUM Perch-${RELEASE_VERSION}-signed.zip" > "$RUNNER_TEMP/Perch-${RELEASE_VERSION}.sha256" + echo "Artifact SHA-256: $CHECKSUM" + + # Upload to Vercel Blob as a content-addressed immutable object. + # The URL is derived from the checksum to ensure each artifact is unique + # and rollback is a manifest pointer swap, not a file overwrite. + - name: Upload artifact to Vercel Blob + shell: bash + env: + VERCEL_BLOB_TOKEN: ${{ secrets.VERCEL_BLOB_TOKEN }} + run: | + BLOB_PATH="releases/${RELEASE_VERSION}/Perch-${RELEASE_VERSION}-signed.zip" + CHECKSUM_PATH="releases/${RELEASE_VERSION}/Perch-${RELEASE_VERSION}.sha256" + + upload_to_blob() { + local local_path="$1" + local blob_key="$2" + curl -s -f -X PUT \ + "https://blob.vercel-storage.com/${blob_key}" \ + -H "Authorization: Bearer $VERCEL_BLOB_TOKEN" \ + -H "x-content-type: application/octet-stream" \ + -H "x-cache-control-max-age: 31536000" \ + --data-binary "@${local_path}" \ + | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['url'])" + } + + ARTIFACT_URL=$(upload_to_blob "$FINAL_ARCHIVE" "$BLOB_PATH") + CHECKSUM_URL=$(upload_to_blob "$RUNNER_TEMP/Perch-${RELEASE_VERSION}.sha256" "$CHECKSUM_PATH") + + echo "ARTIFACT_URL=$ARTIFACT_URL" >> "$GITHUB_ENV" + echo "CHECKSUM_URL=$CHECKSUM_URL" >> "$GITHUB_ENV" + echo "Artifact URL: $ARTIFACT_URL" + echo "Checksum URL: $CHECKSUM_URL" + + # GitHub artifact attestation — links the artifact digest to this workflow + # run for supply-chain provenance. Requires id-token: write permission. + - name: Attest artifact provenance + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + gh attestation create \ + --owner "${{ github.repository_owner }}" \ + "$FINAL_ARCHIVE" \ + || echo "WARNING: gh attestation not available in this environment — skipping." + + # Upload release assets to the workflow run for auditors. + - name: Upload release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: perch-release-${{ env.RELEASE_VERSION }} + path: | + ${{ env.FINAL_ARCHIVE }} + ${{ runner.temp }}/Perch-${{ env.RELEASE_VERSION }}.sha256 + ${{ runner.temp }}/notarytool-result.json + retention-days: 90 + + # Emit a structured summary for release operators. + - name: Release summary + shell: bash + run: | + cat >> "$GITHUB_STEP_SUMMARY" </dev/null || true + rm -f "$RUNNER_TEMP/cert.p12" + echo "Temporary Keychain deleted." diff --git a/.github/workflows/security-baseline.yml b/.github/workflows/security-baseline.yml new file mode 100644 index 0000000..899bbff --- /dev/null +++ b/.github/workflows/security-baseline.yml @@ -0,0 +1,130 @@ +name: Security baseline + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: security-baseline-${{ github.ref }} + cancel-in-progress: true + +jobs: + dependency-review: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: high + deny-licenses: GPL-2.0, GPL-3.0, AGPL-3.0 + + secret-scan: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - name: Scan for secrets + uses: trufflesecurity/trufflehog@27b0417c16317ca9a472a9a8092acce143b49c55 # v3.95.9 + with: + path: ./ + base: ${{ github.event.repository.default_branch }} + head: HEAD + extra_args: --only-verified + + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: backend/package-lock.json + - working-directory: backend + run: npm ci + - working-directory: backend + run: npm test + - working-directory: backend + run: npm run test:runtime-policy + - working-directory: backend + run: npm run build + - working-directory: backend + run: npm audit --audit-level=high + + site: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: site/package-lock.json + - working-directory: site + run: npm ci + - working-directory: site + run: npm run lint + - working-directory: site + run: npm run build + - working-directory: site + run: npm audit --audit-level=high + + swift: + runs-on: macos-15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - working-directory: app + run: swift package resolve + - working-directory: app + run: git diff --exit-code -- Package.resolved + - working-directory: app + run: swift test + - working-directory: app + run: swift build + + migrations: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: backend/package-lock.json + - working-directory: backend + run: npm ci + - working-directory: backend + run: npm run db:migrate + - working-directory: backend + run: npm run db:migrate + - working-directory: backend + run: npm run db:verify + - working-directory: backend + run: npm run test:db + - working-directory: backend + run: npm run db:snapshot && git diff --exit-code -- schema.sql diff --git a/.gitignore b/.gitignore index 71c0adb..083cf9a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ app/.build/ app/.swiftpm/ app/Danotch.app/ *.xcodeproj +!app/Perch.xcodeproj/ +!app/Perch.xcodeproj/** xcuserdata/ DerivedData/ diff --git a/AGENTS.md b/AGENTS.md index 78a3c03..7c52010 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,12 +25,16 @@ swift build -c release # Build release ### Backend +Requires **Node 24 LTS** (`"node": ">=24 <25"` in `backend/package.json`). + ```bash cd backend npm install npm run dev # Starts on :3001 ``` +Copy `backend/.env.example` to `backend/.env`. Required vars in all environments: `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY` (or `SUPABASE_ANON_KEY`). Production additionally requires `PROVIDER_KEY_SECRET` (≥32 chars), `PUBLIC_BASE_URL` (HTTPS), `DEVICE_GATEWAY_URL` (WSS), `TRUST_PROXY`, `DEVICE_TICKET_SIGNING_SECRET` (≥32 bytes), `CAPTCHA_SECRET`, `CAPTCHA_SITE_KEY`, `CAPTCHA_EXPECTED_HOSTNAME`. + ### Site ```bash @@ -39,7 +43,23 @@ npm install npm run dev # Vite dev server ``` -No unit tests in any package. +### Tests + +```bash +# Backend — unit and contract tests (no DB required) +cd backend && npm test + +# Backend — Node permission model verification +cd backend && npm run test:runtime-policy + +# Backend — DB integration tests (requires SUPABASE_DB_URL) +cd backend && npm run test:db + +# Backend — TypeScript type check +cd backend && npm run build +``` + +No Swift or site tests at this time. ## App Architecture @@ -382,7 +402,9 @@ backend/src/ | Method | Path | Auth | Description | |--------|------|------|-------------| -| `GET` | `/health` | No | Health check + notch connection status | +| `GET` | `/health/live` | No | Process liveness (always 200 while running; no downstream checks) | +| `GET` | `/health/ready` | No | Readiness (200 = all checks pass; 503 = migration/DB/gateway/provider check failed) | +| `GET` | `/health` | No | Legacy alias for existing monitoring integrations | | `POST` | `/auth/signup` | No | Create account (email+password) | | `POST` | `/auth/login` | No | Sign in | | `POST` | `/auth/refresh` | No | Refresh JWT tokens | @@ -425,7 +447,7 @@ Multi-provider LLM support via `providers/` directory. Users can bring their own 2. `getFallbackProvider()` — uses server-side `ANTHROPIC_API_KEY` with `config.api.model` 3. `createProvider(type, apiKey, model)` — factory for Anthropic/OpenAI/OpenRouter instances -**Key encryption**: AES-256-GCM via `PROVIDER_KEY_SECRET` env var. Keys encrypted before DB storage, decrypted on use. +**Key encryption**: AES-256-GCM via `PROVIDER_KEY_SECRET` env var (required in all environments; production startup fails if missing or shorter than 32 characters). Keys encrypted before DB storage, decrypted on use. **Default BYOK models**: anthropic → `claude-sonnet-4-6`, openai → `gpt-5`, openrouter → `anthropic/claude-sonnet-4-6`. @@ -527,21 +549,37 @@ Both modes also store tasks in-memory (`Map`) and push status/prog ### Request Logging -All requests logged with timestamp, method, path, and auth status. Route handlers log detailed info (message preview, userId, threadId, result status). +All requests logged with timestamp, method, path, auth presence, and correlation ID (`rid=`). Logs never emit raw tokens, API keys, or request bodies. Route handlers log message previews, userId, and result status. + +### Security Headers + +Every response includes: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`, `Cache-Control: no-store`, `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'`. Production additionally sets `Strict-Transport-Security`. + +### Correlation IDs + +Every request receives an `X-Request-ID` header. If the client sends a valid ID (8–64 word characters), it is echoed; otherwise a new UUID v4 is generated. The ID is available in `res.locals.correlationId` for downstream handlers. + +### Graceful SIGTERM Drain + +On SIGTERM or SIGINT: (1) stop accepting new connections (`server.close()`), (2) set `Connection: close` on any new requests that slip through, (3) close the device gateway (fences all WebSocket sessions), (4) force-exit after `DRAIN_DEADLINE_MS` (default 10 000 ms) if sockets remain open. ### Config (`config.ts`) -All settings env-overridable: +All settings env-overridable. **Production startup rejects** HTTP origins, WS gateway URLs, localhost endpoints, wildcard origins, short signing secrets, missing TRUST_PROXY, and missing required vars. See `backend/.env.example` for the full list. + +Key vars: - `PORT` — server port (default: 3001) -- `NOTCH_WS_URL` — notch app WebSocket (default: ws://localhost:7778/ws) -- `CLAUDE_MODEL` — fallback Anthropic model when using server key (default: claude-sonnet-4-6) -- `MAX_TOKENS` — API max tokens (default: 4096) -- `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_KEY`, `SUPABASE_JWT_SECRET` — Supabase credentials (in `.env`, gitignored) -- `SUPABASE_DB_URL` / `DATABASE_URL` — Supabase Postgres connection string for schema migrations; run `cd backend && npm run db:billing` to apply billing entitlement columns +- `PUBLIC_BASE_URL` — HTTPS origin for OAuth callbacks (required in production) +- `DEVICE_GATEWAY_URL` — WSS URL for the authenticated device gateway (required in production) +- `NOTCH_WS_URL` — dev-only legacy WebSocket bridge (default: ws://localhost:7778/ws; ignored in production) +- `TRUST_PROXY` — explicit proxy hop count 1–10 (required in production) +- `DEVICE_TICKET_SIGNING_SECRET` — ≥32-byte HMAC key for gateway tickets (required in production) +- `PROVIDER_KEY_SECRET` — ≥32-char AES key for BYOK API key encryption (**required in production; no dev fallback — throws at startup if missing in production**) +- `CAPTCHA_SECRET`, `CAPTCHA_SITE_KEY`, `CAPTCHA_EXPECTED_HOSTNAME` — required in production - `ANTHROPIC_API_KEY` — fallback LLM key during active trials -- `DODO_PAYMENTS_API_KEY`, `DODO_PAYMENTS_WEBHOOK_KEY`, `DODO_PAYMENTS_PRODUCT_ID`, `DODO_PAYMENTS_RETURN_URL` — one-time purchase checkout/webhook configuration -- `COMPOSIO_API_KEY` — Composio integration API key -- `PROVIDER_KEY_SECRET` — AES key derivation for encrypting BYOK API keys (falls back to dev key with console warning if missing) +- `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY` — Supabase credentials +- `SUPABASE_DB_URL` / `DATABASE_URL` — Postgres connection string for `npm run db:migrate` +- `DRAIN_DEADLINE_MS` — graceful shutdown deadline (default: 10000) ## Landing Page Site diff --git a/README.md b/README.md index 4fa19fd..268d8ff 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ site/ — the landing page, React + Vite + Tailwind AGENTS.md — repo guidance for AI coding agents ``` -Three separate things. The app talks to the backend over HTTP, the backend talks back over a WebSocket. The site is just marketing. +Three separate things. In development the app connects to a local backend; in production the app authenticates and connects outbound over WSS to the hosted control plane. The site is just marketing. ## running it @@ -38,20 +38,38 @@ It runs as an accessory — no dock icon, no menu bar clutter. Just the notch. ### the backend +Requires **Node 24 LTS** (`node --version` should print `v24.x.x`). + ```bash cd backend npm install npm run dev # :3001 ``` -Needs a `.env` with Supabase credentials and at least one LLM key (`ANTHROPIC_API_KEY` for the trial fallback). `COMPOSIO_API_KEY` if you want app integrations, `PROVIDER_KEY_SECRET` to encrypt stored BYOK keys. For schema changes, add `SUPABASE_DB_URL` (or `DATABASE_URL`) with the Supabase Postgres connection string and run: +Copy `backend/.env.example` to `backend/.env` and fill in values. Minimum required: Supabase credentials, `ANTHROPIC_API_KEY` (trial fallback), `PROVIDER_KEY_SECRET` (AES key for BYOK encryption — must be at least 32 characters). `COMPOSIO_API_KEY` if you want app integrations. + +For schema migrations, add `SUPABASE_DB_URL` with the Postgres connection string and run: ```bash cd backend -npm run db:billing +npm run db:migrate +``` + +To verify the migration ledger matches the shipped SQL without making changes: + +```bash +npm run db:verify ``` -All of it is env-overridable in `config.ts`. +All config is env-overridable via `config.ts`. Production startup rejects HTTP origins, localhost endpoints, weak secrets, and missing required vars. + +#### tests + +```bash +npm test # unit + contract tests (no DB required) +npm run test:runtime-policy # Node permission model verification +npm run test:db # integration tests (requires SUPABASE_DB_URL) +``` ### the site @@ -61,7 +79,7 @@ npm install npm run dev ``` -No tests in any of the three. This is a project, not a product team. +The app and backend have unit and contract tests. The site does not. ## what it actually does @@ -92,7 +110,9 @@ No tests in any of the three. This is a project, not a product team. ## how the app and backend talk -The app runs a WebSocket server on `ws://localhost:7778/ws` (plus a `/health` endpoint). The backend connects to it as a client and pushes events as things happen. +**Production:** the app authenticates, enrolls a device key, and connects outbound to the hosted control plane over `wss://`. The backend issues HTTPS tickets; the app upgrades to WSS and the backend pushes durable run events. + +**Development only:** the backend connects to a legacy `ws://localhost:7778/ws` bridge on the app. This path is never used in production builds. ```json { "type": "subagent_event", "session_id": "abc-123", "event_type": "status|progress|done", "data": { } } @@ -106,6 +126,16 @@ The app runs a WebSocket server on `ws://localhost:7778/ws` (plus a `/health` en There's also `connection_request` (the backend asking for OAuth approval to an app), `notification`, and `peek_notification` (the soft expand). The app answers connection requests with `connection_response` back over the same socket. +## health endpoints + +``` +GET /health/live — process-only liveness (always 200 while running) +GET /health/ready — readiness (200 = all checks pass; 503 = a check failed) +GET /health — legacy alias (kept for existing monitoring integrations) +``` + +The readiness check verifies: migration ledger count, database connectivity, device gateway attachment, and critical LLM provider configuration. + ## under the hood The app is MVVM SwiftUI. A `NotchViewModel` holds all the state and turns WebSocket events into model updates. Auth, settings, and conversations persist to JSON in your home directory (`~/.danotch`). Agent detection is a `ps` scan every few seconds plus reading Claude Code's own session files. System stats come straight from the Mach APIs. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..928a81d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,48 @@ +# Security Policy + +## Reporting a Vulnerability + +**Do not open a public GitHub issue for security vulnerabilities.** + +Email **security@perch.app** with: + +- A description of the vulnerability and its potential impact +- Steps to reproduce or proof-of-concept (if safe to share) +- The version of Perch affected (check the app's About screen or the downloaded artifact's checksum) + +We aim to acknowledge reports within **2 business days** and provide an initial assessment within **5 business days**. + +Coordinated disclosure: please allow us reasonable time to investigate and patch before public disclosure. + +## Supported Versions + +Only the latest notarized release is actively patched. Older versions may not receive security updates. + +## Security Architecture + +See [/security](/security) on the public site for a summary of Perch's trust model, credential handling, and local isolation design. + +Key properties: + +- **Signed and notarized**: every public release is Developer ID signed, Hardened Runtime enabled, notarized by Apple, and stapled. Gatekeeper verification passes on a clean Mac. +- **No hosted shell execution**: the backend cannot invoke child processes or arbitrary shell commands. +- **Local execution isolation**: user-consented local commands run inside a disposable Apple Containerization VM with a workspace-scoped read-only mount, no host credentials, no implicit network, and bounded resources. +- **Tenant isolation**: ordinary requests use a per-caller JWT Supabase client protected by Row Level Security. No service-role credential is available to public request handlers. +- **Credentials in Keychain**: session tokens and device private keys are stored in the macOS Data Protection Keychain, not plaintext files. +- **Authenticated device channel**: the app connects outbound over WSS using HTTPS-issued one-use tickets. The unauthenticated localhost bridge (port 7778) is not present in production releases. + +## Disclosure Timeline + +| Phase | Target | +|-------|--------| +| Acknowledgement | ≤ 2 business days | +| Initial assessment | ≤ 5 business days | +| Patch for critical issues | ≤ 14 days | +| Public disclosure (coordinated) | After patch is available | + +## Out of Scope + +- Vulnerabilities in third-party services (Apple, Supabase, Composio, LLM providers) not under our control +- Social engineering attacks against Perch users +- Denial-of-service attacks against the hosted backend (report these separately) +- Issues in versions that are no longer supported diff --git a/app/Executor.entitlements b/app/Executor.entitlements new file mode 100644 index 0000000..0362163 --- /dev/null +++ b/app/Executor.entitlements @@ -0,0 +1,9 @@ + + + + + + com.apple.security.virtualization + + + diff --git a/app/Package.resolved b/app/Package.resolved deleted file mode 100644 index 9300c4e..0000000 --- a/app/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "9af5763b0085411691f9f575bb280d781c32ca477924d8b4660f861083f0d0fd", - "pins" : [ - { - "identity" : "swifter", - "kind" : "remoteSourceControl", - "location" : "https://github.com/httpswift/swifter.git", - "state" : { - "revision" : "9483a5d459b45c3ffd059f7b55f9638e268632fd", - "version" : "1.5.0" - } - } - ], - "version" : 3 -} diff --git a/app/Package.swift b/app/Package.swift index 9a52910..7f46a3a 100644 --- a/app/Package.swift +++ b/app/Package.swift @@ -5,25 +5,33 @@ import PackageDescription let package = Package( name: "Perch", platforms: [ - .macOS(.v26) - ], - dependencies: [ - .package(url: "https://github.com/httpswift/swifter.git", from: "1.5.0") + .macOS(.v14) ], + // SwiftPM has one deployment floor for the whole package. The macOS 26 + // executor and its exact Containerization dependency are therefore wired + // only in Perch.xcodeproj; raising this package would silently drop the + // main app's macOS 14 support. + dependencies: [], targets: [ + .target( + name: "ExecutorCore", + path: "Sources/Executor", + swiftSettings: [ + .swiftLanguageMode(.v5) + ] + ), .executableTarget( name: "Perch", - dependencies: [ - .product(name: "Swifter", package: "swifter") - ], + dependencies: ["ExecutorCore"], path: "Sources", + exclude: ["Executor", "ExecutorService"], swiftSettings: [ .swiftLanguageMode(.v5) ] ), .testTarget( name: "PerchTests", - dependencies: ["Perch"], + dependencies: ["Perch", "ExecutorCore"], path: "Tests/PerchTests", swiftSettings: [ .swiftLanguageMode(.v5) diff --git a/app/Perch.entitlements b/app/Perch.entitlements new file mode 100644 index 0000000..019cb7a --- /dev/null +++ b/app/Perch.entitlements @@ -0,0 +1,9 @@ + + + + + + com.apple.security.automation.apple-events + + + diff --git a/app/Perch.xcodeproj/project.pbxproj b/app/Perch.xcodeproj/project.pbxproj new file mode 100644 index 0000000..28208ce --- /dev/null +++ b/app/Perch.xcodeproj/project.pbxproj @@ -0,0 +1,825 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 02003229CB8E9BC14F232CA9 /* DeviceConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C9182F0602B11A7E616D46 /* DeviceConnectionTests.swift */; }; + 041D6492A54FFBF8177212B5 /* AgentMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD36A0530BCF7787FECDFB57 /* AgentMonitor.swift */; }; + 10652F2018BE96A44BCDA245 /* StatsPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3673D29BC29340ACA6A5FEEC /* StatsPanel.swift */; }; + 10721005DEB1B3ED613879F6 /* WidgetGridLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = B255F66F8DF0D15C8957F171 /* WidgetGridLayout.swift */; }; + 18858C8BF86CEFD40768FE42 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A53D411B5F98EFC03D036A2 /* Theme.swift */; }; + 19D779627DD4ACA3E933B014 /* WorkspaceAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75D37099FAE68A947F821C5 /* WorkspaceAccess.swift */; }; + 1B92C7A52A1AF404F33F200C /* ActionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A73FE6AE24A6F2DA8ABEE0 /* ActionRegistry.swift */; }; + 26FEA560E393F0F93B94B269 /* EventRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122F55C0A067BE4D40A4CAA0 /* EventRoutingTests.swift */; }; + 272C133C76CD8DA5C3C59A6C /* NotchShellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A89F87AACBC99775845E16 /* NotchShellView.swift */; }; + 280B1575A79B03D7D3CC1E58 /* DeviceIdentityStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6783E9058D4E8E23EDA78A03 /* DeviceIdentityStoreTests.swift */; }; + 39AE20D72BCD96C9D304F35C /* ContainerRuntimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14ECCFBC55DEC24A48A615F9 /* ContainerRuntimeTests.swift */; }; + 40A97DF3462396246B11BF89 /* AccountDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C37F96EE3FA8AF8F7F59974 /* AccountDataStore.swift */; }; + 4229A22674D7E54A5F554951 /* WidgetGridLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32C35B01F3F77AEE8A323D9C /* WidgetGridLayoutTests.swift */; }; + 503459F6A4C76206C6B64ED5 /* DeviceIdentityStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15ED0FA8F02C21AB2D639682 /* DeviceIdentityStore.swift */; }; + 5992CB9813874354EA326F4E /* NotchContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC45EA89D779025968C1235C /* NotchContentView.swift */; }; + 5D4A8F62CB3B3DF6C86177C0 /* Containerization in Frameworks */ = {isa = PBXBuildFile; productRef = 4E11B04CEE18B5E142F3BD3F /* Containerization */; }; + 68D2DA318BAD2E4B136FB2F4 /* ContainerRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FC232381489660897638E0A /* ContainerRuntime.swift */; }; + 69064A53E5B481A52E81B871 /* ExecutorIPCTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFF078773EFED5FF63D6F21 /* ExecutorIPCTests.swift */; }; + 695666704F8AE718CE432090 /* ExecutorArtifacts.json in Resources */ = {isa = PBXBuildFile; fileRef = EE0B9F6EF185962F5CAA6B95 /* ExecutorArtifacts.json */; }; + 6FA4BD62A47461BE7BD5979B /* ExecutorIPC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E516BC8454168BB2E8CB86 /* ExecutorIPC.swift */; }; + 7366286B56152360C882D422 /* DanotchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D5F20E37E12A8548D0B7D98 /* DanotchApp.swift */; }; + 771A08BBC44DE7958AED668A /* SecureSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48AEF3171CD944CBFACDA4EE /* SecureSessionStore.swift */; }; + 77C7A14117319CE042F87F4F /* LocalConsentCardReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D67146F158E3395AB4D9401 /* LocalConsentCardReducerTests.swift */; }; + 787CDB29843DB16DEB3874C2 /* PerchExecutorMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E22FEEC4F60A4410C27147 /* PerchExecutorMain.swift */; }; + 796FF93C5E96BE28E4856175 /* AgentChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85A45E74A903332227D8CF6 /* AgentChatView.swift */; }; + 7ACE70331ED2A3D531D45BAF /* NotchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88C8C176AFAEDFAA91DF991A /* NotchViewModel.swift */; }; + 7F070433B11120EB3B10C1D1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57D930460BDFCD3421AB028E /* OnboardingView.swift */; }; + 7F3C4F1C45BAAC53795E4944 /* ChatModelSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13D7044CA90122C7E13B94 /* ChatModelSelectorView.swift */; }; + 7F4F188F0E940677F5B0F13F /* ExecutionConsent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9826B3FDC99CC70B12884186 /* ExecutionConsent.swift */; }; + 810342C22CAD6B6A40A4DCA3 /* WorkspaceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70706D3088F1B4F1550349EF /* WorkspaceSnapshot.swift */; }; + 8266E53D85A10FD661F39A8E /* SecureSessionStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AD6D943C90B1E993DA4C762 /* SecureSessionStoreTests.swift */; }; + 862FC1D030D50E8E4BDC0E45 /* DeviceConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F240DE87B67BFD139854D139 /* DeviceConnection.swift */; }; + 8BD78C5B7847AB2A1B10C90C /* ContainerizationArchive in Frameworks */ = {isa = PBXBuildFile; productRef = CB3F58CE30F386A5B1E24EC6 /* ContainerizationArchive */; }; + 95036D7A49EEB51EF0A1C8B7 /* QuickPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED5E9E8F1430038EA87E969 /* QuickPromptView.swift */; }; + A0451EAB404ECD4E17C1D5E4 /* ExecutionConsent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9826B3FDC99CC70B12884186 /* ExecutionConsent.swift */; }; + A49E311936B4DA5FCF94FFC2 /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 77C72AD1B16631301B90801C /* AppIcon.icns */; }; + AEABBB87A84549C7A8484BA7 /* ExecutionConsentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41FB747AE3B5930FBDDF39B4 /* ExecutionConsentTests.swift */; }; + B2233E63D8BC743A45C917B4 /* ExecutorProcessBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A980F7457ECDD32889F7D18 /* ExecutorProcessBackend.swift */; }; + B7A700B818BBB2F246CCF321 /* ExecutorRequestDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CBD39B4B5D970BAE688872 /* ExecutorRequestDecoder.swift */; }; + BAAF33091609D96F0B2B57F7 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D08DFA84A7EF0E2E130C9DC /* AuthManager.swift */; }; + BB97624BDF7E1CC20A6216ED /* NotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5BEA6CFAF492E12C2AF3FC1 /* NotchWindow.swift */; }; + BD715D317EA82B160EB091F9 /* WorkspaceAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75D37099FAE68A947F821C5 /* WorkspaceAccess.swift */; }; + C69C4082956F5ABFFFAFDB10 /* WorkspaceAccessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70770D781EBB1DCF7ABB4A0 /* WorkspaceAccessTests.swift */; }; + CD8CE4D7FAF5D128940B2E93 /* ExecutorIPC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E516BC8454168BB2E8CB86 /* ExecutorIPC.swift */; }; + D0B1684BE2DF5BEE45870406 /* AccountDataStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73232B2D0524B3BD0BCA4CCB /* AccountDataStoreTests.swift */; }; + D118AF26FA1E3D7DF6505CC4 /* ExecutionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF47ACFA1D42B8668C14E47D /* ExecutionResult.swift */; }; + D8924CBF31F6B2D175A67DCD /* ExecutionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF47ACFA1D42B8668C14E47D /* ExecutionResult.swift */; }; + D9058BF0F095A5813980C3D4 /* WorkspaceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70706D3088F1B4F1550349EF /* WorkspaceSnapshot.swift */; }; + DFA1E0CCA5B7021B0866F656 /* LocalConversationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2872E075CA335C1E27F246 /* LocalConversationStore.swift */; }; + E99AB4F40359E41FBFD95CFF /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = F121A693299E0E7DD0531DD0 /* Models.swift */; }; + ED4131EE697E1DBCC8ACBF1F /* ActionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A73FE6AE24A6F2DA8ABEE0 /* ActionRegistry.swift */; }; + F28FB4C546827CA0FA725637 /* AppleContainerRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 213ABFE224215B7125B9886C /* AppleContainerRuntime.swift */; }; + F2D2B99B65AB1FE53D1F50B3 /* LocalActionRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9C9EAE88780456E8C8B364 /* LocalActionRegistryTests.swift */; }; + FEF006088F1D01FA8E6732F8 /* ContainerRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FC232381489660897638E0A /* ContainerRuntime.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + DFEF99439DA40CD98D7388B4 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 78904F42286EE3E52ED2700B /* Project object */; + proxyType = 1; + remoteGlobalIDString = 45DC64F4606392864F14C513; + remoteInfo = Perch; + }; + E5D46365D5E96F0582EC6787 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 78904F42286EE3E52ED2700B /* Project object */; + proxyType = 1; + remoteGlobalIDString = 52B1DA4402E23709587187B5; + remoteInfo = PerchExecutor; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 08A73FE6AE24A6F2DA8ABEE0 /* ActionRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionRegistry.swift; sourceTree = ""; }; + 0D5F20E37E12A8548D0B7D98 /* DanotchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DanotchApp.swift; sourceTree = ""; }; + 122F55C0A067BE4D40A4CAA0 /* EventRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventRoutingTests.swift; sourceTree = ""; }; + 14ECCFBC55DEC24A48A615F9 /* ContainerRuntimeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerRuntimeTests.swift; sourceTree = ""; }; + 15ED0FA8F02C21AB2D639682 /* DeviceIdentityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIdentityStore.swift; sourceTree = ""; }; + 213ABFE224215B7125B9886C /* AppleContainerRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleContainerRuntime.swift; sourceTree = ""; }; + 30C9182F0602B11A7E616D46 /* DeviceConnectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceConnectionTests.swift; sourceTree = ""; }; + 32C35B01F3F77AEE8A323D9C /* WidgetGridLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetGridLayoutTests.swift; sourceTree = ""; }; + 3673D29BC29340ACA6A5FEEC /* StatsPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsPanel.swift; sourceTree = ""; }; + 3AD6D943C90B1E993DA4C762 /* SecureSessionStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureSessionStoreTests.swift; sourceTree = ""; }; + 40E22FEEC4F60A4410C27147 /* PerchExecutorMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerchExecutorMain.swift; sourceTree = ""; }; + 41A89F87AACBC99775845E16 /* NotchShellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchShellView.swift; sourceTree = ""; }; + 41FB747AE3B5930FBDDF39B4 /* ExecutionConsentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutionConsentTests.swift; sourceTree = ""; }; + 48AEF3171CD944CBFACDA4EE /* SecureSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureSessionStore.swift; sourceTree = ""; }; + 4C2872E075CA335C1E27F246 /* LocalConversationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalConversationStore.swift; sourceTree = ""; }; + 4EFF078773EFED5FF63D6F21 /* ExecutorIPCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutorIPCTests.swift; sourceTree = ""; }; + 57D930460BDFCD3421AB028E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; + 5A53D411B5F98EFC03D036A2 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + 6783E9058D4E8E23EDA78A03 /* DeviceIdentityStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIdentityStoreTests.swift; sourceTree = ""; }; + 6D67146F158E3395AB4D9401 /* LocalConsentCardReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalConsentCardReducerTests.swift; sourceTree = ""; }; + 6ED5E9E8F1430038EA87E969 /* QuickPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickPromptView.swift; sourceTree = ""; }; + 70706D3088F1B4F1550349EF /* WorkspaceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSnapshot.swift; sourceTree = ""; }; + 73232B2D0524B3BD0BCA4CCB /* AccountDataStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDataStoreTests.swift; sourceTree = ""; }; + 77C72AD1B16631301B90801C /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = ""; }; + 7D08DFA84A7EF0E2E130C9DC /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; + 88C8C176AFAEDFAA91DF991A /* NotchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchViewModel.swift; sourceTree = ""; }; + 8C37F96EE3FA8AF8F7F59974 /* AccountDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDataStore.swift; sourceTree = ""; }; + 8D72E9E88B30FD87A4778631 /* PerchExecutor */ = {isa = PBXFileReference; includeInIndex = 0; path = PerchExecutor; sourceTree = BUILT_PRODUCTS_DIR; }; + 94CBD39B4B5D970BAE688872 /* ExecutorRequestDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutorRequestDecoder.swift; sourceTree = ""; }; + 9826B3FDC99CC70B12884186 /* ExecutionConsent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutionConsent.swift; sourceTree = ""; }; + 9A980F7457ECDD32889F7D18 /* ExecutorProcessBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutorProcessBackend.swift; sourceTree = ""; }; + 9FC232381489660897638E0A /* ContainerRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerRuntime.swift; sourceTree = ""; }; + B255F66F8DF0D15C8957F171 /* WidgetGridLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetGridLayout.swift; sourceTree = ""; }; + B70770D781EBB1DCF7ABB4A0 /* WorkspaceAccessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceAccessTests.swift; sourceTree = ""; }; + B75D37099FAE68A947F821C5 /* WorkspaceAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceAccess.swift; sourceTree = ""; }; + B85A45E74A903332227D8CF6 /* AgentChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatView.swift; sourceTree = ""; }; + B9C11CB6F201B9372EA0827B /* PerchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PerchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + BF47ACFA1D42B8668C14E47D /* ExecutionResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutionResult.swift; sourceTree = ""; }; + BF9C9EAE88780456E8C8B364 /* LocalActionRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalActionRegistryTests.swift; sourceTree = ""; }; + C1E516BC8454168BB2E8CB86 /* ExecutorIPC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutorIPC.swift; sourceTree = ""; }; + CC45EA89D779025968C1235C /* NotchContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchContentView.swift; sourceTree = ""; }; + CD36A0530BCF7787FECDFB57 /* AgentMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitor.swift; sourceTree = ""; }; + D41D4D3B908363F711572982 /* Perch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Perch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DB13D7044CA90122C7E13B94 /* ChatModelSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModelSelectorView.swift; sourceTree = ""; }; + E5BEA6CFAF492E12C2AF3FC1 /* NotchWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchWindow.swift; sourceTree = ""; }; + EE0B9F6EF185962F5CAA6B95 /* ExecutorArtifacts.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ExecutorArtifacts.json; sourceTree = ""; }; + F121A693299E0E7DD0531DD0 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + F240DE87B67BFD139854D139 /* DeviceConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceConnection.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 3076AF115C1F7B6F869A3DE8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5D4A8F62CB3B3DF6C86177C0 /* Containerization in Frameworks */, + 8BD78C5B7847AB2A1B10C90C /* ContainerizationArchive in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 147D4C40D8E79427AA9F17F4 /* Views */ = { + isa = PBXGroup; + children = ( + B85A45E74A903332227D8CF6 /* AgentChatView.swift */, + DB13D7044CA90122C7E13B94 /* ChatModelSelectorView.swift */, + CC45EA89D779025968C1235C /* NotchContentView.swift */, + 41A89F87AACBC99775845E16 /* NotchShellView.swift */, + 57D930460BDFCD3421AB028E /* OnboardingView.swift */, + 6ED5E9E8F1430038EA87E969 /* QuickPromptView.swift */, + 3673D29BC29340ACA6A5FEEC /* StatsPanel.swift */, + ); + path = Views; + sourceTree = ""; + }; + 14E19106BF698A38A911EF68 /* Executor */ = { + isa = PBXGroup; + children = ( + 08A73FE6AE24A6F2DA8ABEE0 /* ActionRegistry.swift */, + 9FC232381489660897638E0A /* ContainerRuntime.swift */, + 9826B3FDC99CC70B12884186 /* ExecutionConsent.swift */, + BF47ACFA1D42B8668C14E47D /* ExecutionResult.swift */, + C1E516BC8454168BB2E8CB86 /* ExecutorIPC.swift */, + B75D37099FAE68A947F821C5 /* WorkspaceAccess.swift */, + 70706D3088F1B4F1550349EF /* WorkspaceSnapshot.swift */, + ); + path = Executor; + sourceTree = ""; + }; + 3FEF4DB920A69CEA9D73DFA8 /* Sources */ = { + isa = PBXGroup; + children = ( + 8C37F96EE3FA8AF8F7F59974 /* AccountDataStore.swift */, + CD36A0530BCF7787FECDFB57 /* AgentMonitor.swift */, + 7D08DFA84A7EF0E2E130C9DC /* AuthManager.swift */, + 0D5F20E37E12A8548D0B7D98 /* DanotchApp.swift */, + F240DE87B67BFD139854D139 /* DeviceConnection.swift */, + 15ED0FA8F02C21AB2D639682 /* DeviceIdentityStore.swift */, + 9A980F7457ECDD32889F7D18 /* ExecutorProcessBackend.swift */, + 4C2872E075CA335C1E27F246 /* LocalConversationStore.swift */, + F121A693299E0E7DD0531DD0 /* Models.swift */, + 88C8C176AFAEDFAA91DF991A /* NotchViewModel.swift */, + E5BEA6CFAF492E12C2AF3FC1 /* NotchWindow.swift */, + 48AEF3171CD944CBFACDA4EE /* SecureSessionStore.swift */, + 5A53D411B5F98EFC03D036A2 /* Theme.swift */, + B255F66F8DF0D15C8957F171 /* WidgetGridLayout.swift */, + 14E19106BF698A38A911EF68 /* Executor */, + 147D4C40D8E79427AA9F17F4 /* Views */, + ); + path = Sources; + sourceTree = ""; + }; + 474ABDFE91153055DD6DB779 /* Resources */ = { + isa = PBXGroup; + children = ( + 77C72AD1B16631301B90801C /* AppIcon.icns */, + EE0B9F6EF185962F5CAA6B95 /* ExecutorArtifacts.json */, + ); + path = Resources; + sourceTree = ""; + }; + 5171C2A138335399233E61C6 /* ExecutorService */ = { + isa = PBXGroup; + children = ( + 213ABFE224215B7125B9886C /* AppleContainerRuntime.swift */, + 94CBD39B4B5D970BAE688872 /* ExecutorRequestDecoder.swift */, + 40E22FEEC4F60A4410C27147 /* PerchExecutorMain.swift */, + ); + name = ExecutorService; + path = Sources/ExecutorService; + sourceTree = ""; + }; + 5604ACE44E439FA92A920531 /* Products */ = { + isa = PBXGroup; + children = ( + D41D4D3B908363F711572982 /* Perch.app */, + 8D72E9E88B30FD87A4778631 /* PerchExecutor */, + B9C11CB6F201B9372EA0827B /* PerchTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + DEEF9E100A7290E48476D7A0 /* PerchTests */ = { + isa = PBXGroup; + children = ( + 73232B2D0524B3BD0BCA4CCB /* AccountDataStoreTests.swift */, + 14ECCFBC55DEC24A48A615F9 /* ContainerRuntimeTests.swift */, + 30C9182F0602B11A7E616D46 /* DeviceConnectionTests.swift */, + 6783E9058D4E8E23EDA78A03 /* DeviceIdentityStoreTests.swift */, + 122F55C0A067BE4D40A4CAA0 /* EventRoutingTests.swift */, + 41FB747AE3B5930FBDDF39B4 /* ExecutionConsentTests.swift */, + 4EFF078773EFED5FF63D6F21 /* ExecutorIPCTests.swift */, + BF9C9EAE88780456E8C8B364 /* LocalActionRegistryTests.swift */, + 6D67146F158E3395AB4D9401 /* LocalConsentCardReducerTests.swift */, + 3AD6D943C90B1E993DA4C762 /* SecureSessionStoreTests.swift */, + 32C35B01F3F77AEE8A323D9C /* WidgetGridLayoutTests.swift */, + B70770D781EBB1DCF7ABB4A0 /* WorkspaceAccessTests.swift */, + ); + name = PerchTests; + path = Tests/PerchTests; + sourceTree = ""; + }; + E2FE76835BB9FBCADB65C38C = { + isa = PBXGroup; + children = ( + 5171C2A138335399233E61C6 /* ExecutorService */, + DEEF9E100A7290E48476D7A0 /* PerchTests */, + 474ABDFE91153055DD6DB779 /* Resources */, + 3FEF4DB920A69CEA9D73DFA8 /* Sources */, + 5604ACE44E439FA92A920531 /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 45DC64F4606392864F14C513 /* Perch */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4E7BAC100ADA994FCF785195 /* Build configuration list for PBXNativeTarget "Perch" */; + buildPhases = ( + D1A1C77C580B3DEA6A464FB2 /* Sources */, + 67BFB14C0DAE62C764806509 /* Embed verified executor helper */, + 2C8B29F9A60922A813480135 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + D457DACDE5634BBE7D195F88 /* PBXTargetDependency */, + ); + name = Perch; + packageProductDependencies = ( + ); + productName = Perch; + productReference = D41D4D3B908363F711572982 /* Perch.app */; + productType = "com.apple.product-type.application"; + }; + 52B1DA4402E23709587187B5 /* PerchExecutor */ = { + isa = PBXNativeTarget; + buildConfigurationList = 46AF58177FCE7FB7DECCF01E /* Build configuration list for PBXNativeTarget "PerchExecutor" */; + buildPhases = ( + CE77430856673D3D967B4C00 /* Sources */, + 3076AF115C1F7B6F869A3DE8 /* Frameworks */, + BB02F9E4D319D8D2EA7DC6C2 /* Copy signed executor artifact manifest */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PerchExecutor; + packageProductDependencies = ( + 4E11B04CEE18B5E142F3BD3F /* Containerization */, + CB3F58CE30F386A5B1E24EC6 /* ContainerizationArchive */, + ); + productName = PerchExecutor; + productReference = 8D72E9E88B30FD87A4778631 /* PerchExecutor */; + productType = "com.apple.product-type.tool"; + }; + 687C8F85AD19D926995FCD41 /* PerchTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = DC552300FFCE4F7CB53BAB19 /* Build configuration list for PBXNativeTarget "PerchTests" */; + buildPhases = ( + A13B18044C6A90B9BC952565 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 2310E3FBF3402F68F5F9A94E /* PBXTargetDependency */, + ); + name = PerchTests; + packageProductDependencies = ( + ); + productName = PerchTests; + productReference = B9C11CB6F201B9372EA0827B /* PerchTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 78904F42286EE3E52ED2700B /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 45DC64F4606392864F14C513 = { + ProvisioningStyle = Automatic; + }; + 52B1DA4402E23709587187B5 = { + ProvisioningStyle = Automatic; + }; + 687C8F85AD19D926995FCD41 = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = E6AE955975C9C0B42C636FA3 /* Build configuration list for PBXProject "Perch" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = E2FE76835BB9FBCADB65C38C; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 2DAAD996092750194CDDFA37 /* XCRemoteSwiftPackageReference "containerization" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 5604ACE44E439FA92A920531 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 45DC64F4606392864F14C513 /* Perch */, + 52B1DA4402E23709587187B5 /* PerchExecutor */, + 687C8F85AD19D926995FCD41 /* PerchTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 2C8B29F9A60922A813480135 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A49E311936B4DA5FCF94FFC2 /* AppIcon.icns in Resources */, + 695666704F8AE718CE432090 /* ExecutorArtifacts.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 67BFB14C0DAE62C764806509 /* Embed verified executor helper */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(BUILT_PRODUCTS_DIR)/PerchExecutor", + ); + name = "Embed verified executor helper"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(CONTENTS_FOLDER_PATH)/Helpers/PerchExecutor", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "mkdir -p \"${TARGET_BUILD_DIR}/${CONTENTS_FOLDER_PATH}/Helpers\"\ncp \"${BUILT_PRODUCTS_DIR}/PerchExecutor\" \"${TARGET_BUILD_DIR}/${CONTENTS_FOLDER_PATH}/Helpers/PerchExecutor\"\n"; + }; + BB02F9E4D319D8D2EA7DC6C2 /* Copy signed executor artifact manifest */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/Resources/ExecutorArtifacts.json", + ); + name = "Copy signed executor artifact manifest"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(BUILT_PRODUCTS_DIR)/ExecutorArtifacts.json", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "cp \"${SRCROOT}/Resources/ExecutorArtifacts.json\" \"${BUILT_PRODUCTS_DIR}/ExecutorArtifacts.json\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A13B18044C6A90B9BC952565 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D0B1684BE2DF5BEE45870406 /* AccountDataStoreTests.swift in Sources */, + 39AE20D72BCD96C9D304F35C /* ContainerRuntimeTests.swift in Sources */, + 02003229CB8E9BC14F232CA9 /* DeviceConnectionTests.swift in Sources */, + 280B1575A79B03D7D3CC1E58 /* DeviceIdentityStoreTests.swift in Sources */, + 26FEA560E393F0F93B94B269 /* EventRoutingTests.swift in Sources */, + AEABBB87A84549C7A8484BA7 /* ExecutionConsentTests.swift in Sources */, + 69064A53E5B481A52E81B871 /* ExecutorIPCTests.swift in Sources */, + F2D2B99B65AB1FE53D1F50B3 /* LocalActionRegistryTests.swift in Sources */, + 77C7A14117319CE042F87F4F /* LocalConsentCardReducerTests.swift in Sources */, + 8266E53D85A10FD661F39A8E /* SecureSessionStoreTests.swift in Sources */, + 4229A22674D7E54A5F554951 /* WidgetGridLayoutTests.swift in Sources */, + C69C4082956F5ABFFFAFDB10 /* WorkspaceAccessTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CE77430856673D3D967B4C00 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1B92C7A52A1AF404F33F200C /* ActionRegistry.swift in Sources */, + F28FB4C546827CA0FA725637 /* AppleContainerRuntime.swift in Sources */, + FEF006088F1D01FA8E6732F8 /* ContainerRuntime.swift in Sources */, + A0451EAB404ECD4E17C1D5E4 /* ExecutionConsent.swift in Sources */, + D8924CBF31F6B2D175A67DCD /* ExecutionResult.swift in Sources */, + CD8CE4D7FAF5D128940B2E93 /* ExecutorIPC.swift in Sources */, + B7A700B818BBB2F246CCF321 /* ExecutorRequestDecoder.swift in Sources */, + 787CDB29843DB16DEB3874C2 /* PerchExecutorMain.swift in Sources */, + 19D779627DD4ACA3E933B014 /* WorkspaceAccess.swift in Sources */, + D9058BF0F095A5813980C3D4 /* WorkspaceSnapshot.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D1A1C77C580B3DEA6A464FB2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 40A97DF3462396246B11BF89 /* AccountDataStore.swift in Sources */, + ED4131EE697E1DBCC8ACBF1F /* ActionRegistry.swift in Sources */, + 796FF93C5E96BE28E4856175 /* AgentChatView.swift in Sources */, + 041D6492A54FFBF8177212B5 /* AgentMonitor.swift in Sources */, + BAAF33091609D96F0B2B57F7 /* AuthManager.swift in Sources */, + 7F3C4F1C45BAAC53795E4944 /* ChatModelSelectorView.swift in Sources */, + 68D2DA318BAD2E4B136FB2F4 /* ContainerRuntime.swift in Sources */, + 7366286B56152360C882D422 /* DanotchApp.swift in Sources */, + 862FC1D030D50E8E4BDC0E45 /* DeviceConnection.swift in Sources */, + 503459F6A4C76206C6B64ED5 /* DeviceIdentityStore.swift in Sources */, + 7F4F188F0E940677F5B0F13F /* ExecutionConsent.swift in Sources */, + D118AF26FA1E3D7DF6505CC4 /* ExecutionResult.swift in Sources */, + 6FA4BD62A47461BE7BD5979B /* ExecutorIPC.swift in Sources */, + B2233E63D8BC743A45C917B4 /* ExecutorProcessBackend.swift in Sources */, + DFA1E0CCA5B7021B0866F656 /* LocalConversationStore.swift in Sources */, + E99AB4F40359E41FBFD95CFF /* Models.swift in Sources */, + 5992CB9813874354EA326F4E /* NotchContentView.swift in Sources */, + 272C133C76CD8DA5C3C59A6C /* NotchShellView.swift in Sources */, + 7ACE70331ED2A3D531D45BAF /* NotchViewModel.swift in Sources */, + BB97624BDF7E1CC20A6216ED /* NotchWindow.swift in Sources */, + 7F070433B11120EB3B10C1D1 /* OnboardingView.swift in Sources */, + 95036D7A49EEB51EF0A1C8B7 /* QuickPromptView.swift in Sources */, + 771A08BBC44DE7958AED668A /* SecureSessionStore.swift in Sources */, + 10652F2018BE96A44BCDA245 /* StatsPanel.swift in Sources */, + 18858C8BF86CEFD40768FE42 /* Theme.swift in Sources */, + 10721005DEB1B3ED613879F6 /* WidgetGridLayout.swift in Sources */, + BD715D317EA82B160EB091F9 /* WorkspaceAccess.swift in Sources */, + 810342C22CAD6B6A40A4DCA3 /* WorkspaceSnapshot.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 2310E3FBF3402F68F5F9A94E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 45DC64F4606392864F14C513 /* Perch */; + targetProxy = DFEF99439DA40CD98D7388B4 /* PBXContainerItemProxy */; + }; + D457DACDE5634BBE7D195F88 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 52B1DA4402E23709587187B5 /* PerchExecutor */; + targetProxy = E5D46365D5E96F0582EC6787 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 0F6AD60D2124FA67F1FB6499 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 204E4DAFE4A978BD53C4066A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Perch.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "Perch accesses Apple Music only to display and control the track you are playing."; + INFOPLIST_KEY_NSHighResolutionCapable = YES; + INFOPLIST_KEY_PerchAPIBaseURL = "https://api.perch.example"; + INFOPLIST_KEY_PerchDeviceGatewayURL = "wss://api.perch.example/api/device-gateway"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = engineering.super.Perch; + PRODUCT_NAME = Perch; + SDKROOT = macosx; + }; + name = Debug; + }; + 99C45836D2F1D4E548E783B4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + 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; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C3127196C4E56D1F26C4CECC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + CODE_SIGN_ENTITLEMENTS = Executor.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 26.0; + ONLY_ACTIVE_ARCH = NO; + PRODUCT_BUNDLE_IDENTIFIER = engineering.super.Perch.Executor; + PRODUCT_NAME = PerchExecutor; + SDKROOT = macosx; + }; + name = Release; + }; + D1903F6D554C0A35CF0EE279 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = engineering.super.PerchTests; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Perch.app/Contents/MacOS/Perch"; + }; + name = Release; + }; + D7328AA565AA95B269F8EE6F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = engineering.super.PerchTests; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Perch.app/Contents/MacOS/Perch"; + }; + name = Debug; + }; + D7DD421E0B1C63821E8E01AD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ARCHS = arm64; + CODE_SIGN_ENTITLEMENTS = Executor.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 26.0; + ONLY_ACTIVE_ARCH = NO; + PRODUCT_BUNDLE_IDENTIFIER = engineering.super.Perch.Executor; + PRODUCT_NAME = PerchExecutor; + SDKROOT = macosx; + }; + name = Debug; + }; + DD1BC7C1BF042D41AC16AD92 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Perch.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "Perch accesses Apple Music only to display and control the track you are playing."; + INFOPLIST_KEY_NSHighResolutionCapable = YES; + INFOPLIST_KEY_PerchAPIBaseURL = "https://api.perch.example"; + INFOPLIST_KEY_PerchDeviceGatewayURL = "wss://api.perch.example/api/device-gateway"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = engineering.super.Perch; + PRODUCT_NAME = Perch; + SDKROOT = macosx; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 46AF58177FCE7FB7DECCF01E /* Build configuration list for PBXNativeTarget "PerchExecutor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D7DD421E0B1C63821E8E01AD /* Debug */, + C3127196C4E56D1F26C4CECC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 4E7BAC100ADA994FCF785195 /* Build configuration list for PBXNativeTarget "Perch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 204E4DAFE4A978BD53C4066A /* Debug */, + DD1BC7C1BF042D41AC16AD92 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + DC552300FFCE4F7CB53BAB19 /* Build configuration list for PBXNativeTarget "PerchTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D7328AA565AA95B269F8EE6F /* Debug */, + D1903F6D554C0A35CF0EE279 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + E6AE955975C9C0B42C636FA3 /* Build configuration list for PBXProject "Perch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 99C45836D2F1D4E548E783B4 /* Debug */, + 0F6AD60D2124FA67F1FB6499 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 2DAAD996092750194CDDFA37 /* XCRemoteSwiftPackageReference "containerization" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/containerization.git"; + requirement = { + kind = exactVersion; + version = 0.33.3; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4E11B04CEE18B5E142F3BD3F /* Containerization */ = { + isa = XCSwiftPackageProductDependency; + package = 2DAAD996092750194CDDFA37 /* XCRemoteSwiftPackageReference "containerization" */; + productName = Containerization; + }; + CB3F58CE30F386A5B1E24EC6 /* ContainerizationArchive */ = { + isa = XCSwiftPackageProductDependency; + package = 2DAAD996092750194CDDFA37 /* XCRemoteSwiftPackageReference "containerization" */; + productName = ContainerizationArchive; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 78904F42286EE3E52ED2700B /* Project object */; +} diff --git a/app/Perch.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/Perch.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/app/Perch.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/app/Perch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/app/Perch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..10cd871 --- /dev/null +++ b/app/Perch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,267 @@ +{ + "originHash" : "6562cbf9a03043806130d3680765e19d05a7c86ac3afb3b07951fba1ffb78d45", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "4603a8036d921ea999fadb742931546c341f4bd7", + "version" : "1.35.0" + } + }, + { + "identity" : "containerization", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/containerization.git", + "state" : { + "revision" : "a2a1add6c7e1a1665e5397edc49d925c49090b3a", + "version" : "0.33.3" + } + }, + { + "identity" : "grpc-swift-2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-2.git", + "state" : { + "revision" : "28cdd63ef88583ddc67d7bb179eab46fab465ce9", + "version" : "2.4.2" + } + }, + { + "identity" : "grpc-swift-nio-transport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-nio-transport.git", + "state" : { + "revision" : "2ca31f06658ed288a2560e23ad649acbb3d6b3a3", + "version" : "2.9.0" + } + }, + { + "identity" : "grpc-swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-protobuf.git", + "state" : { + "revision" : "176c5a434fd76f6f479848d1a8f7d44967534168", + "version" : "2.4.1" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "89fbc3714264cce8db8e4ec51b64e01c3e28c6c5", + "version" : "1.19.3" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "db774a277f60063a32d854f2980299caf06da041", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a", + "version" : "1.14.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "0b18836bd8b0162e7e17a995a3fbee20ed8f3b2b", + "version" : "2.101.3" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "88a51340f59cf181ebde888bd1b749296b3ec029", + "version" : "1.34.3" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", + "version" : "1.44.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "d930168b86f46ca51a4bc09c5ca45c1833db8067", + "version" : "2.37.2" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8", + "version" : "1.38.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "b5544ba79a70a0cb3563e75bf26dc198d6b40ed3", + "version" : "1.7.4" + } + }, + { + "identity" : "zstd", + "kind" : "remoteSourceControl", + "location" : "https://github.com/facebook/zstd.git", + "state" : { + "revision" : "f8745da6ff1ad1e7bab384bd1f9d742439278e99", + "version" : "1.5.7" + } + } + ], + "version" : 3 +} diff --git a/app/Perch.xcodeproj/xcshareddata/xcschemes/Perch.xcscheme b/app/Perch.xcodeproj/xcshareddata/xcschemes/Perch.xcscheme new file mode 100644 index 0000000..1a95af2 --- /dev/null +++ b/app/Perch.xcodeproj/xcshareddata/xcschemes/Perch.xcscheme @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/Perch.xcodeproj/xcshareddata/xcschemes/PerchExecutor.xcscheme b/app/Perch.xcodeproj/xcshareddata/xcschemes/PerchExecutor.xcscheme new file mode 100644 index 0000000..8934744 --- /dev/null +++ b/app/Perch.xcodeproj/xcshareddata/xcschemes/PerchExecutor.xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/Resources/ExecutorArtifacts.json b/app/Resources/ExecutorArtifacts.json new file mode 100644 index 0000000..2cfe454 --- /dev/null +++ b/app/Resources/ExecutorArtifacts.json @@ -0,0 +1,6 @@ +{ + "algorithm": "P256-SHA256-DER", + "key_id": "perch-executor-artifacts-2026-01", + "signed_payload": "eyJhcmNoaXRlY3R1cmUiOiJhcm02NCIsImFydGlmYWN0cyI6W3siYnl0ZXMiOjI5MDU4MTY0OCwia2luZCI6Imtlcm5lbEFyY2hpdmUiLCJyZWZlcmVuY2UiOiJodHRwczovL2dpdGh1Yi5jb20va2F0YS1jb250YWluZXJzL2thdGEtY29udGFpbmVycy9yZWxlYXNlcy9kb3dubG9hZC8zLjE3LjAva2F0YS1zdGF0aWMtMy4xNy4wLWFybTY0LnRhci54eiIsInNoYTI1NiI6IjY0N2M3NjEyZTZlZGY3ODlkNWUxNDY5OGM0OGM5OWQ4YmFjMTVhZDEzOWZmYWExYzhiYjdkMjI5Zjc0OGQxODEiLCJzb3VyY2Vfc2hhMjU2IjpudWxsfSx7ImJ5dGVzIjoxNDc1MDIwOCwia2luZCI6Imtlcm5lbCIsInJlZmVyZW5jZSI6Imh0dHBzOi8vZ2l0aHViLmNvbS9rYXRhLWNvbnRhaW5lcnMva2F0YS1jb250YWluZXJzL3JlbGVhc2VzL2Rvd25sb2FkLzMuMTcuMC9rYXRhLXN0YXRpYy0zLjE3LjAtYXJtNjQudGFyLnh6I29wdC9rYXRhL3NoYXJlL2thdGEtY29udGFpbmVycy92bWxpbnV4LTYuMTIuMjgtMTUzIiwic2hhMjU2IjoiNjdiYWM5ZjQxNmFmNGNkYzliMTUxZTRiYTQ5NjJkNjUxNWUwYWQ3YWNjNTM4MTY3NjFjZjk2NGFhNmFmNmVhMCIsInNvdXJjZV9zaGEyNTYiOiI2NDdjNzYxMmU2ZWRmNzg5ZDVlMTQ2OThjNDhjOTlkOGJhYzE1YWQxMzlmZmFhMWM4YmI3ZDIyOWY3NDhkMTgxIn0seyJieXRlcyI6bnVsbCwia2luZCI6ImluaXRJbWFnZSIsInJlZmVyZW5jZSI6ImdoY3IuaW8vYXBwbGUvY29udGFpbmVyaXphdGlvbi92bWluaXRAc2hhMjU2OjdkOTIzMWJlMTg2M2MyODliYTUyMjM2M2I1MDY5YTVjMDczYzYyZjM0ZWIyNDA3OTdiMGZhMjg5YzA5Y2M5NTIiLCJzaGEyNTYiOiI3ZDkyMzFiZTE4NjNjMjg5YmE1MjIzNjNiNTA2OWE1YzA3M2M2MmYzNGViMjQwNzk3YjBmYTI4OWMwOWNjOTUyIiwic291cmNlX3NoYTI1NiI6bnVsbH0seyJieXRlcyI6bnVsbCwia2luZCI6Indvcmtsb2FkSW1hZ2UiLCJyZWZlcmVuY2UiOiJnaGNyLmlvL2xpbnV4Y29udGFpbmVycy9hbHBpbmVAc2hhMjU2OjI4YTMzZTAzOTBkYTJiMzQxZDA1ZTUzODAzYzY3NjY5NzcwMzliYWVkZmM4YTgyYTQ5MTBkNWFjYzQ3NTViNjYiLCJzaGEyNTYiOiIyOGEzM2UwMzkwZGEyYjM0MWQwNWU1MzgwM2M2NzY2OTc3MDM5YmFlZGZjOGE4MmE0OTEwZDVhY2M0NzU1YjY2Iiwic291cmNlX3NoYTI1NiI6bnVsbH1dLCJjb250YWluZXJpemF0aW9uX2NvbW1pdCI6ImEyYTFhZGQ2YzdlMWExNjY1ZTUzOTdlZGM0OWQ5MjVjNDkwOTBiM2EiLCJjb250YWluZXJpemF0aW9uX3ZlcnNpb24iOiIwLjMzLjMiLCJtaW5pbXVtX29zIjoiMjYuMCIsInNjaGVtYV92ZXJzaW9uIjoxfQ==", + "signature": "" +} diff --git a/app/Sources/AccountDataStore.swift b/app/Sources/AccountDataStore.swift new file mode 100644 index 0000000..3cbb054 --- /dev/null +++ b/app/Sources/AccountDataStore.swift @@ -0,0 +1,201 @@ +import Foundation + +enum AccountDataError: Error, Equatable { + case invalidUserID + case accountMismatch + case verificationFailed +} + +struct DeviceAccountState: Codable, Equatable { + var deviceID: String? + var deviceKeyAlgorithm: String? + var cursor: Int + var processedTransitionIDs: [String] + var pendingResults: [PendingDeviceResult] + + static let empty = DeviceAccountState( + deviceID: nil, + deviceKeyAlgorithm: nil, + cursor: 0, + processedTransitionIDs: [], + pendingResults: [] + ) + + init( + deviceID: String?, + deviceKeyAlgorithm: String? = nil, + cursor: Int, + processedTransitionIDs: [String], + pendingResults: [PendingDeviceResult] = [] + ) { + self.deviceID = deviceID + self.deviceKeyAlgorithm = deviceKeyAlgorithm + self.cursor = cursor + self.processedTransitionIDs = processedTransitionIDs + self.pendingResults = pendingResults + } + + enum CodingKeys: String, CodingKey { + case deviceID, deviceKeyAlgorithm, cursor, processedTransitionIDs, pendingResults + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + deviceID = try values.decodeIfPresent(String.self, forKey: .deviceID) + deviceKeyAlgorithm = try values.decodeIfPresent(String.self, forKey: .deviceKeyAlgorithm) + cursor = try values.decodeIfPresent(Int.self, forKey: .cursor) ?? 0 + processedTransitionIDs = try values.decodeIfPresent( + [String].self, + forKey: .processedTransitionIDs + ) ?? [] + pendingResults = try values.decodeIfPresent( + [PendingDeviceResult].self, + forKey: .pendingResults + ) ?? [] + } +} + +struct PendingDeviceResult: Codable, Equatable { + let resultID: String + let actionID: String + let grantID: String + let status: String + let resultJSON: Data +} + +final class AccountDataStore { + private let fileManager: FileManager + let rootURL: URL + + init( + rootURL: URL? = nil, + fileManager: FileManager = .default + ) { + self.fileManager = fileManager + self.rootURL = rootURL ?? fileManager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0].appendingPathComponent("Perch", isDirectory: true) + } + + func accountDirectory(for userID: String) throws -> URL { + guard isValidUserID(userID) else { throw AccountDataError.invalidUserID } + let directory = rootURL + .appendingPathComponent("Accounts", isDirectory: true) + .appendingPathComponent(userID, isDirectory: true) + try secureDirectory(directory) + return directory + } + + func conversationsURL(for userID: String) throws -> URL { + try accountDirectory(for: userID).appendingPathComponent("conversations.json") + } + + func stateURL(for userID: String) throws -> URL { + try accountDirectory(for: userID).appendingPathComponent("device-state.json") + } + + func loadDeviceState(for userID: String) throws -> DeviceAccountState { + let url = try stateURL(for: userID) + guard fileManager.fileExists(atPath: url.path) else { return .empty } + return try JSONDecoder().decode(DeviceAccountState.self, from: Data(contentsOf: url)) + } + + func saveDeviceState(_ state: DeviceAccountState, for userID: String) throws { + try writeSecurely(JSONEncoder().encode(state), to: stateURL(for: userID)) + guard try loadDeviceState(for: userID) == state else { + throw AccountDataError.verificationFailed + } + } + + /// Imports legacy plaintext only after a freshly authenticated session + /// proves ownership. Destination verification precedes deletion, making + /// interruption safe and retries idempotent. + func migrateLegacyData( + activeSession: AuthSession, + legacyDirectory: URL? = nil, + sessionStore: SecureSessionStore + ) throws { + let legacy = legacyDirectory ?? fileManager.homeDirectoryForCurrentUser + .appendingPathComponent(".danotch", isDirectory: true) + let authURL = legacy.appendingPathComponent("auth.json") + guard fileManager.fileExists(atPath: authURL.path) else { return } + let legacySession = try JSONDecoder().decode(AuthSession.self, from: Data(contentsOf: authURL)) + guard legacySession.userId == activeSession.userId else { + throw AccountDataError.accountMismatch + } + guard try sessionStore.load().userId == activeSession.userId else { + throw AccountDataError.accountMismatch + } + + let legacyConversations = legacy.appendingPathComponent("conversations.json") + let destination = try conversationsURL(for: activeSession.userId) + if fileManager.fileExists(atPath: legacyConversations.path) { + let sourceData = try Data(contentsOf: legacyConversations) + if fileManager.fileExists(atPath: destination.path) { + let merged = try mergeConversationFiles( + destination: Data(contentsOf: destination), + legacy: sourceData + ) + try writeSecurely(merged, to: destination) + } else { + try writeSecurely(sourceData, to: destination) + } + guard fileManager.fileExists(atPath: destination.path), + !(try Data(contentsOf: destination)).isEmpty else { + throw AccountDataError.verificationFailed + } + try fileManager.removeItem(at: legacyConversations) + } + + // Tokens are already verified in the Data Protection Keychain. + try fileManager.removeItem(at: authURL) + } + + func writeSecurely(_ data: Data, to url: URL) throws { + try secureDirectory(url.deletingLastPathComponent()) + try data.write(to: url, options: [.atomic]) + try fileManager.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o600))], + ofItemAtPath: url.path + ) + } + + private func secureDirectory(_ url: URL) throws { + try fileManager.createDirectory( + at: url, + withIntermediateDirectories: true, + attributes: [.posixPermissions: NSNumber(value: Int16(0o700))] + ) + try fileManager.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o700))], + ofItemAtPath: url.path + ) + } + + private func isValidUserID(_ value: String) -> Bool { + !value.isEmpty + && value.count <= 128 + && value.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil + } + + private func mergeConversationFiles(destination: Data, legacy: Data) throws -> Data { + struct GenericStore: Codable { + var conversations: [LocalConversationRecord] + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let current = try decoder.decode(GenericStore.self, from: destination) + let old = try decoder.decode(GenericStore.self, from: legacy) + var byID = Dictionary(uniqueKeysWithValues: current.conversations.map { ($0.id, $0) }) + for record in old.conversations where byID[record.id] == nil { + byID[record.id] = record + } + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(GenericStore( + conversations: byID.values.sorted { $0.updatedAt > $1.updatedAt } + )) + } +} diff --git a/app/Sources/AuthManager.swift b/app/Sources/AuthManager.swift index dfa49d2..752aca8 100644 --- a/app/Sources/AuthManager.swift +++ b/app/Sources/AuthManager.swift @@ -1,7 +1,17 @@ import Foundation import SwiftUI +import AppKit + +enum AuthLifecycleState: Equatable { + case credentials + case browserSignup + case checkEmail(String) + case verificationExpired(String) + case crossDeviceVerified(String) + case repairProvisioning +} -struct AuthSession: Codable { +struct AuthSession: Codable, Equatable { var accessToken: String var refreshToken: String var expiresAt: Int? @@ -17,9 +27,13 @@ class AuthManager: ObservableObject { @Published var isAuthenticated = false @Published var isLoading = false @Published var error: String? + @Published var lifecycleState: AuthLifecycleState = .credentials + + var onSessionWillChange: ((String?, String?) -> Void)? + var onSessionDidChange: ((AuthSession?) -> Void)? - private static let configDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".danotch") - private static let authFile = configDir.appendingPathComponent("auth.json") + private let sessionStore: SecureSessionStore + private let accountDataStore: AccountDataStore private var baseURL: String { APIConfig.baseURL } var userName: String { @@ -28,53 +42,34 @@ class AuthManager: ObservableObject { var accessToken: String? { session?.accessToken } - init() { + init( + sessionStore: SecureSessionStore = SecureSessionStore(), + accountDataStore: AccountDataStore = AccountDataStore() + ) { + self.sessionStore = sessionStore + self.accountDataStore = accountDataStore loadSession() } // MARK: - Signup func signup(email: String, password: String, fullName: String) async -> Bool { - await MainActor.run { isLoading = true; error = nil } - - let body: [String: String] = ["email": email, "password": password, "full_name": fullName] - guard let data = await post("/auth/signup", body: body) else { - await MainActor.run { isLoading = false } - return false + await MainActor.run { + isLoading = false + error = nil + lifecycleState = .browserSignup } - - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let sessionObj = json["session"] as? [String: Any], - let accessToken = sessionObj["access_token"] as? String, - let refreshToken = sessionObj["refresh_token"] as? String, - let userObj = json["user"] as? [String: Any], - let userId = userObj["id"] as? String else { - await MainActor.run { - self.error = "Signup failed" - isLoading = false - } + var components = URLComponents(string: baseURL + "/auth/signup/browser") + components?.queryItems = [URLQueryItem(name: "email", value: email)] + guard let url = components?.url else { + await MainActor.run { error = "Could not open secure signup." } return false } - - let email = (userObj["email"] as? String) ?? email - let name = (userObj["full_name"] as? String) ?? fullName - - let authSession = AuthSession( - accessToken: accessToken, - refreshToken: refreshToken, - expiresAt: sessionObj["expires_at"] as? Int, - userId: userId, - email: email, - fullName: name - ) - await MainActor.run { - self.session = authSession - self.isAuthenticated = true - self.isLoading = false + NSWorkspace.shared.open(url) + lifecycleState = .checkEmail(email) } - saveSession(authSession) - return true + return false } // MARK: - Login @@ -95,7 +90,7 @@ class AuthManager: ObservableObject { let userObj = json["user"] as? [String: Any], let userId = userObj["id"] as? String else { await MainActor.run { - self.error = "Login failed" + self.error = "Unable to sign in. Verify your email and try again." isLoading = false } return false @@ -110,21 +105,35 @@ class AuthManager: ObservableObject { fullName: (userObj["full_name"] as? String) ?? email.components(separatedBy: "@").first ?? "" ) - await MainActor.run { - self.session = authSession - self.isAuthenticated = true - self.isLoading = false + let established = await establishSession(authSession) + if established { + await MainActor.run { lifecycleState = .crossDeviceVerified(authSession.email) } } - saveSession(authSession) - return true + return established + } + + func reopenBrowserSignup(email: String) { + Task { _ = await signup(email: email, password: "", fullName: "") } + } + + func returnToSignIn(email: String) { + self.error = nil + lifecycleState = .credentials } // MARK: - Logout func logout() { + let oldUserID = session?.userId + onSessionWillChange?(oldUserID, nil) session = nil isAuthenticated = false - try? FileManager.default.removeItem(at: Self.authFile) + do { + try sessionStore.delete() + } catch { + self.error = "Could not remove credentials from Keychain." + } + onSessionDidChange?(nil) } // MARK: - Token Refresh @@ -174,36 +183,69 @@ class AuthManager: ObservableObject { fullName: session.fullName ) - await MainActor.run { - self.session = updated - self.isAuthenticated = true + do { + try sessionStore.rotate(from: session.userId, to: updated) + await MainActor.run { + guard self.session?.userId == session.userId else { return } + self.session = updated + self.isAuthenticated = true + self.onSessionDidChange?(updated) + } + } catch { + await MainActor.run { + self.error = "Credential rotation failed. Please sign in again." + self.logout() + } } - saveSession(updated) print("[AuthManager] Token refreshed successfully") } catch { print("[AuthManager] Refresh error: \(error.localizedDescription)") + await MainActor.run { + self.error = "Session refresh failed. Please sign in again." + self.logout() + } } } // MARK: - Persistence - private func saveSession(_ session: AuthSession) { + private func establishSession(_ newSession: AuthSession) async -> Bool { do { - try FileManager.default.createDirectory(at: Self.configDir, withIntermediateDirectories: true) - let data = try JSONEncoder().encode(session) - try data.write(to: Self.authFile) + let previousUserID = await MainActor.run { session?.userId } + await MainActor.run { onSessionWillChange?(previousUserID, newSession.userId) } + try sessionStore.save(newSession) + // Legacy import is allowed only after the matching session is + // durably present in Keychain. + try? accountDataStore.migrateLegacyData( + activeSession: newSession, + sessionStore: sessionStore + ) + await MainActor.run { + session = newSession + isAuthenticated = true + isLoading = false + onSessionDidChange?(newSession) + } + return true } catch { - print("[AuthManager] Failed to save session: \(error)") + await MainActor.run { + session = nil + isAuthenticated = false + isLoading = false + self.error = "Could not secure this session in Keychain." + onSessionDidChange?(nil) + } + return false } } private func loadSession() { - guard let data = try? Data(contentsOf: Self.authFile), - let session = try? JSONDecoder().decode(AuthSession.self, from: data) else { + guard let session = try? sessionStore.load() else { return } self.session = session self.isAuthenticated = true + onSessionDidChange?(session) // Refresh token on startup if needed Task { await ensureValidToken() } @@ -225,7 +267,17 @@ class AuthManager: ObservableObject { if let httpResponse, httpResponse.statusCode >= 400 { if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let errMsg = json["error"] as? String { - await MainActor.run { self.error = errMsg } + await MainActor.run { + self.error = errMsg + switch json["code"] as? String { + case "email_verification_required": + self.lifecycleState = .checkEmail("") + case "provisioning_retry_required": + self.lifecycleState = .repairProvisioning + default: + break + } + } } return nil } diff --git a/app/Sources/DanotchApp.swift b/app/Sources/DanotchApp.swift index e27d7fb..5ed5c43 100644 --- a/app/Sources/DanotchApp.swift +++ b/app/Sources/DanotchApp.swift @@ -18,17 +18,19 @@ class AppDelegate: NSObject, NSApplicationDelegate { var onboardingWindow: NSWindow? let viewModel = NotchViewModel() let auth = AuthManager.shared - var wsServer: WebSocketServer? func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) applyAppIcon() - viewModel.interruptInProgressConversations() - - wsServer = WebSocketServer(viewModel: viewModel) - wsServer?.start() - viewModel.authManager = auth + auth.onSessionWillChange = { [weak viewModel] _, _ in + viewModel?.switchAccount(to: nil) + } + auth.onSessionDidChange = { [weak viewModel] session in + viewModel?.switchAccount(to: session) + } + viewModel.switchAccount(to: auth.session) + viewModel.interruptInProgressConversations() if auth.isAuthenticated && OnboardingCompletionStore.isComplete { // Already logged in and fully onboarded — go straight to notch @@ -146,6 +148,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { viewModel.interruptInProgressConversations() - wsServer?.stop() + viewModel.cancelDeviceConnection() } } diff --git a/app/Sources/DeviceConnection.swift b/app/Sources/DeviceConnection.swift new file mode 100644 index 0000000..feffdb8 --- /dev/null +++ b/app/Sources/DeviceConnection.swift @@ -0,0 +1,1030 @@ +import AppKit +import CryptoKit +#if canImport(ExecutorCore) +import ExecutorCore +#endif +import Foundation +import Network + +enum DeviceConnectionState: Equatable { + case signedOut + case enrolling + case connecting(attempt: Int) + case connected(deviceID: String) + case offline(reason: String) + case expired + case revoked(reason: String) + case interrupted(reason: String) + case reenrollmentRequired(reason: String) + case unsupportedProtocol + case cancelled + + var announcement: String { + switch self { + case .signedOut: return "Device connection signed out." + case .enrolling: return "Enrolling this Mac." + case .connecting(let attempt): return "Connecting this Mac. Attempt \(attempt + 1)." + case .connected: return "This Mac is connected." + case .offline(let reason): return "Device connection offline. \(reason)" + case .expired: return "Device connection ticket expired. Retrying." + case .revoked(let reason): return "This Mac was revoked. \(reason)" + case .interrupted(let reason): return "Device connection interrupted. \(reason)" + case .reenrollmentRequired(let reason): return "This Mac must be enrolled again. \(reason)" + case .unsupportedProtocol: return "This version of Perch is not supported by the server." + case .cancelled: return "Device connection cancelled." + } + } +} + +struct DeviceChallengeResponse: Decodable { + let challengeID: String + let nonce: String + + enum CodingKeys: String, CodingKey { + case challengeID = "challenge_id" + case nonce + } +} + +struct EnrolledDeviceResponse: Decodable { + struct Device: Decodable { let id: String } + let device: Device +} + +struct DeviceTicketResponse: Decodable { + let ticket: String + let expiresAt: String + let protocolVersion: Int + + enum CodingKeys: String, CodingKey { + case ticket + case expiresAt = "expires_at" + case protocolVersion = "protocol_version" + } +} + +protocol DeviceConnectionNetwork { + func challenge(token: String, purpose: String, deviceID: String?) async throws -> DeviceChallengeResponse + func enroll( + token: String, + challengeID: String, + displayName: String, + publicKey: DevicePublicKey, + signature: String, + replacementDeviceID: String? + ) async throws -> EnrolledDeviceResponse + func ticket( + token: String, + deviceID: String, + challengeID: String, + signature: String, + versions: [Int] + ) async throws -> DeviceTicketResponse + func connect(ticket: String, protocolVersion: Int) async throws -> DeviceSocket +} + +protocol DeviceSocket: AnyObject { + func receive() async throws -> Data + func send(_ data: Data) async throws + func cancel() + var closeCode: Int { get } +} + +struct DeviceNetworkError: Error { + let status: Int + let code: String + let message: String +} + +final class URLSessionDeviceConnectionNetwork: DeviceConnectionNetwork { + private let baseURL: URL + private let gatewayURL: URL + private let session: URLSession + + init( + baseURL: URL = APIConfig.baseURLValue, + gatewayURL: URL = APIConfig.gatewayURLValue, + session: URLSession = .shared + ) { + self.baseURL = baseURL + self.gatewayURL = gatewayURL + self.session = session + } + + func challenge(token: String, purpose: String, deviceID: String?) async throws -> DeviceChallengeResponse { + var body: [String: Any] = ["purpose": purpose] + if let deviceID { body["device_id"] = deviceID } + return try await post("/api/devices/challenges", token: token, body: body) + } + + func enroll( + token: String, + challengeID: String, + displayName: String, + publicKey: DevicePublicKey, + signature: String, + replacementDeviceID: String? + ) async throws -> EnrolledDeviceResponse { + var body: [String: Any] = [ + "challenge_id": challengeID, + "display_name": displayName, + "public_key": [ + "algorithm": publicKey.algorithm, + "format": publicKey.format, + "value": publicKey.value, + ], + "signature": signature, + ] + if let replacementDeviceID { + body["replacement_device_id"] = replacementDeviceID + } + return try await post("/api/devices/enroll", token: token, body: body) + } + + func ticket( + token: String, + deviceID: String, + challengeID: String, + signature: String, + versions: [Int] + ) async throws -> DeviceTicketResponse { + try await post("/api/devices/\(deviceID)/tickets", token: token, body: [ + "challenge_id": challengeID, + "signature": signature, + "protocol_versions": versions, + ]) + } + + func connect(ticket: String, protocolVersion: Int) async throws -> DeviceSocket { + var request = URLRequest(url: gatewayURL) + request.setValue("perch://app", forHTTPHeaderField: "Origin") + let encodedTicket = Data(ticket.utf8).base64URLEncodedString + request.setValue( + "perch.v\(protocolVersion), perch-ticket.\(encodedTicket)", + forHTTPHeaderField: "Sec-WebSocket-Protocol" + ) + let task = session.webSocketTask(with: request) + task.resume() + return URLSessionDeviceSocket(task: task) + } + + private func post( + _ path: String, + token: String, + body: [String: Any] + ) async throws -> T { + var request = URLRequest(url: baseURL.appendingPathComponent(path)) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + let (data, response) = try await session.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200...299).contains(status) else { + let payload = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] + throw DeviceNetworkError( + status: status, + code: payload["code"] as? String ?? "request_failed", + message: payload["error"] as? String ?? "Request failed" + ) + } + return try JSONDecoder().decode(T.self, from: data) + } +} + +private final class URLSessionDeviceSocket: DeviceSocket { + private let task: URLSessionWebSocketTask + init(task: URLSessionWebSocketTask) { self.task = task } + + var closeCode: Int { task.closeCode.rawValue } + + func receive() async throws -> Data { + switch try await task.receive() { + case .data(let data): return data + case .string(let string): return Data(string.utf8) + @unknown default: throw URLError(.cannotDecodeContentData) + } + } + + func send(_ data: Data) async throws { + try await task.send(.data(data)) + } + + func cancel() { + task.cancel(with: .goingAway, reason: nil) + } +} + +final class DeviceConnection: ObservableObject { + static let protocolVersion = 1 + + @Published private(set) var state: DeviceConnectionState = .signedOut + @Published private(set) var activeUserID: String? + + var onEvent: ((String, [String: Any]) -> Void)? + var onStateAnnouncement: ((String) -> Void)? + + private let network: DeviceConnectionNetwork + private let identities: DeviceIdentityProviding + private let accountData: AccountDataStore + private let executionBackend: DeviceExecutionBackend + private let workspaceBookmarks: ExecutorWorkspaceBookmarkStore + private let executionConsent = ExecutionConsentStore() + private var connectionTask: Task? + private weak var socket: DeviceSocket? + private var generation = 0 + private var accessToken: String? + private let pathMonitor = NWPathMonitor() + private let monitorQueue = DispatchQueue(label: "engineering.super.Perch.network-monitor") + private var observers: [NSObjectProtocol] = [] + private var pendingExecutionGrants: [String: [String: Any]] = [:] + private var executionTasks: [String: Task] = [:] + private var gatewaySessionID: String? + private var gatewayFence: Int? + + init( + network: DeviceConnectionNetwork = URLSessionDeviceConnectionNetwork(), + identities: DeviceIdentityProviding = DeviceIdentityStore(), + accountData: AccountDataStore = AccountDataStore(), + executionBackend: DeviceExecutionBackend? = nil + ) { + self.network = network + self.identities = identities + self.accountData = accountData + let workspaceBookmarks = ExecutorWorkspaceBookmarkStore(accountData: accountData) + self.workspaceBookmarks = workspaceBookmarks + self.executionBackend = executionBackend ?? ProductionExecutorBackend( + identities: identities, + bookmarks: workspaceBookmarks + ) + observeLifecycle() + } + + var executorCapabilityState: ExecutorCapabilityState { + executionBackend.capabilityState + } + + func saveWorkspaceBookmark( + _ bookmark: Data, + identifier: String, + userID: String + ) async throws { + guard activeUserID == userID else { + throw ExecutorIPCError.unauthenticated + } + try await workspaceBookmarks.save(bookmark, identifier: identifier, userID: userID) + } + + func decideLocalAction( + actionID: UUID, + actionHash: String, + parametersHash: String, + capabilities: ExecutionCapabilities, + workspaceURL: URL?, + workspaceBookmarkID: String, + highRiskShell: Bool, + expiresAt: Date, + approved: Bool + ) async throws { + guard let userID = activeUserID, + let socket, + gatewaySessionID != nil, + gatewayFence != nil else { + throw ExecutorIPCError.unauthenticated + } + if approved { + guard capabilities.egressDestinations.isEmpty, + let workspaceURL else { + throw LocalActionError.invalidBinding( + "network is unavailable or no workspace was selected" + ) + } + var options: URL.BookmarkCreationOptions = [.withSecurityScope] + if capabilities.workspaceMode == .readOnly { + options.insert(.securityScopeAllowOnlyReadAccess) + } + let bookmark = try workspaceURL.bookmarkData( + options: options, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + try await workspaceBookmarks.save( + bookmark, + identifier: workspaceBookmarkID, + userID: userID + ) + try await executionConsent.record(ExecutionApproval( + approvalID: UUID(), + actionID: actionID, + actionHash: actionHash, + parametersHash: parametersHash, + capabilities: capabilities, + workspaceBookmarkHash: WorkspaceBookmark(data: bookmark).hash, + approvedAt: Date(), + expiresAt: expiresAt, + highRiskShell: highRiskShell + )) + } else { + await executionConsent.revoke(actionID: actionID) + } + let message: [String: Any] = [ + "v": Self.protocolVersion, + "type": "action_decision", + "id": UUID().uuidString.lowercased(), + "payload": [ + "action_id": actionID.uuidString.lowercased(), + "decision": approved ? "approved" : "rejected", + "parameters_hash": parametersHash, + ], + ] + try await socket.send(JSONSerialization.data(withJSONObject: message)) + } + + deinit { + pathMonitor.cancel() + observers.forEach(NotificationCenter.default.removeObserver) + } + + func start(session: AuthSession) { + cancel(markCancelled: false) + generation += 1 + activeUserID = session.userId + accessToken = session.accessToken + let currentGeneration = generation + connectionTask = Task { [weak self] in + await self?.run(userID: session.userId, token: session.accessToken, generation: currentGeneration) + } + } + + func retry() { + guard let userID = activeUserID, let token = accessToken else { return } + cancel(markCancelled: false) + generation += 1 + let currentGeneration = generation + connectionTask = Task { [weak self] in + await self?.run(userID: userID, token: token, generation: currentGeneration) + } + } + + func cancel(markCancelled: Bool = true) { + generation += 1 + connectionTask?.cancel() + connectionTask = nil + executionTasks.values.forEach { $0.cancel() } + executionTasks.removeAll() + pendingExecutionGrants.removeAll() + gatewaySessionID = nil + gatewayFence = nil + socket?.cancel() + socket = nil + if markCancelled { setState(.cancelled) } + } + + func logout() { + generation += 1 + connectionTask?.cancel() + connectionTask = nil + executionTasks.values.forEach { $0.cancel() } + executionTasks.removeAll() + pendingExecutionGrants.removeAll() + gatewaySessionID = nil + gatewayFence = nil + let activeSocket = socket + socket = nil + activeUserID = nil + accessToken = nil + setState(.signedOut) + Task { + if let activeSocket { + let logout: [String: Any] = [ + "v": Self.protocolVersion, + "type": "logout", + "id": UUID().uuidString.lowercased(), + "payload": [:], + ] + if let data = try? JSONSerialization.data(withJSONObject: logout) { + try? await activeSocket.send(data) + } + activeSocket.cancel() + } + } + } + + func reenroll() { + guard let userID = activeUserID else { return } + do { + try identities.deleteIdentity(for: userID) + var accountState = try accountData.loadDeviceState(for: userID) + accountState.deviceID = nil + accountState.deviceKeyAlgorithm = nil + accountState.cursor = 0 + accountState.processedTransitionIDs = [] + try accountData.saveDeviceState(accountState, for: userID) + retry() + } catch { + setState(.reenrollmentRequired(reason: error.localizedDescription)) + } + } + + private func run(userID: String, token: String, generation: Int) async { + var attempt = 0 + while !Task.isCancelled, generation == self.generation { + do { + var accountState = try accountData.loadDeviceState(for: userID) + let identity = try identities.identity(for: userID, createIfMissing: true) + let publicKey = try identity.publicKey + let replacementDeviceID = accountState.deviceKeyAlgorithm == publicKey.algorithm + ? nil + : accountState.deviceID + if replacementDeviceID != nil { + accountState.deviceID = nil + accountState.cursor = 0 + accountState.processedTransitionIDs = [] + } + if accountState.deviceID == nil { + setState(.enrolling) + let challenge = try await network.challenge( + token: token, + purpose: "enrollment", + deviceID: nil + ) + let signature = try identity.sign(challengeMessage( + purpose: "enrollment", + challenge: challenge, + userID: userID, + deviceID: nil + )).base64URLEncodedString + let enrolled = try await network.enroll( + token: token, + challengeID: challenge.challengeID, + displayName: Host.current().localizedName ?? "Mac", + publicKey: publicKey, + signature: signature, + replacementDeviceID: replacementDeviceID + ) + accountState.deviceID = enrolled.device.id + accountState.deviceKeyAlgorithm = publicKey.algorithm + try accountData.saveDeviceState(accountState, for: userID) + } + guard let deviceID = accountState.deviceID else { throw SecureStoreError.missing } + setState(.connecting(attempt: attempt)) + let challenge = try await network.challenge( + token: token, + purpose: "ticket", + deviceID: deviceID + ) + let signature = try identity.sign(challengeMessage( + purpose: "ticket", + challenge: challenge, + userID: userID, + deviceID: deviceID + )).base64URLEncodedString + let ticket = try await network.ticket( + token: token, + deviceID: deviceID, + challengeID: challenge.challengeID, + signature: signature, + versions: [Self.protocolVersion] + ) + let connectedSocket = try await network.connect( + ticket: ticket.ticket, + protocolVersion: ticket.protocolVersion + ) + guard generation == self.generation else { + connectedSocket.cancel() + return + } + socket = connectedSocket + setState(.connected(deviceID: deviceID)) + attempt = 0 + try await receive( + socket: connectedSocket, + userID: userID, + deviceID: deviceID, + generation: generation + ) + if Task.isCancelled { return } + classifyClose(code: connectedSocket.closeCode) + } catch is CancellationError { + return + } catch DeviceIdentityError.secureEnclaveUnavailable { + setState(.reenrollmentRequired( + reason: "Secure Enclave is required to enroll this Mac." + )) + return + } catch DeviceIdentityError.invalidStoredKey { + setState(.reenrollmentRequired( + reason: "The hardware device key cannot be restored. Re-enrollment is required." + )) + return + } catch let error as DeviceNetworkError { + if error.status == 426 || error.code == "unsupported_protocol" { + setState(.unsupportedProtocol) + return + } + if error.code == "device_not_found" || error.code == "device_revoked" { + setState(.revoked(reason: error.message)) + return + } + if error.code == "fresh_auth_required" { + setState(.reenrollmentRequired(reason: error.message)) + return + } + if error.status == 401 { + setState(.expired) + } else { + setState(.offline(reason: error.message)) + } + } catch { + setState(.interrupted(reason: error.localizedDescription)) + } + attempt += 1 + let ceiling = min(30.0, pow(2.0, Double(min(attempt, 5)))) + try? await Task.sleep(for: .seconds(Double.random(in: 0...ceiling))) + } + } + + private func receive( + socket: DeviceSocket, + userID: String, + deviceID: String, + generation: Int + ) async throws { + while !Task.isCancelled, generation == self.generation { + let data = try await socket.receive() + guard generation == self.generation, + activeUserID == userID, + let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any], + envelope["v"] as? Int == Self.protocolVersion, + let type = envelope["type"] as? String, + let payload = envelope["payload"] as? [String: Any] else { continue } + if type == "event" { + try await handleEvent( + envelope: envelope, + payload: payload, + userID: userID, + deviceID: deviceID, + socket: socket + ) + } else if type == "snapshot" { + try handleSnapshot(payload, userID: userID) + } else if type == "reconnect_contract" { + // The persisted cursor remains authoritative. Reconnects use + // the client's bounded full-jitter policy below. + guard let sessionID = payload["session_id"] as? String, + UUID(uuidString: sessionID) != nil, + let fence = payload["fence"] as? Int, + fence >= 0 else { + throw LocalActionError.invalidBinding("gateway session binding") + } + gatewaySessionID = sessionID.lowercased() + gatewayFence = fence + try await retryPendingResults( + socket: socket, + userID: userID, + deviceID: deviceID + ) + } else if type == "execution_grant" { + try await claimExecutionGrant(payload, deviceID: deviceID, socket: socket) + } else if type == "grant_consumed" { + try await startConsumedExecution(payload, deviceID: deviceID, socket: socket) + } else if type == "result_ack" { + try acknowledgeResult(payload, userID: userID) + } + } + } + + private func handleEvent( + envelope: [String: Any], + payload: [String: Any], + userID: String, + deviceID: String, + socket: DeviceSocket + ) async throws { + guard let eventID = envelope["id"] as? String, + let sequence = payload["sequence"] as? Int, + let transitionID = payload["transition_id"] as? String, + let eventType = payload["event_type"] as? String else { return } + var state = try accountData.loadDeviceState(for: userID) + if state.processedTransitionIDs.contains(transitionID) || sequence <= state.cursor { + try await acknowledge(eventID: eventID, sequence: sequence, socket: socket) + return + } + guard sequence == state.cursor + 1 else { + throw DeviceNetworkError(status: 409, code: "cursor_gap", message: "Event replay has a cursor gap") + } + let data = payload["data"] as? [String: Any] ?? [:] + if eventType == "cancellation_requested", + let actionID = data["action_id"] as? String { + executionTasks[actionID]?.cancel() + await executionBackend.cancel(actionID: actionID) + } + var routed = data + if routed["type"] == nil { routed["type"] = eventType } + guard activeUserID == userID else { return } + onEvent?(userID, routed) + state.cursor = sequence + state.processedTransitionIDs.append(transitionID) + state.processedTransitionIDs = Array(state.processedTransitionIDs.suffix(500)) + try accountData.saveDeviceState(state, for: userID) + try await acknowledge(eventID: eventID, sequence: sequence, socket: socket) + } + + private func handleSnapshot(_ payload: [String: Any], userID: String) throws { + guard let cursor = payload["cursor"] as? Int else { return } + var state = try accountData.loadDeviceState(for: userID) + state.cursor = cursor + state.processedTransitionIDs = [] + try accountData.saveDeviceState(state, for: userID) + if let snapshot = payload["snapshot"] as? [String: Any], activeUserID == userID { + onEvent?(userID, ["type": "device_snapshot", "data": snapshot]) + } + } + + private func acknowledge(eventID: String, sequence: Int, socket: DeviceSocket) async throws { + let message: [String: Any] = [ + "v": Self.protocolVersion, + "type": "ack", + "id": UUID().uuidString.lowercased(), + "payload": ["event_id": eventID, "sequence": sequence], + ] + try await socket.send(JSONSerialization.data(withJSONObject: message)) + } + + private func claimExecutionGrant( + _ payload: [String: Any], + deviceID: String, + socket: DeviceSocket + ) async throws { + let required = Set([ + "grant_id", "action_id", "sequence", "grant_token", "grant_signature", + "action_hash", "parameters_hash", "registry_version", "action_type", + "normalized_parameters", "capabilities", "image_digest", + "workspace_bookmark_id", "result_disclosure_policy", "session_id", + "device_key_fingerprint", "device_id", "fence", "expires_at", + "transition_id", + ]) + guard Set(payload.keys) == required, + payload["device_id"] as? String == deviceID, + let grantID = payload["grant_id"] as? String, + UUID(uuidString: grantID) != nil, + let actionID = payload["action_id"] as? String, + UUID(uuidString: actionID) != nil, + let sessionID = payload["session_id"] as? String, + sessionID.lowercased() == gatewaySessionID, + let token = payload["grant_token"] as? String, + token.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil, + let actionHash = payload["action_hash"] as? String, + actionHash.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, + let parametersHash = payload["parameters_hash"] as? String, + parametersHash.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, + payload["normalized_parameters"] is [String: Any], + payload["capabilities"] is [String: Any], + let imageDigest = payload["image_digest"] as? String, + imageDigest.range(of: "^sha256:[0-9a-f]{64}$", options: .regularExpression) != nil, + let fence = payload["fence"] as? Int, + fence >= 0, + fence == gatewayFence, + let expiry = payload["expires_at"] as? String, + let expiresAt = ISO8601DateFormatter().date(from: expiry), + expiresAt > Date() else { + throw LocalActionError.invalidBinding("gateway grant schema, device, or expiry") + } + try ExecutionGrantAuthorization().verify(payload: payload) + guard pendingExecutionGrants[grantID] == nil else { return } + let parameters = try ExecutorJSON(any: payload["normalized_parameters"]!) + let capabilities = try ExecutionCapabilities( + json: ExecutorJSON(any: payload["capabilities"]!) + ) + guard capabilities.egressDestinations.isEmpty, + let registryVersion = payload["registry_version"] as? String, + let actionType = payload["action_type"] as? String, + LocalActionRegistry.shared.parametersHash(parameters) == parametersHash, + LocalActionRegistry.shared.actionHash( + registryVersion: registryVersion, + name: actionType, + parameters: parameters + ) == actionHash, + let disclosure = payload["result_disclosure_policy"] as? [String: Any], + Set(disclosure.keys) == Set(["sensitive_output", "upload"]), + (disclosure["sensitive_output"] as? Bool) + == capabilities.sensitiveOutputDisclosure, + (disclosure["upload"] as? Bool) == capabilities.resultUpload else { + throw LocalActionError.invalidBinding("local action or disclosure binding") + } + let resolvedAction = try LocalActionRegistry.shared.resolve( + registryVersion: registryVersion, + name: actionType, + parameters: parameters, + capabilities: capabilities + ) + _ = resolvedAction + pendingExecutionGrants[grantID] = payload + let consume: [String: Any] = [ + "v": Self.protocolVersion, + "type": "consume_grant", + "id": UUID().uuidString.lowercased(), + "payload": [ + "action_id": actionID, + "grant_id": grantID, + "grant_token": token, + "grant_signature": payload["grant_signature"]!, + "action_hash": actionHash, + "parameters_hash": parametersHash, + "registry_version": registryVersion, + "action_type": actionType, + "normalized_parameters": payload["normalized_parameters"]!, + "capabilities": payload["capabilities"]!, + "image_digest": imageDigest, + "workspace_bookmark_id": payload["workspace_bookmark_id"]!, + "result_disclosure_policy": payload["result_disclosure_policy"]!, + "session_id": sessionID, + "device_key_fingerprint": payload["device_key_fingerprint"]!, + "expires_at": expiry, + "transition_id": payload["transition_id"]!, + ], + ] + try await socket.send(JSONSerialization.data(withJSONObject: consume)) + } + + private func startConsumedExecution( + _ payload: [String: Any], + deviceID: String, + socket: DeviceSocket + ) async throws { + guard Set(payload.keys) == Set(["grant_id", "action_id", "transition_id"]), + let grantID = payload["grant_id"] as? String, + let actionID = payload["action_id"] as? String, + let grant = pendingExecutionGrants.removeValue(forKey: grantID), + grant["action_id"] as? String == actionID, + executionTasks[actionID] == nil else { + return + } + guard let userID = activeUserID else { + throw ExecutorIPCError.unauthenticated + } + try await consumeApproval(for: grant, userID: userID) + let task = Task { [weak self, weak socket] in + guard let self, let socket else { return } + let status: String + let result: [String: Any] + do { + let execution = try await self.executionBackend.execute( + grantPayload: grant, + deviceID: deviceID, + userID: userID + ) + status = execution.status.rawValue + let mayUpload = ((grant["capabilities"] as? [String: Any])?["result_upload"] as? Bool) == true + result = mayUpload + ? execution.protocolObject + : [ + "result_disclosed": false, + "exit_code": execution.exitCode.map { Int($0) as Any } ?? NSNull(), + "truncated": execution.truncated, + "redactions": execution.redactions, + ] + } catch is CancellationError { + status = "cancelled" + result = ["code": "cancelled"] + } catch { + status = "failed" + result = ["code": "local_execution_failed"] + } + if self.activeUserID == userID { + do { + let bounded = try self.boundedResult(result) + let pending = PendingDeviceResult( + resultID: UUID().uuidString.lowercased(), + actionID: actionID, + grantID: grantID, + status: status, + resultJSON: bounded + ) + var state = try self.accountData.loadDeviceState(for: userID) + if !state.pendingResults.contains(where: { $0.resultID == pending.resultID }) { + state.pendingResults.append(pending) + try self.accountData.saveDeviceState(state, for: userID) + } + try await self.sendPendingResult( + pending, + socket: socket, + userID: userID, + deviceID: deviceID + ) + } catch { + // The persisted result remains queued and is retried after + // the next authenticated reconnect contract. + } + } + self.executionTasks.removeValue(forKey: actionID) + } + executionTasks[actionID] = task + } + + private func consumeApproval( + for grant: [String: Any], + userID: String + ) async throws { + guard let actionValue = grant["action_id"] as? String, + let actionID = UUID(uuidString: actionValue), + let actionHash = grant["action_hash"] as? String, + let parametersHash = grant["parameters_hash"] as? String, + let registryVersion = grant["registry_version"] as? String, + let actionType = grant["action_type"] as? String, + let bookmarkID = grant["workspace_bookmark_id"] as? String, + let rawParameters = grant["normalized_parameters"], + let rawCapabilities = grant["capabilities"] else { + throw LocalActionError.invalidBinding("approval workspace binding") + } + let parameters = try ExecutorJSON(any: rawParameters) + let capabilities = try ExecutionCapabilities( + json: ExecutorJSON(any: rawCapabilities) + ) + let action = try LocalActionRegistry.shared.resolve( + registryVersion: registryVersion, + name: actionType, + parameters: parameters, + capabilities: capabilities + ) + let bookmark = try await workspaceBookmarks.bookmark( + identifier: bookmarkID, + userID: userID + ) + _ = try await executionConsent.consume( + actionID: actionID, + actionHash: actionHash, + parametersHash: parametersHash, + capabilities: capabilities, + workspaceBookmarkHash: WorkspaceBookmark(data: bookmark).hash, + highRiskShell: action.highRiskShell + ) + } + + private func retryPendingResults( + socket: DeviceSocket, + userID: String, + deviceID: String + ) async throws { + let state = try accountData.loadDeviceState(for: userID) + for pending in state.pendingResults { + try await sendPendingResult( + pending, + socket: socket, + userID: userID, + deviceID: deviceID + ) + } + } + + private func sendPendingResult( + _ pending: PendingDeviceResult, + socket: DeviceSocket, + userID: String, + deviceID: String + ) async throws { + guard let sessionID = gatewaySessionID, + let fence = gatewayFence, + let result = try JSONSerialization.jsonObject( + with: pending.resultJSON + ) as? [String: Any] else { + throw ExecutorIPCError.unauthenticated + } + let hash = SHA256.hash(data: pending.resultJSON) + .map { String(format: "%02x", $0) } + .joined() + let signingPayload = Data([ + "perch-action-result", + String(Self.protocolVersion), + pending.resultID.lowercased(), + deviceID.lowercased(), + sessionID.lowercased(), + String(fence), + pending.actionID.lowercased(), + pending.grantID.lowercased(), + pending.status, + hash, + ].joined(separator: "\n").utf8) + let identity = try identities.identity(for: userID, createIfMissing: false) + let signature = try identity.sign(signingPayload).base64URLEncodedString + let message: [String: Any] = [ + "v": Self.protocolVersion, + "type": "action_result", + "id": pending.resultID, + "payload": [ + "action_id": pending.actionID, + "grant_id": pending.grantID, + "status": pending.status, + "result": result, + "session_id": sessionID, + "signature": signature, + ], + ] + let encoded = try JSONSerialization.data(withJSONObject: message) + guard encoded.count <= 64 * 1_024 else { throw ExecutorIPCError.oversized } + try await socket.send(encoded) + } + + private func acknowledgeResult(_ payload: [String: Any], userID: String) throws { + guard Set(payload.keys) == Set(["result_id", "action_id", "grant_id"]), + let resultID = payload["result_id"] as? String, + let actionID = payload["action_id"] as? String, + let grantID = payload["grant_id"] as? String else { + throw ExecutorIPCError.malformed + } + var state = try accountData.loadDeviceState(for: userID) + guard let pending = state.pendingResults.first(where: { $0.resultID == resultID }), + pending.actionID == actionID, + pending.grantID == grantID else { + return + } + state.pendingResults.removeAll { $0.resultID == resultID } + try accountData.saveDeviceState(state, for: userID) + } + + private func boundedResult(_ result: [String: Any]) throws -> Data { + var bounded = result + var encoded = try JSONSerialization.data( + withJSONObject: bounded, + options: [.sortedKeys, .withoutEscapingSlashes] + ) + if encoded.count > 48 * 1_024 { + for key in ["stdout", "stderr"] { + if let text = bounded[key] as? String { + bounded[key] = String( + decoding: Data(text.utf8.prefix(16 * 1_024)), + as: UTF8.self + ) + } + } + bounded["truncated"] = true + encoded = try JSONSerialization.data( + withJSONObject: bounded, + options: [.sortedKeys, .withoutEscapingSlashes] + ) + } + guard encoded.count <= 48 * 1_024 else { throw ExecutorIPCError.oversized } + return encoded + } + + private func challengeMessage( + purpose: String, + challenge: DeviceChallengeResponse, + userID: String, + deviceID: String? + ) -> Data { + Data([ + "perch-device-challenge", "1", purpose, challenge.challengeID, + challenge.nonce, userID, deviceID ?? "new", + ].joined(separator: ":").utf8) + } + + private func classifyClose(code: Int) { + switch code { + case 4002, 4008: setState(.revoked(reason: "The server fenced this device session.")) + case 4003, 426: setState(.unsupportedProtocol) + case 4006: setState(.interrupted(reason: "Heartbeat timed out.")) + default: setState(.interrupted(reason: "The secure connection closed.")) + } + } + + private func setState(_ newState: DeviceConnectionState) { + guard state != newState else { return } + state = newState + onStateAnnouncement?(newState.announcement) + } + + private func observeLifecycle() { + pathMonitor.pathUpdateHandler = { [weak self] path in + guard path.status == .satisfied else { + Task { @MainActor [weak self] in + self?.setState(.offline(reason: "Network unavailable.")) + } + return + } + Task { @MainActor [weak self] in + guard let self, self.activeUserID != nil, + case .offline = self.state else { return } + self.retry() + } + } + pathMonitor.start(queue: monitorQueue) + observers.append(NotificationCenter.default.addObserver( + forName: NSWorkspace.willSleepNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.cancel(markCancelled: false) + self?.setState(.interrupted(reason: "Mac went to sleep.")) + } + }) + observers.append(NotificationCenter.default.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.retry() } + }) + } +} diff --git a/app/Sources/DeviceIdentityStore.swift b/app/Sources/DeviceIdentityStore.swift new file mode 100644 index 0000000..68f8b85 --- /dev/null +++ b/app/Sources/DeviceIdentityStore.swift @@ -0,0 +1,196 @@ +import CryptoKit +import Foundation + +struct DevicePublicKey: Equatable { + let algorithm: String + let format: String + let value: String +} + +enum DeviceIdentityError: Error, Equatable { + case secureEnclaveUnavailable + case invalidStoredKey +} + +protocol DeviceSigningIdentity { + var publicKey: DevicePublicKey { get throws } + func sign(_ message: Data) throws -> Data + func delete() throws +} + +protocol DeviceIdentityProviding { + func identity(for userID: String, createIfMissing: Bool) throws -> DeviceSigningIdentity + func deleteIdentity(for userID: String) throws +} + +protocol HardwareDeviceSigningKey { + var encryptedRepresentation: Data { get } + var publicX963Representation: Data { get } + func signatureDER(for message: Data) throws -> Data +} + +protocol HardwareDeviceKeyFactory { + func create() throws -> HardwareDeviceSigningKey + func restore(encryptedRepresentation: Data) throws -> HardwareDeviceSigningKey +} + +struct SecureEnclaveDeviceKeyFactory: HardwareDeviceKeyFactory { + func create() throws -> HardwareDeviceSigningKey { + guard SecureEnclave.isAvailable else { + throw DeviceIdentityError.secureEnclaveUnavailable + } + do { + return SecureEnclaveDeviceSigningKey( + key: try SecureEnclave.P256.Signing.PrivateKey() + ) + } catch { + throw DeviceIdentityError.secureEnclaveUnavailable + } + } + + func restore(encryptedRepresentation: Data) throws -> HardwareDeviceSigningKey { + guard SecureEnclave.isAvailable else { + throw DeviceIdentityError.secureEnclaveUnavailable + } + do { + return SecureEnclaveDeviceSigningKey( + key: try SecureEnclave.P256.Signing.PrivateKey( + dataRepresentation: encryptedRepresentation + ) + ) + } catch { + throw DeviceIdentityError.invalidStoredKey + } + } +} + +private struct SecureEnclaveDeviceSigningKey: HardwareDeviceSigningKey { + let key: SecureEnclave.P256.Signing.PrivateKey + var encryptedRepresentation: Data { key.dataRepresentation } + var publicX963Representation: Data { key.publicKey.x963Representation } + + func signatureDER(for message: Data) throws -> Data { + try key.signature(for: message).derRepresentation + } +} + +/// Stores only Secure Enclave's encrypted key reference. The private scalar +/// never leaves the enclave and cannot be reconstructed from Keychain data. +final class DeviceIdentityStore: DeviceIdentityProviding { + fileprivate static let service = "engineering.super.Perch.device-identity.p256" + private let keychain: KeychainDataClient + private let factory: HardwareDeviceKeyFactory + + init( + keychain: KeychainDataClient = DataProtectionKeychainClient(), + factory: HardwareDeviceKeyFactory = SecureEnclaveDeviceKeyFactory() + ) { + self.keychain = keychain + self.factory = factory + } + + func identity(for userID: String, createIfMissing: Bool) throws -> DeviceSigningIdentity { + do { + let encrypted = try keychain.read(service: Self.service, account: userID) + return identity( + try factory.restore(encryptedRepresentation: encrypted), + userID: userID + ) + } catch SecureStoreError.missing { + guard createIfMissing else { throw SecureStoreError.missing } + } catch let error as DeviceIdentityError { + throw error + } catch { + throw DeviceIdentityError.invalidStoredKey + } + + let key = try factory.create() + do { + try keychain.add( + key.encryptedRepresentation, + service: Self.service, + account: userID + ) + } catch SecureStoreError.duplicate { + let encrypted = try keychain.read(service: Self.service, account: userID) + return identity( + try factory.restore(encryptedRepresentation: encrypted), + userID: userID + ) + } + return identity(key, userID: userID) + } + + func deleteIdentity(for userID: String) throws { + try keychain.delete(service: Self.service, account: userID) + } + + private func identity( + _ key: HardwareDeviceSigningKey, + userID: String + ) -> DeviceSigningIdentity { + HardwareBackedDeviceIdentity( + key: key, + userID: userID, + keychain: keychain + ) + } +} + +private final class HardwareBackedDeviceIdentity: DeviceSigningIdentity { + let key: HardwareDeviceSigningKey + let userID: String + let keychain: KeychainDataClient + + init( + key: HardwareDeviceSigningKey, + userID: String, + keychain: KeychainDataClient + ) { + self.key = key + self.userID = userID + self.keychain = keychain + } + + var publicKey: DevicePublicKey { + get throws { + let point = key.publicX963Representation + guard point.count == 65, point.first == 0x04 else { + throw DeviceIdentityError.invalidStoredKey + } + // RFC 5480 SubjectPublicKeyInfo for id-ecPublicKey + prime256v1. + let prefix = 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, + ]) + let der = prefix + point + let body = der.base64EncodedString( + options: [.lineLength64Characters, .endLineWithLineFeed] + ) + return DevicePublicKey( + algorithm: "P-256", + format: "spki-pem", + value: "-----BEGIN PUBLIC KEY-----\n\(body)\n-----END PUBLIC KEY-----\n" + ) + } + } + + func sign(_ message: Data) throws -> Data { + try key.signatureDER(for: message) + } + + func delete() throws { + try keychain.delete(service: DeviceIdentityStore.service, account: userID) + } +} + +extension Data { + var base64URLEncodedString: String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/app/Sources/Executor/ActionRegistry.swift b/app/Sources/Executor/ActionRegistry.swift new file mode 100644 index 0000000..97dfa11 --- /dev/null +++ b/app/Sources/Executor/ActionRegistry.swift @@ -0,0 +1,341 @@ +import CryptoKit +import Foundation + +public enum ExecutorJSON: Hashable, Sendable { + case string(String) + case integer(Int) + case boolean(Bool) + case array([ExecutorJSON]) + case object([String: ExecutorJSON]) + case null + + public init(any value: Any) throws { + switch value { + case let value as String: + self = .string(value) + case let value as NSNumber: + if CFGetTypeID(value) == CFBooleanGetTypeID() { + self = .boolean(value.boolValue) + return + } + guard Double(value.intValue) == value.doubleValue else { + throw LocalActionError.invalidSchema("Only integral JSON numbers are supported") + } + self = .integer(value.intValue) + case let value as Bool: + self = .boolean(value) + case let value as [Any]: + self = .array(try value.map(ExecutorJSON.init(any:))) + case let value as [String: Any]: + self = .object(try value.mapValues(ExecutorJSON.init(any:))) + case _ as NSNull: + self = .null + default: + throw LocalActionError.invalidSchema("Unsupported JSON value") + } + } + + public var any: Any { + switch self { + case .string(let value): return value + case .integer(let value): return value + case .boolean(let value): return value + case .array(let value): return value.map(\.any) + case .object(let value): return value.mapValues(\.any) + case .null: return NSNull() + } + } + + public var postgresJSON: String { + switch self { + case .string(let value): + let data = try! JSONSerialization.data(withJSONObject: [value]) + return String(decoding: data, as: UTF8.self).dropFirst().dropLast().description + case .integer(let value): return String(value) + case .boolean(let value): return value ? "true" : "false" + case .array(let value): return "[\(value.map(\.postgresJSON).joined(separator: ", "))]" + case .object(let value): + let members = value.keys.sorted().map { + ExecutorJSON.string($0).postgresJSON + ": " + value[$0]!.postgresJSON + } + return "{" + members.joined(separator: ", ") + "}" + case .null: return "null" + } + } +} + +public enum LocalActionError: Error, Equatable, LocalizedError { + case registryVersion + case unknownAction + case invalidSchema(String) + case invalidBinding(String) + case expired + case unsupported + + public var errorDescription: String? { + switch self { + case .registryVersion: return "The local action registry version is unsupported." + case .unknownAction: return "The local action is not registered." + case .invalidSchema(let reason): return "The local action schema is invalid: \(reason)" + case .invalidBinding(let reason): return "The execution grant binding is invalid: \(reason)" + case .expired: return "The execution grant expired before startup." + case .unsupported: return "Local execution requires macOS 26 on Apple silicon." + } + } +} + +public struct ExecutionLimits: Hashable, Sendable { + public let cpuCount: Int + public let memoryBytes: UInt64 + public let diskBytes: UInt64 + public let processCount: Int + public let outputBytes: Int + public let timeoutSeconds: Int + + public init(json: ExecutorJSON) throws { + guard case .object(let value) = json, + Set(value.keys) == Set([ + "cpu_count", "memory_bytes", "disk_bytes", "process_count", + "output_bytes", "timeout_seconds", + ]), + case .integer(let cpu) = value["cpu_count"], + case .integer(let memory) = value["memory_bytes"], + case .integer(let disk) = value["disk_bytes"], + case .integer(let processes) = value["process_count"], + case .integer(let output) = value["output_bytes"], + case .integer(let timeout) = value["timeout_seconds"], + (1...4).contains(cpu), + (128 * 1_024 * 1_024...4 * 1_024 * 1_024 * 1_024).contains(memory), + (256 * 1_024 * 1_024...8 * 1_024 * 1_024 * 1_024).contains(disk), + (1...256).contains(processes), + (1...4 * 1_024 * 1_024).contains(output), + (1...1_800).contains(timeout) else { + throw LocalActionError.invalidSchema("resource limits are missing, excessive, or malformed") + } + cpuCount = cpu + memoryBytes = UInt64(memory) + diskBytes = UInt64(disk) + processCount = processes + outputBytes = output + timeoutSeconds = timeout + } + + public var json: ExecutorJSON { + .object([ + "cpu_count": .integer(cpuCount), + "memory_bytes": .integer(Int(memoryBytes)), + "disk_bytes": .integer(Int(diskBytes)), + "process_count": .integer(processCount), + "output_bytes": .integer(outputBytes), + "timeout_seconds": .integer(timeoutSeconds), + ]) + } +} + +public struct ExecutionCapabilities: Hashable, Sendable { + public enum WorkspaceMode: String, Sendable { case readOnly = "read_only", readWrite = "read_write" } + + public let workspaceMode: WorkspaceMode + public let egressDestinations: [String] + public let sensitiveFileAccess: Bool + public let sensitiveOutputDisclosure: Bool + public let resultUpload: Bool + public let limits: ExecutionLimits + + public init(json: ExecutorJSON) throws { + guard case .object(let value) = json, + Set(value.keys) == Set([ + "workspace_mode", "egress_destinations", "sensitive_file_access", + "sensitive_output_disclosure", "result_upload", "limits", + ]), + case .string(let modeValue) = value["workspace_mode"], + let mode = WorkspaceMode(rawValue: modeValue), + case .array(let destinationsValue) = value["egress_destinations"], + case .boolean(let sensitiveFiles) = value["sensitive_file_access"], + case .boolean(let sensitive) = value["sensitive_output_disclosure"], + case .boolean(let upload) = value["result_upload"], + let limitsValue = value["limits"] else { + throw LocalActionError.invalidSchema("capabilities do not match registry v1") + } + let destinations = try destinationsValue.map { item -> String in + guard case .string(let destination) = item, + Self.isValidDestination(destination) else { + throw LocalActionError.invalidSchema("egress destinations must be exact HTTPS host:port values") + } + return destination.lowercased() + } + guard Set(destinations).count == destinations.count else { + throw LocalActionError.invalidSchema("duplicate egress destination") + } + workspaceMode = mode + egressDestinations = destinations.sorted() + sensitiveFileAccess = sensitiveFiles + sensitiveOutputDisclosure = sensitive + resultUpload = upload + limits = try ExecutionLimits(json: limitsValue) + } + + public var json: ExecutorJSON { + .object([ + "workspace_mode": .string(workspaceMode.rawValue), + "egress_destinations": .array(egressDestinations.map(ExecutorJSON.string)), + "sensitive_file_access": .boolean(sensitiveFileAccess), + "sensitive_output_disclosure": .boolean(sensitiveOutputDisclosure), + "result_upload": .boolean(resultUpload), + "limits": limits.json, + ]) + } + + private static func isValidDestination(_ value: String) -> Bool { + guard !value.contains("/"), !value.contains("@"), !value.contains("*"), + let separator = value.lastIndex(of: ":"), + Int(value[value.index(after: separator)...]).map({ (1...65535).contains($0) }) == true else { + return false + } + let host = value[.. RegisteredLocalAction { + guard registryVersion == Self.version else { throw LocalActionError.registryVersion } + guard case .object(let values) = parameters else { + throw LocalActionError.invalidSchema("parameters must be an object") + } + + switch name { + case "workspace.inspect": + try exact(values, ["path", "depth"]) + let path = try relativePath(values["path"]) + let depth = try integer(values["depth"], range: 1...8) + return .init( + name: name, displayName: "Inspect workspace", highRiskShell: false, + executable: "/usr/bin/find", + arguments: [path, "-xdev", "-maxdepth", String(depth), "-print"], + workingDirectory: "/workspace" + ) + case "workspace.search": + try exact(values, ["query", "path", "max_results"]) + let query = try nonEmptyString(values["query"], maximum: 500) + let path = try relativePath(values["path"]) + let maximum = try integer(values["max_results"], range: 1...1_000) + return .init( + name: name, displayName: "Search workspace", highRiskShell: false, + executable: "/bin/grep", + arguments: ["-R", "-n", "-I", "-m", String(maximum), "--", query, path], + workingDirectory: "/workspace" + ) + case "workspace.read_file": + try exact(values, ["path", "max_bytes"]) + let path = try relativePath(values["path"]) + let bytes = try integer(values["max_bytes"], range: 1...capabilities.limits.outputBytes) + return .init( + name: name, displayName: "Read workspace file", highRiskShell: false, + executable: "/usr/bin/head", arguments: ["-c", String(bytes), "--", path], + workingDirectory: "/workspace" + ) + case "workspace.run_tests": + try exact(values, ["runner", "arguments"]) + let runner = try nonEmptyString(values["runner"], maximum: 20) + let args = try stringArray(values["arguments"], maximumCount: 20, maximumLength: 200) + let executable: String + switch runner { + case "swift": executable = "/usr/bin/swift" + case "npm": executable = "/usr/bin/npm" + default: throw LocalActionError.invalidSchema("runner is not allowlisted") + } + guard args.allSatisfy({ !$0.contains("\0") && $0 != "--prefix" && $0 != "--global" }) else { + throw LocalActionError.invalidSchema("test arguments contain a forbidden option") + } + return .init( + name: name, displayName: "Run \(runner) tests", highRiskShell: false, + executable: executable, arguments: ["test"] + args, workingDirectory: "/workspace" + ) + case "shell.execute": + try exact(values, ["command"]) + let command = try nonEmptyString(values["command"], maximum: 4_096) + guard !command.contains("\0") else { + throw LocalActionError.invalidSchema("shell command contains NUL") + } + return .init( + name: name, displayName: "Run exact shell command", highRiskShell: true, + executable: "/bin/sh", arguments: ["-lc", command], workingDirectory: "/workspace" + ) + default: + throw LocalActionError.unknownAction + } + } + + public func actionHash(registryVersion: String, name: String, parameters: ExecutorJSON) -> String { + Self.sha256("\(registryVersion)\n\(name)\n\(parameters.postgresJSON)") + } + + public func parametersHash(_ parameters: ExecutorJSON) -> String { + Self.sha256(parameters.postgresJSON) + } + + static func sha256(_ value: String) -> String { + SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } + + private func exact(_ values: [String: ExecutorJSON], _ keys: Set) throws { + guard Set(values.keys) == keys else { + throw LocalActionError.invalidSchema("parameters must contain exactly \(keys.sorted())") + } + } + + private func relativePath(_ value: ExecutorJSON?) throws -> String { + let path = try nonEmptyString(value, maximum: 1_024) + guard path != ".", !path.hasPrefix("/"), !path.contains("\0"), + !path.split(separator: "/", omittingEmptySubsequences: false).contains("..") else { + throw LocalActionError.invalidSchema("path must be a normalized workspace-relative path") + } + return path + } + + private func nonEmptyString(_ value: ExecutorJSON?, maximum: Int) throws -> String { + guard case .string(let string) = value, + !string.isEmpty, string.utf8.count <= maximum else { + throw LocalActionError.invalidSchema("string is empty or too long") + } + return string + } + + private func integer(_ value: ExecutorJSON?, range: ClosedRange) throws -> Int { + guard case .integer(let number) = value, range.contains(number) else { + throw LocalActionError.invalidSchema("integer is outside its allowed range") + } + return number + } + + private func stringArray( + _ value: ExecutorJSON?, + maximumCount: Int, + maximumLength: Int + ) throws -> [String] { + guard case .array(let values) = value, values.count <= maximumCount else { + throw LocalActionError.invalidSchema("argument array is too large") + } + return try values.map { try nonEmptyString($0, maximum: maximumLength) } + } +} diff --git a/app/Sources/Executor/ContainerRuntime.swift b/app/Sources/Executor/ContainerRuntime.swift new file mode 100644 index 0000000..2e6f0d1 --- /dev/null +++ b/app/Sources/Executor/ContainerRuntime.swift @@ -0,0 +1,409 @@ +import CryptoKit +import Foundation + +public struct ExecutorArtifact: Codable, Equatable, Sendable { + public enum Kind: String, Codable, Sendable { case kernelArchive, kernel, initImage, workloadImage } + public let kind: Kind + public let reference: String + public let sha256: String + public let bytes: Int? + public let sourceSHA256: String? + + enum CodingKeys: String, CodingKey { + case kind, reference, sha256, bytes + case sourceSHA256 = "source_sha256" + } +} + +public struct ExecutorArtifactManifestPayload: Codable, Equatable, Sendable { + public let schemaVersion: Int + public let containerizationVersion: String + public let containerizationCommit: String + public let minimumOS: String + public let architecture: String + public let artifacts: [ExecutorArtifact] + + enum CodingKeys: String, CodingKey { + case artifacts, architecture + case schemaVersion = "schema_version" + case containerizationVersion = "containerization_version" + case containerizationCommit = "containerization_commit" + case minimumOS = "minimum_os" + } +} + +public struct SignedExecutorArtifactManifest: Codable, Sendable { + let algorithm: String + let keyID: String + let signedPayload: String + let signature: String + + enum CodingKeys: String, CodingKey { + case algorithm, signature + case keyID = "key_id" + case signedPayload = "signed_payload" + } +} + +public enum ArtifactVerificationError: Error, Equatable { + case invalidManifest + case unsupportedSigner + case invalidSignature + case invalidArtifact(String) + case cacheMismatch(String) +} + +public struct ExecutorArtifactManifestVerifier: Sendable { + public static let releaseKeyID = "perch-executor-artifacts-2026-01" + // Public verification material only. Resources/ExecutorArtifacts.json is + // intentionally unsigned in source control. Protected U8 release CI signs + // its canonical payload with the corresponding private key held outside + // this repository; development tests create an isolated fixture key. + public static let releasePublicKeyPEM = """ + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEw0GxpZZneZlYNcgeHV9sV62TZWoa + xN0UvteMArMUnTaCkLvtKDi1Eiw9I3GFfiNaJ0li9d62h3hWD3TvXnWpEQ== + -----END PUBLIC KEY----- + """ + + private let publicKeyPEM: String + + public init(publicKeyPEM: String = Self.releasePublicKeyPEM) { + self.publicKeyPEM = publicKeyPEM + } + + public func verify(data: Data) throws -> ExecutorArtifactManifestPayload { + let envelope = try JSONDecoder().decode(SignedExecutorArtifactManifest.self, from: data) + guard envelope.algorithm == "P256-SHA256-DER", + envelope.keyID == Self.releaseKeyID, + let payload = Data(base64Encoded: envelope.signedPayload), + let signatureData = Data(base64Encoded: envelope.signature) else { + throw ArtifactVerificationError.invalidManifest + } + let key: P256.Signing.PublicKey + do { + key = try P256.Signing.PublicKey(pemRepresentation: publicKeyPEM) + } catch { + throw ArtifactVerificationError.unsupportedSigner + } + guard let signature = try? P256.Signing.ECDSASignature(derRepresentation: signatureData), + key.isValidSignature(signature, for: payload) else { + throw ArtifactVerificationError.invalidSignature + } + let manifest = try JSONDecoder().decode(ExecutorArtifactManifestPayload.self, from: payload) + try validate(manifest) + return manifest + } + + private func validate(_ manifest: ExecutorArtifactManifestPayload) throws { + guard manifest.schemaVersion == 1, + manifest.containerizationVersion == "0.33.3", + manifest.containerizationCommit == "a2a1add6c7e1a1665e5397edc49d925c49090b3a", + manifest.minimumOS == "26.0", + manifest.architecture == "arm64", + Set(manifest.artifacts.map(\.kind)) == Set([ + .kernelArchive, .kernel, .initImage, .workloadImage, + ]) else { + throw ArtifactVerificationError.invalidManifest + } + for artifact in manifest.artifacts { + guard artifact.sha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil, + artifact.reference.range(of: #"^(https://|ghcr\.io/)"#, options: .regularExpression) != nil else { + throw ArtifactVerificationError.invalidArtifact(artifact.reference) + } + } + } +} + +public protocol ExecutorArtifactFetching: Sendable { + func fetch(_ url: URL) async throws -> Data +} + +public struct URLSessionArtifactFetcher: ExecutorArtifactFetching { + public init() {} + public func fetch(_ url: URL) async throws -> Data { + guard url.scheme == "https" else { throw ArtifactVerificationError.invalidArtifact(url.absoluteString) } + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw URLError(.badServerResponse) + } + return data + } +} + +public actor ExecutorArtifactCache { + private let root: URL + private let fetcher: ExecutorArtifactFetching + private let fileManager: FileManager + + public init( + root: URL, + fetcher: ExecutorArtifactFetching = URLSessionArtifactFetcher(), + fileManager: FileManager = .default + ) { + self.root = root + self.fetcher = fetcher + self.fileManager = fileManager + } + + public func verifiedFile(for artifact: ExecutorArtifact, allowBootstrap: Bool) async throws -> URL { + let target = root.appendingPathComponent(artifact.sha256, isDirectory: false) + if fileManager.fileExists(atPath: target.path) { + guard try digest(target) == artifact.sha256 else { + try? fileManager.removeItem(at: target) + throw ArtifactVerificationError.cacheMismatch(artifact.reference) + } + return target + } + guard allowBootstrap, let url = URL(string: artifact.reference), url.scheme == "https" else { + throw ArtifactVerificationError.cacheMismatch(artifact.reference) + } + let data = try await fetcher.fetch(url) + guard Self.digest(data) == artifact.sha256, + artifact.bytes.map({ $0 == data.count }) ?? true else { + throw ArtifactVerificationError.invalidArtifact(artifact.reference) + } + try fileManager.createDirectory( + at: root, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let temporary = root.appendingPathComponent(".\(UUID().uuidString).download") + try data.write(to: temporary, options: [.atomic, .completeFileProtection]) + try fileManager.setAttributes([.posixPermissions: 0o500], ofItemAtPath: temporary.path) + try fileManager.moveItem(at: temporary, to: target) + guard try digest(target) == artifact.sha256 else { + try? fileManager.removeItem(at: target) + throw ArtifactVerificationError.cacheMismatch(artifact.reference) + } + return target + } + + public func verifyExtracted(_ url: URL, artifact: ExecutorArtifact) throws { + guard try digest(url) == artifact.sha256 else { + throw ArtifactVerificationError.cacheMismatch(artifact.reference) + } + } + + private func digest(_ url: URL) throws -> String { + Self.digest(try Data(contentsOf: url, options: [.mappedIfSafe])) + } + + private static func digest(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +public struct LocalActionOffer: Sendable { + public let actionID: UUID + public let registryVersion: String + public let actionName: String + public let normalizedParameters: ExecutorJSON + public let parametersHash: String + public let capabilities: ExecutionCapabilities + public let imageDigest: String + public let expiresAt: Date +} + +public struct ExecutionGrant: Sendable { + public let grantID: UUID + public let actionID: UUID + public let grantToken: String + public let grantSignature: String + public let actionHash: String + public let parametersHash: String + public let normalizedParameters: ExecutorJSON + public let capabilities: ExecutionCapabilities + public let imageDigest: String + public let deviceID: UUID + public let fence: Int + public let expiresAt: Date + public let transitionID: UUID +} + +public struct ExecutionGrantAuthorization: Sendable { + public static let signedFields = [ + "grant_id", "action_id", "action_hash", "parameters_hash", + "registry_version", "action_type", "normalized_parameters", + "capabilities", "image_digest", "workspace_bookmark_id", + "result_disclosure_policy", "session_id", "device_key_fingerprint", + "device_id", "fence", "expires_at", "transition_id", + ] + + public init() {} + + public func verify(payload: [String: Any]) throws { + guard let token = payload["grant_token"] as? String, + token.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil, + let signature = payload["grant_signature"] as? String, + let signatureData = Data(base64URLEncoded: signature), + signatureData.count == SHA256.byteCount else { + throw LocalActionError.invalidBinding("grant authorization signature") + } + var signed: [String: Any] = [:] + for field in Self.signedFields { + guard let value = payload[field] else { + throw LocalActionError.invalidBinding("missing signed grant field \(field)") + } + signed[field] = value + } + let canonical = try JSONSerialization.data( + withJSONObject: signed, + options: [.sortedKeys, .withoutEscapingSlashes] + ) + guard HMAC.isValidAuthenticationCode( + signatureData, + authenticating: canonical, + using: SymmetricKey(data: Data(token.utf8)) + ) else { + throw LocalActionError.invalidBinding("grant authorization signature") + } + } +} + +public struct ValidatedExecution: Sendable { + public let offer: LocalActionOffer + public let grant: ExecutionGrant + public let action: RegisteredLocalAction + public let workspace: WorkspaceScope +} + +struct ExecutionGrantValidator: Sendable { + let registry: LocalActionRegistry + let approvedImageDigest: String + + init(registry: LocalActionRegistry = .shared, approvedImageDigest: String) { + self.registry = registry + self.approvedImageDigest = approvedImageDigest.lowercased() + } + + func validate( + offer: LocalActionOffer, + grant: ExecutionGrant, + connectedDeviceID: UUID, + currentFence: Int, + workspace: WorkspaceScope, + now: Date = Date() + ) throws -> ValidatedExecution { + guard grant.grantToken.range(of: "^[A-Za-z0-9_-]{43}$", options: .regularExpression) != nil else { + throw LocalActionError.invalidBinding("grant authorization signature is malformed") + } + guard offer.actionID == grant.actionID else { + throw LocalActionError.invalidBinding("action ID") + } + guard grant.deviceID == connectedDeviceID else { + throw LocalActionError.invalidBinding("device ID") + } + guard grant.fence == currentFence, currentFence >= 0 else { + throw LocalActionError.invalidBinding("connection fence") + } + guard offer.expiresAt > now, grant.expiresAt > now, grant.expiresAt <= offer.expiresAt else { + throw LocalActionError.expired + } + guard grant.imageDigest.lowercased() == approvedImageDigest, + offer.imageDigest.lowercased() == approvedImageDigest else { + throw LocalActionError.invalidBinding("image digest") + } + guard offer.normalizedParameters == grant.normalizedParameters, + offer.parametersHash == grant.parametersHash, + offer.capabilities == grant.capabilities else { + throw LocalActionError.invalidBinding("normalized parameters or capabilities") + } + guard registry.parametersHash(grant.normalizedParameters) == grant.parametersHash else { + throw LocalActionError.invalidBinding("normalized parameters hash") + } + let expectedActionHash = registry.actionHash( + registryVersion: offer.registryVersion, + name: offer.actionName, + parameters: grant.normalizedParameters + ) + guard expectedActionHash == grant.actionHash else { + throw LocalActionError.invalidBinding("action hash") + } + let action = try registry.resolve( + registryVersion: offer.registryVersion, + name: offer.actionName, + parameters: grant.normalizedParameters, + capabilities: grant.capabilities + ) + if workspace.containsSensitiveFiles { + guard grant.capabilities.sensitiveFileAccess else { + throw LocalActionError.invalidBinding("sensitive file access") + } + } + if workspace.containsSensitiveFiles && !grant.capabilities.sensitiveOutputDisclosure { + // Reading was separately approved, but remote disclosure remains disabled. + guard !grant.capabilities.resultUpload else { + throw LocalActionError.invalidBinding("sensitive output disclosure") + } + } + return ValidatedExecution(offer: offer, grant: grant, action: action, workspace: workspace) + } +} + +public enum ExecutorCapabilityState: Equatable, Sendable { + case available + case unsupportedOS + case unsupportedArchitecture + case artifactsUnavailable(String) + case virtualizationUnavailable(String) + + public static var current: ExecutorCapabilityState { + #if arch(arm64) + if #available(macOS 26, *) { return .available } + return .unsupportedOS + #else + return .unsupportedArchitecture + #endif + } +} + +public protocol ContainerRuntime: Sendable { + var capabilityState: ExecutorCapabilityState { get } + func execute(_ request: ValidatedExecution) async throws -> ExecutionResult + func cancel(actionID: UUID) async + func cleanup(actionID: UUID) async +} + +public struct UnavailableContainerRuntime: ContainerRuntime { + public let capabilityState: ExecutorCapabilityState + public init(state: ExecutorCapabilityState = .current) { capabilityState = state } + public func execute(_ request: ValidatedExecution) async throws -> ExecutionResult { + throw LocalActionError.unsupported + } + public func cancel(actionID: UUID) async {} + public func cleanup(actionID: UUID) async {} +} + +public protocol DeviceExecutionBackend: Sendable { + var capabilityState: ExecutorCapabilityState { get } + func execute( + grantPayload: [String: Any], + deviceID: String, + userID: String + ) async throws -> ExecutionResult + func cancel(actionID: String) async +} + +public struct DisabledDeviceExecutionBackend: DeviceExecutionBackend { + public let capabilityState: ExecutorCapabilityState = .current + public init() {} + public func execute( + grantPayload: [String: Any], + deviceID: String, + userID: String + ) async throws -> ExecutionResult { + throw LocalActionError.unsupported + } + public func cancel(actionID: String) async {} +} + +private extension Data { + init?(base64URLEncoded value: String) { + var base64 = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) + self.init(base64Encoded: base64) + } +} diff --git a/app/Sources/Executor/ExecutionConsent.swift b/app/Sources/Executor/ExecutionConsent.swift new file mode 100644 index 0000000..fcd7c62 --- /dev/null +++ b/app/Sources/Executor/ExecutionConsent.swift @@ -0,0 +1,152 @@ +import Foundation + +public struct ExecutionConsentDisclosure: Equatable, Sendable { + let actionID: UUID + let actionName: String + let command: [String] + let workspacePath: String + let workspaceMode: ExecutionCapabilities.WorkspaceMode + let egressDestinations: [String] + let mayReadSensitiveFiles: Bool + let sensitiveOutputMayLeaveDevice: Bool + let resultWillBeUploaded: Bool + let expiresAt: Date + + public var summary: String { + let commandText = command.map(Self.shellQuote).joined(separator: " ") + let network = egressDestinations.isEmpty + ? "No network access" + : "Network: \(egressDestinations.joined(separator: ", "))" + let write = workspaceMode == .readOnly ? "read-only" : "read/write" + let upload = resultWillBeUploaded ? "Result leaves this Mac" : "Result remains on this Mac" + let sensitive = mayReadSensitiveFiles + ? (sensitiveOutputMayLeaveDevice + ? "Sensitive output may be disclosed" + : "Sensitive output is redacted before disclosure") + : "No sensitive-file access approved" + return [ + "\(actionName): \(commandText)", + "Mount \(workspacePath) (\(write))", + network, + sensitive, + upload, + ].joined(separator: "\n") + } + + private static func shellQuote(_ value: String) -> String { + guard !value.isEmpty else { return "''" } + let safe = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_./:-") + if value.unicodeScalars.allSatisfy(safe.contains) { return value } + return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} + +public struct ExecutionApproval: Equatable, Sendable { + public let approvalID: UUID + public let actionID: UUID + public let actionHash: String + public let parametersHash: String + public let capabilities: ExecutionCapabilities + public let workspaceBookmarkHash: String + public let approvedAt: Date + public let expiresAt: Date + public let highRiskShell: Bool + + public init( + approvalID: UUID, + actionID: UUID, + actionHash: String, + parametersHash: String, + capabilities: ExecutionCapabilities, + workspaceBookmarkHash: String, + approvedAt: Date, + expiresAt: Date, + highRiskShell: Bool + ) { + self.approvalID = approvalID + self.actionID = actionID + self.actionHash = actionHash + self.parametersHash = parametersHash + self.capabilities = capabilities + self.workspaceBookmarkHash = workspaceBookmarkHash + self.approvedAt = approvedAt + self.expiresAt = expiresAt + self.highRiskShell = highRiskShell + } +} + +enum ConsentRequirement: Equatable, Sendable { + case approvalRequired(ExecutionConsentDisclosure) + case approved(ExecutionApproval) +} + +public actor ExecutionConsentStore { + public init() {} + private var approvals: [UUID: ExecutionApproval] = [:] + private var consumed: Set = [] + + public func record(_ approval: ExecutionApproval) throws { + guard approval.expiresAt > approval.approvedAt else { + throw LocalActionError.invalidBinding("approval expiry is invalid") + } + approvals[approval.actionID] = approval + } + + public func consume( + actionID: UUID, + actionHash: String, + parametersHash: String, + capabilities: ExecutionCapabilities, + workspaceBookmarkHash: String, + highRiskShell: Bool, + now: Date = Date() + ) throws -> ExecutionApproval { + guard !consumed.contains(actionID), + let approval = approvals[actionID] else { + throw LocalActionError.invalidBinding("a fresh single-use approval is required") + } + guard approval.expiresAt > now else { + approvals.removeValue(forKey: actionID) + throw LocalActionError.expired + } + guard approval.actionHash == actionHash, + approval.parametersHash == parametersHash, + approval.capabilities == capabilities, + approval.workspaceBookmarkHash == workspaceBookmarkHash, + approval.highRiskShell == highRiskShell else { + throw LocalActionError.invalidBinding("approval does not match the exact action parameters and capabilities") + } + approvals.removeValue(forKey: actionID) + consumed.insert(actionID) + return approval + } + + public func revoke(actionID: UUID) { + approvals.removeValue(forKey: actionID) + } +} + +struct ConsentPolicy: Sendable { + func requiresFreshApproval( + previous: ExecutionApproval?, + actionID: UUID, + actionHash: String, + parametersHash: String, + capabilities: ExecutionCapabilities, + workspaceBookmarkHash: String, + highRiskShell: Bool + ) -> Bool { + guard let previous, + previous.actionID == actionID, + previous.actionHash == actionHash, + previous.parametersHash == parametersHash, + previous.capabilities == capabilities, + previous.workspaceBookmarkHash == workspaceBookmarkHash, + previous.highRiskShell == highRiskShell else { + return true + } + // Approval is always single-use. Write, egress, sensitive disclosure, + // result upload, and arbitrary shell therefore can never inherit consent. + return true + } +} diff --git a/app/Sources/Executor/ExecutionResult.swift b/app/Sources/Executor/ExecutionResult.swift new file mode 100644 index 0000000..d0ccd16 --- /dev/null +++ b/app/Sources/Executor/ExecutionResult.swift @@ -0,0 +1,125 @@ +import Foundation + +public enum ExecutionResultStatus: String, Sendable { + case completed + case failed + case cancelled +} + +public struct ExecutionResult: Equatable, Sendable { + public let status: ExecutionResultStatus + public let exitCode: Int32? + public let stdout: String + public let stderr: String + public let truncated: Bool + public let durationMilliseconds: Int + public let redactions: Int + + public init( + status: ExecutionResultStatus, + exitCode: Int32?, + stdout: String, + stderr: String, + truncated: Bool, + durationMilliseconds: Int, + redactions: Int + ) { + self.status = status + self.exitCode = exitCode + self.stdout = stdout + self.stderr = stderr + self.truncated = truncated + self.durationMilliseconds = durationMilliseconds + self.redactions = redactions + } + + public var protocolObject: [String: Any] { + [ + "exit_code": exitCode.map { Int($0) as Any } ?? NSNull(), + "stdout": stdout, + "stderr": stderr, + "truncated": truncated, + "duration_ms": durationMilliseconds, + "redactions": redactions, + ] + } +} + +public final class BoundedOutputWriter: @unchecked Sendable { + private let lock = NSLock() + private let limit: Int + private var storage = Data() + private(set) var truncated = false + + public init(limit: Int) { + self.limit = max(0, limit) + } + + public func append(_ data: Data) { + lock.lock() + defer { lock.unlock() } + let remaining = max(0, limit - storage.count) + if data.count > remaining { truncated = true } + storage.append(data.prefix(remaining)) + } + + public func snapshot() -> (data: Data, truncated: Bool) { + lock.lock() + defer { lock.unlock() } + return (storage, truncated) + } +} + +public struct HostileOutputSanitizer: Sendable { + public struct Sanitized: Equatable, Sendable { + public let text: String + public let redactions: Int + } + + private static let secretPatterns: [NSRegularExpression] = [ + try! NSRegularExpression(pattern: #"(?i)\b(api[_-]?key|token|secret|password)\s*[:=]\s*['"]?([A-Za-z0-9_./+=-]{8,})"#), + try! NSRegularExpression(pattern: #"\bgh[pousr]_[A-Za-z0-9]{20,}\b"#), + try! NSRegularExpression(pattern: #"\bsk-[A-Za-z0-9_-]{20,}\b"#), + try! NSRegularExpression(pattern: #"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"#), + ] + + public init() {} + + public func sanitize(_ data: Data, allowSensitiveDisclosure: Bool) -> Sanitized { + var scalarSafe = String(decoding: data, as: UTF8.self) + scalarSafe = stripControlAndTerminalSequences(scalarSafe) + guard !allowSensitiveDisclosure else { + return Sanitized(text: scalarSafe, redactions: 0) + } + + var redactions = 0 + for expression in Self.secretPatterns { + let range = NSRange(scalarSafe.startIndex..., in: scalarSafe) + let matches = expression.matches(in: scalarSafe, range: range) + redactions += matches.count + scalarSafe = expression.stringByReplacingMatches( + in: scalarSafe, + range: range, + withTemplate: "[REDACTED]" + ) + } + return Sanitized(text: scalarSafe, redactions: redactions) + } + + private func stripControlAndTerminalSequences(_ input: String) -> String { + var result = input.replacingOccurrences( + of: #"\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))"#, + with: "", + options: .regularExpression + ) + result.removeAll { character in + character.unicodeScalars.contains { + ($0.value < 0x20 && $0 != "\n" && $0 != "\r" && $0 != "\t") + || $0.value == 0x7f + || (0x202a...0x202e).contains($0.value) + || (0x2066...0x2069).contains($0.value) + } + } + return result + } +} diff --git a/app/Sources/Executor/ExecutorIPC.swift b/app/Sources/Executor/ExecutorIPC.swift new file mode 100644 index 0000000..b8eef43 --- /dev/null +++ b/app/Sources/Executor/ExecutorIPC.swift @@ -0,0 +1,228 @@ +import CryptoKit +import Darwin +import Foundation + +public enum ExecutorIPCError: Error, Equatable { + case oversized + case malformed + case unsupportedMessage + case unauthenticated + case staleFence + case replay + case executorUnavailable +} + +public struct ExecutorIPCRequest: Codable, Sendable { + public static let version = 1 + public static let maximumBytes = 512 * 1_024 + public static let messageType = "execute_grant" + + public let version: Int + public let type: String + public let requestID: UUID + public let sessionID: UUID + public let deviceID: UUID + public let fence: Int + public let grantJSON: Data + public let workspaceBookmark: Data + public let devicePublicKeyPEM: String + public let ipcSecret: Data + public let signatureDER: Data + + enum CodingKeys: String, CodingKey, CaseIterable { + case version, type + case requestID = "request_id" + case sessionID = "session_id" + case deviceID = "device_id" + case fence + case grantJSON = "grant_json" + case workspaceBookmark = "workspace_bookmark" + case devicePublicKeyPEM = "device_public_key_pem" + case ipcSecret = "ipc_secret" + case signatureDER = "signature_der" + } + + public init( + requestID: UUID, + sessionID: UUID, + deviceID: UUID, + fence: Int, + grantJSON: Data, + workspaceBookmark: Data, + devicePublicKeyPEM: String, + ipcSecret: Data, + signatureDER: Data + ) { + version = Self.version + type = Self.messageType + self.requestID = requestID + self.sessionID = sessionID + self.deviceID = deviceID + self.fence = fence + self.grantJSON = grantJSON + self.workspaceBookmark = workspaceBookmark + self.devicePublicKeyPEM = devicePublicKeyPEM + self.ipcSecret = ipcSecret + self.signatureDER = signatureDER + } + + public func signingData() throws -> Data { + try Self.canonicalData([ + "version": version, + "type": type, + "request_id": requestID.uuidString.lowercased(), + "session_id": sessionID.uuidString.lowercased(), + "device_id": deviceID.uuidString.lowercased(), + "fence": fence, + "grant_json": grantJSON.base64EncodedString(), + "workspace_bookmark": workspaceBookmark.base64EncodedString(), + "device_public_key_pem": devicePublicKeyPEM, + "ipc_secret": ipcSecret.base64EncodedString(), + ]) + } + + public static func decode(_ data: Data) throws -> ExecutorIPCRequest { + guard data.count <= maximumBytes, + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + Set(object.keys) == Set(CodingKeys.allCases.map(\.rawValue)) else { + throw data.count > maximumBytes ? ExecutorIPCError.oversized : ExecutorIPCError.malformed + } + let request = try JSONDecoder().decode(Self.self, from: data) + guard request.version == version, request.type == messageType else { + throw ExecutorIPCError.unsupportedMessage + } + guard request.ipcSecret.count == 32, + request.signatureDER.count >= 8, + request.signatureDER.count <= 80, + request.fence >= 0, + request.grantJSON.count <= maximumBytes / 2, + request.workspaceBookmark.count <= 128 * 1_024, + request.devicePublicKeyPEM.utf8.count <= 1_000 else { + throw ExecutorIPCError.malformed + } + return request + } + + fileprivate static func canonicalData(_ object: [String: Any]) throws -> Data { + try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys, .withoutEscapingSlashes]) + } +} + +public struct ExecutorIPCResponse: Codable, Sendable { + public static let maximumBytes = 8 * 1_024 * 1_024 + public let version: Int + public let type: String + public let requestID: UUID + public let resultJSON: Data + public let authenticationTag: Data + + enum CodingKeys: String, CodingKey, CaseIterable { + case version, type + case requestID = "request_id" + case resultJSON = "result_json" + case authenticationTag = "authentication_tag" + } + + public init(requestID: UUID, resultJSON: Data, ipcSecret: Data) throws { + version = ExecutorIPCRequest.version + type = "execution_result" + self.requestID = requestID + self.resultJSON = resultJSON + authenticationTag = Data(HMAC.authenticationCode( + for: try Self.authenticationData(requestID: requestID, resultJSON: resultJSON), + using: SymmetricKey(data: ipcSecret) + )) + } + + public func verify(ipcSecret: Data, expectedRequestID: UUID) throws { + guard version == ExecutorIPCRequest.version, + type == "execution_result", + requestID == expectedRequestID, + authenticationTag.count == SHA256.byteCount, + HMAC.isValidAuthenticationCode( + authenticationTag, + authenticating: try Self.authenticationData( + requestID: requestID, + resultJSON: resultJSON + ), + using: SymmetricKey(data: ipcSecret) + ) else { + throw ExecutorIPCError.unauthenticated + } + } + + public static func decode(_ data: Data) throws -> ExecutorIPCResponse { + guard data.count <= maximumBytes, + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + Set(object.keys) == Set(CodingKeys.allCases.map(\.rawValue)) else { + throw data.count > maximumBytes ? ExecutorIPCError.oversized : ExecutorIPCError.malformed + } + return try JSONDecoder().decode(Self.self, from: data) + } + + private static func authenticationData(requestID: UUID, resultJSON: Data) throws -> Data { + try ExecutorIPCRequest.canonicalData([ + "request_id": requestID.uuidString.lowercased(), + "result_json": resultJSON.base64EncodedString(), + ]) + } +} + +public struct ExecutorIPCAuthenticator: Sendable { + public init() {} + + public func verify(_ request: ExecutorIPCRequest, now: Date = Date()) throws -> [String: Any] { + let key: P256.Signing.PublicKey + let signature: P256.Signing.ECDSASignature + do { + key = try P256.Signing.PublicKey(pemRepresentation: request.devicePublicKeyPEM) + signature = try P256.Signing.ECDSASignature(derRepresentation: request.signatureDER) + } catch { + throw ExecutorIPCError.unauthenticated + } + guard key.isValidSignature(signature, for: try request.signingData()), + let grant = try JSONSerialization.jsonObject(with: request.grantJSON) as? [String: Any], + grant["device_id"] as? String == request.deviceID.uuidString.lowercased(), + grant["session_id"] as? String == request.sessionID.uuidString.lowercased(), + grant["fence"] as? Int == request.fence, + let expiry = grant["expires_at"] as? String, + ISO8601DateFormatter().date(from: expiry).map({ $0 > now }) == true, + let expectedFingerprint = grant["device_key_fingerprint"] as? String, + expectedFingerprint == Self.fingerprint(key) else { + throw ExecutorIPCError.unauthenticated + } + return grant + } + + private static func fingerprint(_ key: P256.Signing.PublicKey) -> String { + SHA256.hash(data: key.derRepresentation) + .map { String(format: "%02x", $0) } + .joined() + } +} + +public struct ExecutorIPCReplayGuard: Sendable { + private let root: URL + + public init(root: URL) { + self.root = root + } + + public func claim(requestID: UUID, grantID: UUID) throws { + try FileManager.default.createDirectory( + at: root, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let markerName = LocalActionRegistry.sha256( + requestID.uuidString.lowercased() + ":" + grantID.uuidString.lowercased() + ) + let marker = root.appendingPathComponent(markerName) + let descriptor = open(marker.path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0o600) + guard descriptor >= 0 else { + if errno == EEXIST { throw ExecutorIPCError.replay } + throw ExecutorIPCError.malformed + } + close(descriptor) + } +} diff --git a/app/Sources/Executor/WorkspaceAccess.swift b/app/Sources/Executor/WorkspaceAccess.swift new file mode 100644 index 0000000..efbf763 --- /dev/null +++ b/app/Sources/Executor/WorkspaceAccess.swift @@ -0,0 +1,218 @@ +import CryptoKit +import Darwin +import Foundation + +public struct WorkspaceBookmark: Hashable, Sendable { + public let data: Data + public init(data: Data) { self.data = data } + public var hash: String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +public struct WorkspaceScope: Sendable { + public let selectedRoot: URL + public let root: URL + public let bookmark: WorkspaceBookmark + public let readOnly: Bool + public let containsSensitiveFiles: Bool + fileprivate let stopAccessing: @Sendable () -> Void + + public func close() { stopAccessing() } +} + +enum WorkspaceAccessError: Error, Equatable, LocalizedError { + case staleBookmark + case securityScopeDenied + case notDirectory + case unsafeEntry(String) + case changedDuringValidation + + var errorDescription: String? { + switch self { + case .staleBookmark: return "Workspace permission is stale. Select the workspace again." + case .securityScopeDenied: return "macOS denied access to the selected workspace." + case .notDirectory: return "The selected workspace is not a directory." + case .unsafeEntry(let path): return "Workspace contains an unsafe entry: \(path)" + case .changedDuringValidation: return "Workspace changed while it was being validated." + } + } +} + +protocol WorkspaceBookmarkResolving: Sendable { + func create(for url: URL) throws -> WorkspaceBookmark + func resolve(_ bookmark: WorkspaceBookmark) throws -> (url: URL, stale: Bool) + func startAccessing(_ url: URL) -> Bool + func stopAccessing(_ url: URL) +} + +struct SecurityScopedBookmarkResolver: WorkspaceBookmarkResolving { + func create(for url: URL) throws -> WorkspaceBookmark { + WorkspaceBookmark(data: try url.bookmarkData( + options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess], + includingResourceValuesForKeys: nil, + relativeTo: nil + )) + } + + func resolve(_ bookmark: WorkspaceBookmark) throws -> (url: URL, stale: Bool) { + var stale = false + let url = try URL( + resolvingBookmarkData: bookmark.data, + options: [.withSecurityScope, .withoutUI], + relativeTo: nil, + bookmarkDataIsStale: &stale + ) + return (url, stale) + } + + func startAccessing(_ url: URL) -> Bool { url.startAccessingSecurityScopedResource() } + func stopAccessing(_ url: URL) { url.stopAccessingSecurityScopedResource() } +} + +struct WorkspaceAccess: @unchecked Sendable { + private let bookmarks: WorkspaceBookmarkResolving + private let fileManager: FileManager + private let snapshotter: WorkspaceSnapshotter + + init( + bookmarks: WorkspaceBookmarkResolving = SecurityScopedBookmarkResolver(), + fileManager: FileManager = .default, + snapshotter: WorkspaceSnapshotter = WorkspaceSnapshotter() + ) { + self.bookmarks = bookmarks + self.fileManager = fileManager + self.snapshotter = snapshotter + } + + func open( + bookmark: WorkspaceBookmark, + mode: ExecutionCapabilities.WorkspaceMode, + quota: WorkspaceSnapshotQuota = .init( + maximumFiles: 100_000, + maximumBytes: 1_073_741_824 + ) + ) throws -> WorkspaceScope { + let resolved = try bookmarks.resolve(bookmark) + guard !resolved.stale else { throw WorkspaceAccessError.staleBookmark } + guard bookmarks.startAccessing(resolved.url) else { + throw WorkspaceAccessError.securityScopeDenied + } + do { + let selectedRoot = resolved.url.standardizedFileURL + let stagedRoot: URL + do { + stagedRoot = try snapshotter.stage( + source: selectedRoot, + quota: quota, + readOnly: mode == .readOnly + ) + } catch let error as WorkspaceSnapshotError { + throw WorkspaceAccessError.unsafeEntry(String(describing: error)) + } + let sensitive = try validateTree(stagedRoot) + return WorkspaceScope( + selectedRoot: selectedRoot, + root: stagedRoot, + bookmark: bookmark, + readOnly: mode == .readOnly, + containsSensitiveFiles: sensitive, + stopAccessing: { + try? FileManager.default.removeItem(at: stagedRoot) + bookmarks.stopAccessing(resolved.url) + } + ) + } catch { + bookmarks.stopAccessing(resolved.url) + throw error + } + } + + func validateRelativePath(_ path: String, in scope: WorkspaceScope) throws -> URL { + guard !path.hasPrefix("/"), !path.contains("\0"), + !path.split(separator: "/", omittingEmptySubsequences: false).contains("..") else { + throw WorkspaceAccessError.unsafeEntry(path) + } + let candidate = scope.root.appendingPathComponent(path).standardizedFileURL + let rootPath = scope.root.path.hasSuffix("/") ? scope.root.path : scope.root.path + "/" + guard candidate.path == scope.root.path || candidate.path.hasPrefix(rootPath) else { + throw WorkspaceAccessError.unsafeEntry(path) + } + return candidate + } + + private func validateTree(_ root: URL) throws -> Bool { + var rootInfo = stat() + guard lstat(root.path, &rootInfo) == 0, + (rootInfo.st_mode & S_IFMT) == S_IFDIR else { + throw WorkspaceAccessError.notDirectory + } + let originalRoot = (device: rootInfo.st_dev, inode: rootInfo.st_ino) + var containsSensitive = false + let keys: [URLResourceKey] = [.isDirectoryKey, .isSymbolicLinkKey, .nameKey] + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: keys, + options: [.skipsPackageDescendants], + errorHandler: { _, _ in false } + ) else { + throw WorkspaceAccessError.notDirectory + } + + for case let url as URL in enumerator { + var info = stat() + guard lstat(url.path, &info) == 0 else { + throw WorkspaceAccessError.changedDuringValidation + } + let type = info.st_mode & S_IFMT + guard info.st_dev == rootInfo.st_dev else { + throw WorkspaceAccessError.unsafeEntry(relative(url, root: root) + " (nested mount)") + } + switch type { + case S_IFDIR: + break + case S_IFREG: + guard info.st_nlink == 1 else { + throw WorkspaceAccessError.unsafeEntry(relative(url, root: root) + " (hard link)") + } + case S_IFLNK: + throw WorkspaceAccessError.unsafeEntry(relative(url, root: root) + " (symbolic link)") + case S_IFSOCK: + throw WorkspaceAccessError.unsafeEntry(relative(url, root: root) + " (socket)") + case S_IFIFO: + throw WorkspaceAccessError.unsafeEntry(relative(url, root: root) + " (FIFO)") + case S_IFCHR, S_IFBLK: + throw WorkspaceAccessError.unsafeEntry(relative(url, root: root) + " (device)") + default: + throw WorkspaceAccessError.unsafeEntry(relative(url, root: root)) + } + if isSensitive(url: url, root: root) { containsSensitive = true } + } + + guard lstat(root.path, &rootInfo) == 0, + rootInfo.st_dev == originalRoot.device, + rootInfo.st_ino == originalRoot.inode else { + throw WorkspaceAccessError.changedDuringValidation + } + return containsSensitive + } + + private func relative(_ url: URL, root: URL) -> String { + String(url.path.dropFirst(min(root.path.count + 1, url.path.count))) + } + + private func isSensitive(url: URL, root: URL) -> Bool { + let name = url.lastPathComponent.lowercased() + let relativePath = relative(url, root: root).lowercased() + let exact = Set([ + ".env", ".env.local", ".npmrc", ".pypirc", "credentials.json", + "id_rsa", "id_ed25519", ".netrc", + ]) + return exact.contains(name) + || name.hasSuffix(".pem") + || name.hasSuffix(".key") + || relativePath.hasPrefix(".ssh/") + || relativePath.hasPrefix(".aws/") + || relativePath.hasPrefix(".config/gcloud/") + } +} diff --git a/app/Sources/Executor/WorkspaceSnapshot.swift b/app/Sources/Executor/WorkspaceSnapshot.swift new file mode 100644 index 0000000..bdf1a4e --- /dev/null +++ b/app/Sources/Executor/WorkspaceSnapshot.swift @@ -0,0 +1,241 @@ +import Darwin +import Foundation + +public struct WorkspaceSnapshotQuota: Equatable, Sendable { + public let maximumFiles: Int + public let maximumBytes: UInt64 + + public init(maximumFiles: Int, maximumBytes: UInt64) { + self.maximumFiles = maximumFiles + self.maximumBytes = maximumBytes + } +} + +enum WorkspaceSnapshotError: Error, Equatable { + case invalidQuota + case unsafeEntry(String) + case quotaExceeded + case sourceChanged(String) + case ioFailure(String) +} + +/// Copies a selected workspace through already-open directory/file descriptors. +/// The VM receives only the private snapshot path, never the mutable source path. +struct WorkspaceSnapshotter: Sendable { + private let stagingRoot: URL + + init(stagingRoot: URL = FileManager.default.temporaryDirectory + .appendingPathComponent("PerchExecutorSnapshots", isDirectory: true)) { + self.stagingRoot = stagingRoot + } + + func stage( + source: URL, + quota: WorkspaceSnapshotQuota, + readOnly: Bool + ) throws -> URL { + guard quota.maximumFiles > 0, quota.maximumBytes > 0 else { + throw WorkspaceSnapshotError.invalidQuota + } + try FileManager.default.createDirectory( + at: stagingRoot, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let destination = stagingRoot.appendingPathComponent(UUID().uuidString, isDirectory: true) + guard mkdir(destination.path, 0o700) == 0 else { + throw WorkspaceSnapshotError.ioFailure("create staging directory") + } + var keepDestination = false + defer { + if !keepDestination { try? FileManager.default.removeItem(at: destination) } + } + + let sourceFD = open(source.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard sourceFD >= 0 else { + throw WorkspaceSnapshotError.ioFailure("open selected workspace") + } + defer { close(sourceFD) } + var rootStat = stat() + guard fstat(sourceFD, &rootStat) == 0, (rootStat.st_mode & S_IFMT) == S_IFDIR else { + throw WorkspaceSnapshotError.ioFailure("stat selected workspace") + } + var counters = Counters() + try copyDirectory( + sourceFD: sourceFD, + destination: destination, + rootDevice: rootStat.st_dev, + relativePath: "", + quota: quota, + counters: &counters + ) + try applySnapshotPermissions(destination, readOnly: readOnly) + keepDestination = true + return destination + } + + private func copyDirectory( + sourceFD: Int32, + destination: URL, + rootDevice: dev_t, + relativePath: String, + quota: WorkspaceSnapshotQuota, + counters: inout Counters + ) throws { + guard let directory = fdopendir(dup(sourceFD)) else { + throw WorkspaceSnapshotError.ioFailure("enumerate \(relativePath)") + } + defer { closedir(directory) } + while let entry = readdir(directory) { + let name = withUnsafePointer(to: &entry.pointee.d_name) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(MAXNAMLEN) + 1) { + String(cString: $0) + } + } + if name == "." || name == ".." { continue } + guard !name.contains("/"), !name.contains("\0") else { + throw WorkspaceSnapshotError.unsafeEntry(path(relativePath, name)) + } + let childRelative = path(relativePath, name) + var before = stat() + guard fstatat(sourceFD, name, &before, AT_SYMLINK_NOFOLLOW) == 0 else { + throw WorkspaceSnapshotError.sourceChanged(childRelative) + } + guard before.st_dev == rootDevice else { + throw WorkspaceSnapshotError.unsafeEntry(childRelative + " (nested mount)") + } + counters.files += 1 + guard counters.files <= quota.maximumFiles else { + throw WorkspaceSnapshotError.quotaExceeded + } + let destinationChild = destination.appendingPathComponent(name) + switch before.st_mode & S_IFMT { + case S_IFDIR: + let childFD = openat(sourceFD, name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard childFD >= 0 else { throw WorkspaceSnapshotError.sourceChanged(childRelative) } + defer { close(childFD) } + var opened = stat() + guard fstat(childFD, &opened) == 0, + sameIdentity(before, opened), + mkdir(destinationChild.path, 0o700) == 0 else { + throw WorkspaceSnapshotError.sourceChanged(childRelative) + } + try copyDirectory( + sourceFD: childFD, + destination: destinationChild, + rootDevice: rootDevice, + relativePath: childRelative, + quota: quota, + counters: &counters + ) + case S_IFREG: + guard before.st_nlink == 1, before.st_size >= 0 else { + throw WorkspaceSnapshotError.unsafeEntry(childRelative + " (hard link)") + } + let size = UInt64(before.st_size) + guard size <= quota.maximumBytes - min(counters.bytes, quota.maximumBytes) else { + throw WorkspaceSnapshotError.quotaExceeded + } + let input = openat(sourceFD, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC) + guard input >= 0 else { throw WorkspaceSnapshotError.sourceChanged(childRelative) } + defer { close(input) } + var opened = stat() + guard fstat(input, &opened) == 0, sameIdentity(before, opened) else { + throw WorkspaceSnapshotError.sourceChanged(childRelative) + } + let output = open(destinationChild.path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0o400) + guard output >= 0 else { + throw WorkspaceSnapshotError.ioFailure("create \(childRelative)") + } + do { + try copyFile(input: input, output: output, expectedBytes: size) + } catch { + close(output) + throw error + } + guard close(output) == 0 else { + throw WorkspaceSnapshotError.ioFailure("close \(childRelative)") + } + var after = stat() + guard fstat(input, &after) == 0, + sameIdentity(opened, after), + after.st_size == opened.st_size, + after.st_mtimespec.tv_sec == opened.st_mtimespec.tv_sec, + after.st_mtimespec.tv_nsec == opened.st_mtimespec.tv_nsec else { + throw WorkspaceSnapshotError.sourceChanged(childRelative) + } + counters.bytes += size + case S_IFLNK: + throw WorkspaceSnapshotError.unsafeEntry(childRelative + " (symbolic link)") + case S_IFSOCK: + throw WorkspaceSnapshotError.unsafeEntry(childRelative + " (socket)") + case S_IFIFO: + throw WorkspaceSnapshotError.unsafeEntry(childRelative + " (FIFO)") + case S_IFCHR, S_IFBLK: + throw WorkspaceSnapshotError.unsafeEntry(childRelative + " (device)") + default: + throw WorkspaceSnapshotError.unsafeEntry(childRelative) + } + } + } + + private func copyFile(input: Int32, output: Int32, expectedBytes: UInt64) throws { + var total: UInt64 = 0 + var buffer = [UInt8](repeating: 0, count: 64 * 1_024) + while true { + let count = read(input, &buffer, buffer.count) + if count == 0 { break } + guard count > 0 else { + if errno == EINTR { continue } + throw WorkspaceSnapshotError.ioFailure("read source") + } + var written = 0 + while written < count { + let result = buffer.withUnsafeBytes { + write(output, $0.baseAddress!.advanced(by: written), count - written) + } + guard result > 0 else { + if result < 0, errno == EINTR { continue } + throw WorkspaceSnapshotError.ioFailure("write snapshot") + } + written += result + } + total += UInt64(count) + } + guard total == expectedBytes else { + throw WorkspaceSnapshotError.sourceChanged("file size changed") + } + } + + private func applySnapshotPermissions(_ root: URL, readOnly: Bool) throws { + let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: [.isDirectoryKey]) + while let url = enumerator?.nextObject() as? URL { + let values = try url.resourceValues(forKeys: [.isDirectoryKey]) + try FileManager.default.setAttributes( + [.posixPermissions: values.isDirectory == true + ? (readOnly ? 0o500 : 0o700) + : (readOnly ? 0o400 : 0o600)], + ofItemAtPath: url.path + ) + } + try FileManager.default.setAttributes( + [.posixPermissions: readOnly ? 0o500 : 0o700], + ofItemAtPath: root.path + ) + } + + private func sameIdentity(_ lhs: stat, _ rhs: stat) -> Bool { + lhs.st_dev == rhs.st_dev + && lhs.st_ino == rhs.st_ino + && (lhs.st_mode & S_IFMT) == (rhs.st_mode & S_IFMT) + } + + private func path(_ parent: String, _ child: String) -> String { + parent.isEmpty ? child : parent + "/" + child + } + + private struct Counters { + var files = 0 + var bytes: UInt64 = 0 + } +} diff --git a/app/Sources/ExecutorProcessBackend.swift b/app/Sources/ExecutorProcessBackend.swift new file mode 100644 index 0000000..4810127 --- /dev/null +++ b/app/Sources/ExecutorProcessBackend.swift @@ -0,0 +1,320 @@ +#if canImport(ExecutorCore) +import ExecutorCore +#endif +import Foundation +import Security + +actor ExecutorWorkspaceBookmarkStore { + private let accountData: AccountDataStore + + init(accountData: AccountDataStore) { + self.accountData = accountData + } + + func save(_ bookmark: Data, identifier: String, userID: String) throws { + guard Self.validIdentifier(identifier), bookmark.count <= 128 * 1_024 else { + throw ExecutorIPCError.malformed + } + var values = try load(userID: userID) + values[identifier] = bookmark + let data = try PropertyListEncoder().encode(values) + try accountData.writeSecurely(data, to: try url(userID: userID)) + } + + func bookmark(identifier: String, userID: String) throws -> Data { + guard Self.validIdentifier(identifier), + let value = try load(userID: userID)[identifier] else { + throw ExecutorIPCError.unauthenticated + } + return value + } + + func removeAll(userID: String) throws { + try? FileManager.default.removeItem(at: url(userID: userID)) + } + + private func load(userID: String) throws -> [String: Data] { + let file = try url(userID: userID) + guard FileManager.default.fileExists(atPath: file.path) else { return [:] } + return try PropertyListDecoder().decode([String: Data].self, from: Data(contentsOf: file)) + } + + private func url(userID: String) throws -> URL { + try accountData.accountDirectory(for: userID) + .appendingPathComponent("executor-workspaces.plist") + } + + private static func validIdentifier(_ value: String) -> Bool { + value.range(of: "^[A-Za-z0-9._-]{1,128}$", options: .regularExpression) != nil + } +} + +protocol ExecutorSubprocessRunning: Sendable { + func run(executable: URL, input: Data) async throws -> Data + func cancel(actionID: String) async +} + +actor FixedExecutorSubprocessRunner: ExecutorSubprocessRunning { + private var processes: [String: Process] = [:] + + func run(executable: URL, input: Data) async throws -> Data { + guard input.count <= ExecutorIPCRequest.maximumBytes else { + throw ExecutorIPCError.oversized + } + let process = Process() + let stdin = Pipe() + let stdout = Pipe() + let stderr = Pipe() + process.executableURL = executable + process.arguments = ["--ipc"] + process.standardInput = stdin + process.standardOutput = stdout + process.standardError = stderr + let actionID = UUID().uuidString.lowercased() + processes[actionID] = process + defer { processes.removeValue(forKey: actionID) } + try process.run() + stdin.fileHandleForWriting.write(input) + try stdin.fileHandleForWriting.close() + + return try await withTaskCancellationHandler { + async let output = Task.detached { + stdout.fileHandleForReading.readDataToEndOfFile() + }.value + async let diagnostics = Task.detached { + stderr.fileHandleForReading.readDataToEndOfFile() + }.value + let (result, errorData) = await (output, diagnostics) + process.waitUntilExit() + guard result.count <= ExecutorIPCResponse.maximumBytes, + errorData.count <= 64 * 1_024, + process.terminationReason == .exit, + process.terminationStatus == 0 else { + throw ExecutorIPCError.executorUnavailable + } + return result + } onCancel: { + process.terminate() + } + } + + func cancel(actionID: String) async { + processes.values.forEach { $0.terminate() } + } +} + +protocol ExecutorInstallationVerifying: Sendable { + func verifiedExecutable() throws -> URL +} + +enum ExecutorInstallationError: Error { + case unsupportedOS + case unsupportedArchitecture + case artifacts + case signature + case virtualizationEntitlement +} + +struct ProductionExecutorInstallationVerifier: ExecutorInstallationVerifying { + func verifiedExecutable() throws -> URL { + guard #available(macOS 26.0, *) else { + throw ExecutorInstallationError.unsupportedOS + } + #if !arch(arm64) + throw ExecutorInstallationError.unsupportedArchitecture + #else + let executable = Bundle.main.bundleURL + .appendingPathComponent("Contents/Helpers/PerchExecutor", isDirectory: false) + guard FileManager.default.isExecutableFile(atPath: executable.path), + let manifest = Bundle.main.url( + forResource: "ExecutorArtifacts", + withExtension: "json" + ) else { + throw ExecutorInstallationError.artifacts + } + do { + _ = try ExecutorArtifactManifestVerifier().verify(data: Data(contentsOf: manifest)) + } catch { + throw ExecutorInstallationError.artifacts + } + try verifySameTeamSignature(executable) + return executable + #endif + } + + private func verifySameTeamSignature(_ executable: URL) throws { + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath( + executable as CFURL, + SecCSFlags(), + &staticCode + ) == errSecSuccess, + let staticCode, + SecStaticCodeCheckValidity( + staticCode, + SecCSFlags(rawValue: kSecCSStrictValidate), + nil + ) == errSecSuccess, + let executorTeam = teamIdentifier(staticCode: staticCode) else { + throw ExecutorInstallationError.signature + } + guard let entitlements = signingInformation(staticCode: staticCode)?[ + kSecCodeInfoEntitlementsDict + ] as? [String: Any], + entitlements["com.apple.security.virtualization"] as? Bool == true else { + throw ExecutorInstallationError.virtualizationEntitlement + } + var selfCode: SecCode? + guard SecCodeCopySelf(SecCSFlags(), &selfCode) == errSecSuccess, + let selfCode else { + throw ExecutorInstallationError.signature + } + var selfStaticCode: SecStaticCode? + guard SecCodeCopyStaticCode(selfCode, SecCSFlags(), &selfStaticCode) == errSecSuccess, + let selfStaticCode, + let appTeam = teamIdentifier(staticCode: selfStaticCode), + appTeam == executorTeam else { + throw ExecutorInstallationError.signature + } + } + + private func teamIdentifier(staticCode: SecStaticCode) -> String? { + (signingInformation(staticCode: staticCode)?[kSecCodeInfoTeamIdentifier] as? String) + } + + private func signingInformation(staticCode: SecStaticCode) -> [CFString: Any]? { + var information: CFDictionary? + guard SecCodeCopySigningInformation( + staticCode, + SecCSFlags(rawValue: kSecCSSigningInformation), + &information + ) == errSecSuccess else { return nil } + return information as? [CFString: Any] + } + +} + +actor ProductionExecutorBackend: DeviceExecutionBackend { + nonisolated let capabilityState: ExecutorCapabilityState + private let installation: ExecutorInstallationVerifying + private let runner: ExecutorSubprocessRunning + private let identities: DeviceIdentityProviding + private let bookmarks: ExecutorWorkspaceBookmarkStore + + init( + identities: DeviceIdentityProviding, + bookmarks: ExecutorWorkspaceBookmarkStore, + installation: ExecutorInstallationVerifying = ProductionExecutorInstallationVerifier(), + runner: ExecutorSubprocessRunning = FixedExecutorSubprocessRunner() + ) { + self.identities = identities + self.bookmarks = bookmarks + self.installation = installation + self.runner = runner + do { + _ = try installation.verifiedExecutable() + capabilityState = .available + } catch ExecutorInstallationError.unsupportedOS { + capabilityState = .unsupportedOS + } catch ExecutorInstallationError.unsupportedArchitecture { + capabilityState = .unsupportedArchitecture + } catch ExecutorInstallationError.virtualizationEntitlement { + capabilityState = .virtualizationUnavailable( + "The executor signature lacks com.apple.security.virtualization" + ) + } catch { + capabilityState = .artifactsUnavailable(error.localizedDescription) + } + } + + func execute( + grantPayload: [String: Any], + deviceID: String, + userID: String + ) async throws -> ExecutionResult { + guard case .available = capabilityState, + let deviceUUID = UUID(uuidString: deviceID), + let sessionValue = grantPayload["session_id"] as? String, + let sessionID = UUID(uuidString: sessionValue), + let fence = grantPayload["fence"] as? Int, + let bookmarkID = grantPayload["workspace_bookmark_id"] as? String else { + throw ExecutorIPCError.executorUnavailable + } + let executable = try installation.verifiedExecutable() + let grantJSON = try JSONSerialization.data( + withJSONObject: grantPayload, + options: [.sortedKeys, .withoutEscapingSlashes] + ) + let bookmark = try await bookmarks.bookmark(identifier: bookmarkID, userID: userID) + let identity = try identities.identity(for: userID, createIfMissing: false) + let publicKey = try identity.publicKey + guard publicKey.algorithm == "P-256" else { throw ExecutorIPCError.unauthenticated } + var secret = Data(count: 32) + guard secret.withUnsafeMutableBytes({ + SecRandomCopyBytes(kSecRandomDefault, 32, $0.baseAddress!) + }) == errSecSuccess else { + throw ExecutorIPCError.executorUnavailable + } + let requestID = UUID() + let unsigned = ExecutorIPCRequest( + requestID: requestID, + sessionID: sessionID, + deviceID: deviceUUID, + fence: fence, + grantJSON: grantJSON, + workspaceBookmark: bookmark, + devicePublicKeyPEM: publicKey.value, + ipcSecret: secret, + signatureDER: Data([0x30, 0x00]) + ) + let signature = try identity.sign(unsigned.signingData()) + let request = ExecutorIPCRequest( + requestID: requestID, + sessionID: sessionID, + deviceID: deviceUUID, + fence: fence, + grantJSON: grantJSON, + workspaceBookmark: bookmark, + devicePublicKeyPEM: publicKey.value, + ipcSecret: secret, + signatureDER: signature + ) + let input = try JSONEncoder().encode(request) + let response = try ExecutorIPCResponse.decode( + await runner.run(executable: executable, input: input) + ) + try response.verify(ipcSecret: secret, expectedRequestID: requestID) + return try Self.executionResult(from: response.resultJSON) + } + + func cancel(actionID: String) async { + await runner.cancel(actionID: actionID) + } + + private static func executionResult(from data: Data) throws -> ExecutionResult { + guard let value = try JSONSerialization.jsonObject(with: data) as? [String: Any], + Set(value.keys) == Set([ + "status", "exit_code", "stdout", "stderr", "truncated", + "duration_ms", "redactions", + ]), + let statusValue = value["status"] as? String, + let status = ExecutionResultStatus(rawValue: statusValue), + let stdout = value["stdout"] as? String, + let stderr = value["stderr"] as? String, + let truncated = value["truncated"] as? Bool, + let duration = value["duration_ms"] as? Int, + let redactions = value["redactions"] as? Int else { + throw ExecutorIPCError.malformed + } + let exitCode = (value["exit_code"] as? NSNumber).map { Int32($0.intValue) } + return ExecutionResult( + status: status, + exitCode: exitCode, + stdout: stdout, + stderr: stderr, + truncated: truncated, + durationMilliseconds: duration, + redactions: redactions + ) + } +} diff --git a/app/Sources/ExecutorService/AppleContainerRuntime.swift b/app/Sources/ExecutorService/AppleContainerRuntime.swift new file mode 100644 index 0000000..c582951 --- /dev/null +++ b/app/Sources/ExecutorService/AppleContainerRuntime.swift @@ -0,0 +1,217 @@ +#if canImport(Containerization) +import Containerization +import ContainerizationArchive +import Foundation + +@available(macOS 26.0, *) +actor AppleContainerRuntime: ContainerRuntime { + nonisolated let capabilityState: ExecutorCapabilityState = .available + + private let manifest: ExecutorArtifactManifestPayload + private let cache: ExecutorArtifactCache + private let cacheRoot: URL + private var running: [UUID: LinuxContainer] = [:] + + init( + manifest: ExecutorArtifactManifestPayload, + cache: ExecutorArtifactCache, + cacheRoot: URL + ) { + self.manifest = manifest + self.cache = cache + self.cacheRoot = cacheRoot + } + + func execute(_ request: ValidatedExecution) async throws -> ExecutionResult { + guard request.grant.capabilities.egressDestinations.isEmpty else { + // Containerization 0.33.3 can omit an interface, but does not expose + // a destination firewall. Deny rather than silently grant broad egress. + throw LocalActionError.invalidBinding("destination-bounded egress is unavailable") + } + let kernelURL = try await bootstrapKernel() + let initArtifact = try artifact(.initImage) + let imageArtifact = try artifact(.workloadImage) + guard imageArtifact.sha256 == request.grant.imageDigest.dropSHA256Prefix else { + throw LocalActionError.invalidBinding("workload image artifact") + } + + let actionID = request.grant.actionID + let identifier = "perch-\(actionID.uuidString.lowercased())" + let stateRoot = cacheRoot.appendingPathComponent("runs/\(identifier)", isDirectory: true) + try FileManager.default.createDirectory( + at: stateRoot, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + + var manager = try await ContainerManager( + kernel: Kernel(path: kernelURL, platform: .linuxArm), + initfsReference: initArtifact.reference, + root: stateRoot, + network: nil, + rosetta: false, + nestedVirtualization: false + ) + let stdout = RuntimeOutputWriter(limit: request.grant.capabilities.limits.outputBytes) + let stderr = RuntimeOutputWriter(limit: request.grant.capabilities.limits.outputBytes) + let workspaceOptions = request.workspace.readOnly + ? ["ro", "nosuid", "nodev"] + : ["rw", "nosuid", "nodev"] + let limits = request.grant.capabilities.limits + let container = try await manager.create( + identifier, + reference: imageArtifact.reference, + rootfsSizeInBytes: limits.diskBytes, + writableLayerSizeInBytes: nil, + readOnly: true, + networking: false + ) { config in + config.cpus = limits.cpuCount + config.memoryInBytes = limits.memoryBytes + config.virtualization = false + config.sockets = [] + config.interfaces = [] + config.dns = nil + config.process.arguments = [request.action.executable] + request.action.arguments + config.process.workingDirectory = request.action.workingDirectory + config.process.environmentVariables = [ + "PATH=/usr/local/bin:/usr/bin:/bin", + "HOME=/nonexistent", + "LANG=C.UTF-8", + ] + config.process.capabilities = LinuxCapabilities() + config.process.noNewPrivileges = true + config.process.rlimits = [ + LinuxRLimit(kind: .cpuTime, limit: UInt64(limits.timeoutSeconds)), + LinuxRLimit(kind: .fileSize, limit: limits.diskBytes), + LinuxRLimit(kind: .numberOfProcesses, limit: UInt64(limits.processCount)), + LinuxRLimit(kind: .openFiles, limit: 256), + LinuxRLimit(kind: .coreFileSize, limit: 0), + ] + config.process.stdout = stdout + config.process.stderr = stderr + config.mounts.append(.share( + source: request.workspace.root.path, + destination: "/workspace", + options: workspaceOptions + )) + config.mounts.append(.any( + type: "tmpfs", + source: "tmpfs", + destination: "/tmp", + options: ["nosuid", "nodev", "noexec", "size=67108864"] + )) + } + running[actionID] = container + let started = ContinuousClock.now + defer { + running.removeValue(forKey: actionID) + try? manager.delete(identifier) + try? FileManager.default.removeItem(at: stateRoot) + } + + do { + try await container.create() + try Task.checkCancellation() + try await container.start() + let status = try await withTaskCancellationHandler { + try await container.wait(timeoutInSeconds: Int64(limits.timeoutSeconds)) + } onCancel: { + Task { try? await container.stop() } + } + try await container.stop() + let elapsed = started.duration(to: .now) + let output = stdout.snapshot() + let errorOutput = stderr.snapshot() + let sanitizer = HostileOutputSanitizer() + let safeOut = sanitizer.sanitize( + output.data, + allowSensitiveDisclosure: request.grant.capabilities.sensitiveOutputDisclosure + ) + let safeError = sanitizer.sanitize( + errorOutput.data, + allowSensitiveDisclosure: request.grant.capabilities.sensitiveOutputDisclosure + ) + return ExecutionResult( + status: status.exitCode == 0 ? .completed : .failed, + exitCode: status.exitCode, + stdout: safeOut.text, + stderr: safeError.text, + truncated: output.truncated || errorOutput.truncated, + durationMilliseconds: Int(elapsed.components.seconds * 1_000) + + Int(elapsed.components.attoseconds / 1_000_000_000_000_000), + redactions: safeOut.redactions + safeError.redactions + ) + } catch is CancellationError { + try? await container.stop() + return ExecutionResult( + status: .cancelled, + exitCode: nil, + stdout: "", + stderr: "Execution cancelled.", + truncated: false, + durationMilliseconds: 0, + redactions: 0 + ) + } catch { + try? await container.stop() + throw error + } + } + + func cancel(actionID: UUID) async { + try? await running[actionID]?.stop() + } + + func cleanup(actionID: UUID) async { + try? await running[actionID]?.stop() + running.removeValue(forKey: actionID) + } + + private func artifact(_ kind: ExecutorArtifact.Kind) throws -> ExecutorArtifact { + guard let artifact = manifest.artifacts.first(where: { $0.kind == kind }) else { + throw ArtifactVerificationError.invalidManifest + } + return artifact + } + + private func bootstrapKernel() async throws -> URL { + let kernel = try artifact(.kernel) + let kernelURL = cacheRoot.appendingPathComponent(kernel.sha256) + if FileManager.default.fileExists(atPath: kernelURL.path) { + try await cache.verifyExtracted(kernelURL, artifact: kernel) + return kernelURL + } + let archive = try artifact(.kernelArchive) + let archiveURL = try await cache.verifiedFile(for: archive, allowBootstrap: true) + let extractionRoot = cacheRoot.appendingPathComponent(".extract-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: extractionRoot) } + try FileManager.default.createDirectory(at: extractionRoot, withIntermediateDirectories: false) + let rejected = try ArchiveReader(file: archiveURL).extractContents(to: extractionRoot) + guard rejected.isEmpty else { + throw ArtifactVerificationError.invalidArtifact("kernel archive contains unsafe paths") + } + let extracted = extractionRoot.appendingPathComponent( + "opt/kata/share/kata-containers/vmlinux-6.12.28-153" + ) + try await cache.verifyExtracted(extracted, artifact: kernel) + try FileManager.default.moveItem(at: extracted, to: kernelURL) + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: kernelURL.path) + return kernelURL + } +} + +private final class RuntimeOutputWriter: Writer, @unchecked Sendable { + private let writer: BoundedOutputWriter + init(limit: Int) { writer = BoundedOutputWriter(limit: limit) } + func write(_ data: Data) throws { writer.append(data) } + func close() throws {} + func snapshot() -> (data: Data, truncated: Bool) { writer.snapshot() } +} + +private extension String { + var dropSHA256Prefix: String { + hasPrefix("sha256:") ? String(dropFirst("sha256:".count)) : self + } +} +#endif diff --git a/app/Sources/ExecutorService/ExecutorRequestDecoder.swift b/app/Sources/ExecutorService/ExecutorRequestDecoder.swift new file mode 100644 index 0000000..b8939d8 --- /dev/null +++ b/app/Sources/ExecutorService/ExecutorRequestDecoder.swift @@ -0,0 +1,102 @@ +import Foundation + +@available(macOS 26.0, *) +struct ExecutorRequestDecoder { + func validatedExecution( + request: ExecutorIPCRequest, + grant: [String: Any], + manifest: ExecutorArtifactManifestPayload, + now: Date = Date() + ) throws -> ValidatedExecution { + try ExecutionGrantAuthorization().verify(payload: grant) + let required = Set([ + "grant_id", "action_id", "sequence", "grant_token", "grant_signature", + "action_hash", "parameters_hash", "registry_version", "action_type", + "normalized_parameters", "capabilities", "image_digest", + "workspace_bookmark_id", "result_disclosure_policy", "session_id", + "device_key_fingerprint", "device_id", "fence", "expires_at", + "transition_id", + ]) + guard Set(grant.keys) == required, + let grantID = (grant["grant_id"] as? String).flatMap(UUID.init(uuidString:)), + let actionID = (grant["action_id"] as? String).flatMap(UUID.init(uuidString:)), + let transitionID = (grant["transition_id"] as? String).flatMap(UUID.init(uuidString:)), + let registryVersion = grant["registry_version"] as? String, + let actionType = grant["action_type"] as? String, + let parametersValue = grant["normalized_parameters"], + let capabilitiesValue = grant["capabilities"], + let parametersHash = grant["parameters_hash"] as? String, + let actionHash = grant["action_hash"] as? String, + let token = grant["grant_token"] as? String, + let signature = grant["grant_signature"] as? String, + let imageDigest = grant["image_digest"] as? String, + let fence = grant["fence"] as? Int, + let expiryValue = grant["expires_at"] as? String, + let expiry = ISO8601DateFormatter().date(from: expiryValue), + let disclosure = grant["result_disclosure_policy"] as? [String: Any], + Set(disclosure.keys) == Set(["sensitive_output", "upload"]), + let sensitiveDisclosure = disclosure["sensitive_output"] as? Bool, + let resultUpload = disclosure["upload"] as? Bool else { + throw LocalActionError.invalidBinding("executor grant schema") + } + let parameters = try ExecutorJSON(any: parametersValue) + let capabilities = try ExecutionCapabilities(json: ExecutorJSON(any: capabilitiesValue)) + guard capabilities.egressDestinations.isEmpty, + capabilities.sensitiveOutputDisclosure == sensitiveDisclosure, + capabilities.resultUpload == resultUpload else { + throw LocalActionError.invalidBinding("network or result disclosure policy") + } + guard let workload = manifest.artifacts.first(where: { $0.kind == .workloadImage }) else { + throw ArtifactVerificationError.invalidManifest + } + let approvedImage = "sha256:" + workload.sha256 + let offer = LocalActionOffer( + actionID: actionID, + registryVersion: registryVersion, + actionName: actionType, + normalizedParameters: parameters, + parametersHash: parametersHash, + capabilities: capabilities, + imageDigest: imageDigest, + expiresAt: expiry + ) + let executionGrant = ExecutionGrant( + grantID: grantID, + actionID: actionID, + grantToken: token, + grantSignature: signature, + actionHash: actionHash, + parametersHash: parametersHash, + normalizedParameters: parameters, + capabilities: capabilities, + imageDigest: imageDigest, + deviceID: request.deviceID, + fence: fence, + expiresAt: expiry, + transitionID: transitionID + ) + let workspace = try WorkspaceAccess().open( + bookmark: WorkspaceBookmark(data: request.workspaceBookmark), + mode: capabilities.workspaceMode, + quota: .init( + maximumFiles: min(capabilities.limits.processCount * 1_000, 100_000), + maximumBytes: capabilities.limits.diskBytes + ) + ) + do { + return try ExecutionGrantValidator( + approvedImageDigest: approvedImage + ).validate( + offer: offer, + grant: executionGrant, + connectedDeviceID: request.deviceID, + currentFence: request.fence, + workspace: workspace, + now: now + ) + } catch { + workspace.close() + throw error + } + } +} diff --git a/app/Sources/ExecutorService/PerchExecutorMain.swift b/app/Sources/ExecutorService/PerchExecutorMain.swift new file mode 100644 index 0000000..8387247 --- /dev/null +++ b/app/Sources/ExecutorService/PerchExecutorMain.swift @@ -0,0 +1,122 @@ +#if canImport(ExecutorCore) +import ExecutorCore +#endif +import Foundation + +@main +struct PerchExecutorMain { + static func main() async { + guard #available(macOS 26.0, *) else { + fail("unsupported: Perch Executor requires macOS 26") + } + #if !arch(arm64) + fail("unsupported: Perch Executor requires Apple silicon") + #else + do { + #if SWIFT_PACKAGE + let resourceBundle = Bundle.module + #else + let resourceBundle = Bundle.main + #endif + let adjacentManifestURL = URL(fileURLWithPath: CommandLine.arguments[0]) + .standardizedFileURL + .deletingLastPathComponent() + .appendingPathComponent("ExecutorArtifacts.json", isDirectory: false) + guard let manifestURL = resourceBundle.url( + forResource: "ExecutorArtifacts", + withExtension: "json" + ) ?? (FileManager.default.fileExists(atPath: adjacentManifestURL.path) + ? adjacentManifestURL + : nil) else { + throw ArtifactVerificationError.invalidManifest + } + let manifest = try ExecutorArtifactManifestVerifier().verify( + data: Data(contentsOf: manifestURL) + ) + if CommandLine.arguments == [CommandLine.arguments[0], "--ipc"] { + try await runIPC(manifest: manifest) + return + } + if CommandLine.arguments.contains("--capability") { + print("available containerization=\(manifest.containerizationVersion) architecture=arm64") + return + } + if CommandLine.arguments.contains("--verify-artifacts") { + print("verified signed manifest with \(manifest.artifacts.count) pinned artifacts") + return + } + fail("No execution request was supplied. This service accepts only authenticated app requests.") + } catch { + fail("executor unavailable: \(error.localizedDescription)") + } + #endif + } + + @available(macOS 26.0, *) + private static func runIPC( + manifest: ExecutorArtifactManifestPayload + ) async throws { + let input = try readBoundedStandardInput() + let request = try ExecutorIPCRequest.decode(input) + let grant = try ExecutorIPCAuthenticator().verify(request) + guard let grantIDValue = grant["grant_id"] as? String, + let grantID = UUID(uuidString: grantIDValue) else { + throw ExecutorIPCError.malformed + } + let cacheRoot = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + )[0].appendingPathComponent("Perch/Executor", isDirectory: true) + try ExecutorIPCReplayGuard( + root: cacheRoot.appendingPathComponent("ConsumedIPC", isDirectory: true) + ).claim(requestID: request.requestID, grantID: grantID) + + let execution = try ExecutorRequestDecoder().validatedExecution( + request: request, + grant: grant, + manifest: manifest + ) + defer { execution.workspace.close() } + let artifactCacheRoot = cacheRoot.appendingPathComponent("Artifacts", isDirectory: true) + let runtime = AppleContainerRuntime( + manifest: manifest, + cache: ExecutorArtifactCache(root: artifactCacheRoot), + cacheRoot: artifactCacheRoot + ) + let result = try await runtime.execute(execution) + var object = result.protocolObject + object["status"] = result.status.rawValue + let resultJSON = try JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys, .withoutEscapingSlashes] + ) + let response = try ExecutorIPCResponse( + requestID: request.requestID, + resultJSON: resultJSON, + ipcSecret: request.ipcSecret + ) + let encoded = try JSONEncoder().encode(response) + guard encoded.count <= ExecutorIPCResponse.maximumBytes else { + throw ExecutorIPCError.oversized + } + FileHandle.standardOutput.write(encoded) + } + + private static func readBoundedStandardInput() throws -> Data { + var result = Data() + while true { + guard let chunk = try FileHandle.standardInput.read(upToCount: 64 * 1_024), + !chunk.isEmpty else { break } + result.append(chunk) + guard result.count <= ExecutorIPCRequest.maximumBytes else { + throw ExecutorIPCError.oversized + } + } + return result + } + + private static func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data((message + "\n").utf8)) + Foundation.exit(78) + } +} diff --git a/app/Sources/LocalConversationStore.swift b/app/Sources/LocalConversationStore.swift index 5ba4ece..8c8816a 100644 --- a/app/Sources/LocalConversationStore.swift +++ b/app/Sources/LocalConversationStore.swift @@ -13,9 +13,6 @@ struct LocalConversationRecord: Codable, Identifiable { } final class LocalConversationStore { - private static let configDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".danotch") - private static let storeFile = configDir.appendingPathComponent("conversations.json") - // All reads/writes go through this serial queue. `upsert` is a // read-modify-write over the whole file and gets called synchronously from // the main actor on every tool_start/tool_result/text_flush/done WebSocket @@ -23,7 +20,7 @@ final class LocalConversationStore { // write on the main thread multiple times a second, which shows up as UI // stutter. Moving it to a background serial queue keeps the writes // ordered (no lost updates) without blocking the UI thread. - private static let ioQueue = DispatchQueue(label: "com.danotch.conversationstore", qos: .utility) + private let ioQueue = DispatchQueue(label: "engineering.super.Perch.conversationstore", qos: .utility) private struct StoreFile: Codable { var conversations: [LocalConversationRecord] @@ -31,8 +28,12 @@ final class LocalConversationStore { private let encoder: JSONEncoder private let decoder: JSONDecoder + private let accountDataStore: AccountDataStore + private var activeUserID: String? + private var generation = 0 - init() { + init(accountDataStore: AccountDataStore = AccountDataStore()) { + self.accountDataStore = accountDataStore encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.dateEncodingStrategy = .iso8601 @@ -41,8 +42,16 @@ final class LocalConversationStore { decoder.dateDecodingStrategy = .iso8601 } + func activate(userID: String?) { + ioQueue.sync { + activeUserID = userID + generation += 1 + } + } + func loadAll() -> [LocalConversationRecord] { - guard let data = try? Data(contentsOf: Self.storeFile), + guard let url = currentStoreURL(), + let data = try? Data(contentsOf: url), let file = try? decoder.decode(StoreFile.self, from: data) else { return [] } @@ -57,7 +66,9 @@ final class LocalConversationStore { /// (main actor) don't block on disk I/O. Writes for the same store are /// strictly ordered since they all funnel through `ioQueue`. func upsert(_ record: LocalConversationRecord) { - Self.ioQueue.async { [self] in + let scheduledGeneration = ioQueue.sync { generation } + ioQueue.async { [self] in + guard scheduledGeneration == generation, activeUserID != nil else { return } var records = loadAll() if let idx = records.firstIndex(where: { $0.id == record.id }) { records[idx] = record @@ -69,7 +80,8 @@ final class LocalConversationStore { } func markInProgressInterrupted() { - Self.ioQueue.sync { [self] in + ioQueue.sync { [self] in + guard activeUserID != nil else { return } var records = loadAll() var changed = false let now = Date() @@ -97,14 +109,19 @@ final class LocalConversationStore { private func save(_ records: [LocalConversationRecord]) { do { - try FileManager.default.createDirectory(at: Self.configDir, withIntermediateDirectories: true, attributes: nil) + guard let url = currentStoreURL() else { return } let file = StoreFile(conversations: records.sorted { $0.updatedAt > $1.updatedAt }) let data = try encoder.encode(file) - try data.write(to: Self.storeFile, options: [.atomic]) + try accountDataStore.writeSecurely(data, to: url) } catch { print("[Perch] LocalConversationStore save failed: \(error.localizedDescription)") } } + + private func currentStoreURL() -> URL? { + guard let activeUserID else { return nil } + return try? accountDataStore.conversationsURL(for: activeUserID) + } } private extension TaskStatus { diff --git a/app/Sources/Models.swift b/app/Sources/Models.swift index 89540ef..060ab48 100644 --- a/app/Sources/Models.swift +++ b/app/Sources/Models.swift @@ -17,6 +17,107 @@ struct DraftCard: Codable { let recipient: String? } +enum LocalConsentConfirmation: String, Codable, CaseIterable, Hashable { + case shell + case write + case sensitiveRead + case sensitiveDisclosure + case resultUpload +} + +enum LocalConsentCardState: String, Codable { + case pending + case approving + case approved + case rejected + case unavailable + case failed +} + +struct LocalExecutionConsentCard: Codable, Equatable { + let actionID: String + let registryVersion: String + let actionType: String + let actionHash: String + let parametersHash: String + let normalizedParametersJSON: Data + let capabilitiesJSON: Data + let workspaceBookmarkID: String + let command: [String] + let workspaceMode: String + let egressDestinations: [String] + let sensitiveFileAccess: Bool + let sensitiveDisclosure: Bool + let resultUpload: Bool + let expiresAt: Date + var selectedWorkspacePath: String? + var confirmations: Set + var state: LocalConsentCardState + var error: String? + + var requiredConfirmations: Set { + var required: Set = [] + if actionType == "shell.execute" { required.insert(.shell) } + if workspaceMode == "read_write" { required.insert(.write) } + if sensitiveFileAccess { required.insert(.sensitiveRead) } + if sensitiveDisclosure { required.insert(.sensitiveDisclosure) } + if resultUpload { required.insert(.resultUpload) } + return required + } + + var canApprove: Bool { + state == .pending + && selectedWorkspacePath != nil + && egressDestinations.isEmpty + && confirmations.isSuperset(of: requiredConfirmations) + && expiresAt > Date() + } +} + +enum LocalConsentCardEvent { + case selectedWorkspace(String) + case toggled(LocalConsentConfirmation) + case approve + case approved + case reject + case failed(String) +} + +struct LocalConsentCardReducer { + func reduce( + _ card: LocalExecutionConsentCard, + event: LocalConsentCardEvent + ) -> LocalExecutionConsentCard { + var next = card + switch event { + case .selectedWorkspace(let path): + guard card.state == .pending else { return card } + next.selectedWorkspacePath = path + case .toggled(let confirmation): + guard card.state == .pending, + card.requiredConfirmations.contains(confirmation) else { return card } + if next.confirmations.contains(confirmation) { + next.confirmations.remove(confirmation) + } else { + next.confirmations.insert(confirmation) + } + case .approve: + guard card.canApprove else { return card } + next.state = .approving + case .approved: + guard card.state == .approving else { return card } + next.state = .approved + case .reject: + guard card.state == .pending || card.state == .approving else { return card } + next.state = .rejected + case .failed(let error): + next.state = .failed + next.error = error + } + return next + } +} + struct ChatMessage: Identifiable, Codable { let id: String let role: String @@ -25,6 +126,7 @@ struct ChatMessage: Identifiable, Codable { var toolInput: String? var toolOutput: String? let draftCard: DraftCard? + var localExecutionCard: LocalExecutionConsentCard? let timestamp: Date init( @@ -35,6 +137,7 @@ struct ChatMessage: Identifiable, Codable { toolInput: String? = nil, toolOutput: String? = nil, draftCard: DraftCard? = nil, + localExecutionCard: LocalExecutionConsentCard? = nil, timestamp: Date ) { self.id = id @@ -44,6 +147,7 @@ struct ChatMessage: Identifiable, Codable { self.toolInput = toolInput self.toolOutput = toolOutput self.draftCard = draftCard + self.localExecutionCard = localExecutionCard self.timestamp = timestamp } } diff --git a/app/Sources/NotchViewModel.swift b/app/Sources/NotchViewModel.swift index 44f158b..305fc25 100644 --- a/app/Sources/NotchViewModel.swift +++ b/app/Sources/NotchViewModel.swift @@ -1,3 +1,7 @@ +import AppKit +#if canImport(ExecutorCore) +import ExecutorCore +#endif import Foundation import SwiftUI import Combine @@ -191,7 +195,31 @@ class NotchSettings: ObservableObject { } enum APIConfig { - static let baseURL = "http://localhost:3001" + static let baseURL = Bundle.main.object(forInfoDictionaryKey: "PerchAPIBaseURL") as? String + ?? ProcessInfo.processInfo.environment["PERCH_API_BASE_URL"] + ?? "http://localhost:3001" + static let gatewayURL = Bundle.main.object(forInfoDictionaryKey: "PerchDeviceGatewayURL") as? String + ?? ProcessInfo.processInfo.environment["PERCH_DEVICE_GATEWAY_URL"] + ?? "ws://localhost:3001/api/device-gateway" + + static var baseURLValue: URL { + validatedURL(baseURL, secureScheme: "https") + } + + static var gatewayURLValue: URL { + validatedURL(gatewayURL, secureScheme: "wss") + } + + private static func validatedURL(_ value: String, secureScheme: String) -> URL { + guard let url = URL(string: value), + url.user == nil, url.password == nil, + url.scheme == secureScheme + || ((url.host == "localhost" || url.host == "127.0.0.1") + && (url.scheme == "http" || url.scheme == "ws")) else { + fatalError("Perch endpoint must use \(secureScheme) outside local development") + } + return url + } } private enum CachedFormatters { @@ -219,6 +247,10 @@ class NotchViewModel: ObservableObject { var lastViewBeforeCollapse: NotchViewState = .overview var authManager: AuthManager? + let deviceConnection: DeviceConnection + @Published var connectionState: DeviceConnectionState = .signedOut + @Published var connectionAnnouncement = "" + private var activeUserID: String? // App connection states (keyed by app_type: gmail, googlecalendar, googledocs, github) @Published var appConnected: [String: Bool] = [:] @@ -244,9 +276,6 @@ class NotchViewModel: ObservableObject { private var checkoutPollAttempts = 0 private let checkoutPollMaxAttempts = 40 // ~2 min at 3s intervals - // WebSocket send callback (set by WebSocketServer) - var wsSend: (([String: Any]) -> Void)? - // Pending connection requests from agent (requestId → metadata) @Published var pendingConnectionRequests: [String: PendingConnectionRequest] = [:] @@ -254,12 +283,13 @@ class NotchViewModel: ObservableObject { @Published var agentMonitor = AgentMonitor() @Published var nowPlaying = NowPlayingMonitor() let statsMonitor = SystemStatsMonitor() - private let localConversationStore = LocalConversationStore() + private let localConversationStore: LocalConversationStore private var clockTimer: Timer? private var shimmerTimer: Timer? private var agentMonitorCancellable: AnyCancellable? private var settingsCancellable: AnyCancellable? private var cancellables: Set = [] + private var selectedExecutionWorkspaces: [String: URL] = [:] var timeString: String { CachedFormatters.time.string(from: currentTime) } var periodString: String { CachedFormatters.period.string(from: currentTime) } @@ -267,7 +297,12 @@ class NotchViewModel: ObservableObject { var shortDateString: String { CachedFormatters.shortDate.string(from: currentTime) } var shortTimeString: String { CachedFormatters.shortTime.string(from: currentTime) } - init() { + init( + localConversationStore: LocalConversationStore = LocalConversationStore(), + deviceConnection: DeviceConnection = DeviceConnection() + ) { + self.localConversationStore = localConversationStore + self.deviceConnection = deviceConnection startClock() startShimmerCycle() // Forward agent monitor changes to trigger view updates @@ -280,6 +315,16 @@ class NotchViewModel: ObservableObject { settingsCancellable = settings.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() } + deviceConnection.$state.sink { [weak self] state in + self?.connectionState = state + }.store(in: &cancellables) + deviceConnection.onStateAnnouncement = { [weak self] message in + self?.connectionAnnouncement = message + } + deviceConnection.onEvent = { [weak self] userID, event in + guard let self, self.activeUserID == userID else { return } + self.processEvent(event) + } } deinit { @@ -332,6 +377,41 @@ class NotchViewModel: ObservableObject { @Published var threadHistory: [ThreadSummary] = [] @Published var isLoadingHistory = false + func switchAccount(to session: AuthSession?) { + // Fence queued disk writes and clear every user-derived in-memory + // collection before loading the next partition. + activeUserID = nil + localConversationStore.activate(userID: nil) + tasks = [] + threadHistory = [] + notifications = [] + unreadCount = 0 + scheduledTasks = [] + pendingConnectionRequests = [:] + appConnected = [:] + providerConfigs = [] + billingStatus = nil + viewState = .overview + dismissPeek() + deviceConnection.logout() + guard let session else { return } + activeUserID = session.userId + localConversationStore.activate(userID: session.userId) + deviceConnection.start(session: session) + } + + func retryDeviceConnection() { + deviceConnection.retry() + } + + func cancelDeviceConnection() { + deviceConnection.cancel() + } + + func reenrollDevice() { + deviceConnection.reenroll() + } + func loadThreadHistory() { let records = localConversationStore.loadAll() isLoadingHistory = false @@ -481,7 +561,7 @@ class NotchViewModel: ObservableObject { @Published var unreadCount: Int = 0 func loadNotifications() { - guard let auth = authManager else { return } + guard let auth = authManager, let requestUserID = auth.session?.userId else { return } Task { await auth.ensureValidToken() guard let token = auth.accessToken else { return } @@ -508,6 +588,7 @@ class NotchViewModel: ObservableObject { } await MainActor.run { + guard self.activeUserID == requestUserID else { return } self.notifications = parsed self.unreadCount = parsed.filter { !$0.read }.count } @@ -515,7 +596,7 @@ class NotchViewModel: ObservableObject { } func loadUnreadCount() { - guard let auth = authManager else { return } + guard let auth = authManager, let requestUserID = auth.session?.userId else { return } Task { await auth.ensureValidToken() guard let token = auth.accessToken else { return } @@ -527,7 +608,10 @@ class NotchViewModel: ObservableObject { let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let count = json["count"] as? Int else { return } - await MainActor.run { self.unreadCount = count } + await MainActor.run { + guard self.activeUserID == requestUserID else { return } + self.unreadCount = count + } } } @@ -597,7 +681,7 @@ class NotchViewModel: ObservableObject { @Published var scheduledTasks: [ScheduledTask] = [] func loadScheduledTasks() { - guard let auth = authManager else { return } + guard let auth = authManager, let requestUserID = auth.session?.userId else { return } Task { await auth.ensureValidToken() guard let token = auth.accessToken else { return } @@ -634,6 +718,7 @@ class NotchViewModel: ObservableObject { } await MainActor.run { + guard self.activeUserID == requestUserID else { return } self.scheduledTasks = parsed print("[Perch] loadScheduledTasks: \(parsed.count) tasks") } @@ -709,7 +794,7 @@ class NotchViewModel: ObservableObject { // MARK: - App Connections (Generic) func checkAppStatus(_ appType: String) { - guard let auth = authManager else { return } + guard let auth = authManager, let requestUserID = auth.session?.userId else { return } appLoading[appType] = true appError[appType] = nil // clear any stale error from a previous connect attempt Task { @@ -728,6 +813,7 @@ class NotchViewModel: ObservableObject { return } await MainActor.run { + guard self.activeUserID == requestUserID else { return } self.appConnected[appType] = connected self.appLoading[appType] = false } @@ -736,6 +822,10 @@ class NotchViewModel: ObservableObject { func connectApp(_ appType: String) { guard let auth = authManager else { return } + guard case .connected(let deviceID) = deviceConnection.state else { + appError[appType] = "Connect this Mac before linking an app." + return + } appLoading[appType] = true appError[appType] = nil Task { @@ -751,6 +841,10 @@ class NotchViewModel: ObservableObject { request.httpMethod = "POST" request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.httpBody = try? JSONSerialization.data(withJSONObject: [ + "device_id": deviceID, + "replace": self.appConnected[appType] == true, + ]) guard let (data, _) = try? await URLSession.shared.data(for: request), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { @@ -778,14 +872,20 @@ class NotchViewModel: ObservableObject { } if let redirectUrl = json["redirectUrl"] as? String, - let url = URL(string: redirectUrl) { + let url = URL(string: redirectUrl), + let attemptID = json["attempt_id"] as? String { await MainActor.run { NSWorkspace.shared.open(url) } // Poll for connection until OAuth completes (up to 2 minutes) for _ in 0..<40 { try? await Task.sleep(nanoseconds: 3_000_000_000) - var statusReq = URLRequest(url: URL(string: "\(APIConfig.baseURL)/api/apps/\(appType)/status")!) + var statusComponents = URLComponents(string: "\(APIConfig.baseURL)/api/apps/\(appType)/status")! + statusComponents.queryItems = [ + URLQueryItem(name: "attempt_id", value: attemptID), + URLQueryItem(name: "device_id", value: deviceID), + ] + var statusReq = URLRequest(url: statusComponents.url!) statusReq.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") if let (sData, _) = try? await URLSession.shared.data(for: statusReq), let sJson = try? JSONSerialization.jsonObject(with: sData) as? [String: Any], @@ -852,7 +952,8 @@ class NotchViewModel: ObservableObject { // MARK: - Billing func loadBillingStatus() { - guard let auth = authManager, auth.accessToken != nil else { return } + guard let auth = authManager, auth.accessToken != nil, + let requestUserID = auth.session?.userId else { return } billingLoading = true billingError = nil @@ -902,6 +1003,7 @@ class NotchViewModel: ObservableObject { ) await MainActor.run { + guard self.activeUserID == requestUserID else { return } self.billingStatus = parsed self.billingLoading = false self.billingError = nil @@ -978,7 +1080,8 @@ class NotchViewModel: ObservableObject { // MARK: - Provider Config (BYOK) func loadProviderConfigs() { - guard let auth = authManager, let token = auth.accessToken else { return } + guard let auth = authManager, let token = auth.accessToken, + let requestUserID = auth.session?.userId else { return } providerLoading = true Task { @@ -1007,6 +1110,7 @@ class NotchViewModel: ObservableObject { } await MainActor.run { + guard self.activeUserID == requestUserID else { return } self.providerConfigs = parsed self.providerLoading = false if let active = parsed.first(where: { $0.isActive }) { @@ -1021,7 +1125,8 @@ class NotchViewModel: ObservableObject { } func loadProviderModels() { - guard let auth = authManager, let token = auth.accessToken else { + guard let auth = authManager, let token = auth.accessToken, + let requestUserID = auth.session?.userId else { let provider = activeProviderType activeModelProvider = provider modelOptions = fallbackModelOptions(for: provider) @@ -1064,6 +1169,7 @@ class NotchViewModel: ObservableObject { } await MainActor.run { + guard self.activeUserID == requestUserID else { return } self.isLoadingModels = false self.activeModelProvider = provider self.modelOptions = models.isEmpty ? self.fallbackModelOptions(for: provider) : models @@ -1275,6 +1381,7 @@ class NotchViewModel: ObservableObject { // MARK: - Event Processing func processEvent(_ json: [String: Any]) { + guard activeUserID != nil else { return } guard let type = json["type"] as? String else { return } switch type { case "subagent_event": processSubagentEvent(json) @@ -1283,10 +1390,220 @@ class NotchViewModel: ObservableObject { case "peek_notification": processPeekNotification(json) case "connection_request": processConnectionRequest(json) case "pending_action": processPendingAction(json) + case "local_action_offered": processLocalActionOffer(json) default: break } } + private func processLocalActionOffer(_ json: [String: Any]) { + guard let actionID = json["action_id"] as? String, + UUID(uuidString: actionID) != nil, + let sessionID = (json["session_id"] ?? json["run_id"]) as? String, + let registryVersion = json["registry_version"] as? String, + let actionType = json["action_type"] as? String, + let actionHash = json["action_hash"] as? String, + let parametersHash = json["parameters_hash"] as? String, + let parametersValue = json["normalized_parameters"], + let capabilitiesValue = json["capabilities"], + let workspaceBookmarkID = json["workspace_bookmark_id"] as? String, + let expiresValue = json["expires_at"] as? String, + let expiresAt = ISO8601DateFormatter().date(from: expiresValue), + let taskIndex = tasks.firstIndex(where: { + $0.id == sessionID || $0.threadId == sessionID + }), + !tasks[taskIndex].chatHistory.contains(where: { $0.id == actionID }), + let parametersJSON = try? JSONSerialization.data( + withJSONObject: parametersValue, + options: [.sortedKeys, .withoutEscapingSlashes] + ), + let capabilitiesJSON = try? JSONSerialization.data( + withJSONObject: capabilitiesValue, + options: [.sortedKeys, .withoutEscapingSlashes] + ), + let parameters = try? ExecutorJSON(any: parametersValue), + let capabilities = try? ExecutionCapabilities( + json: ExecutorJSON(any: capabilitiesValue) + ), + let action = try? LocalActionRegistry.shared.resolve( + registryVersion: registryVersion, + name: actionType, + parameters: parameters, + capabilities: capabilities + ) else { + return + } + let networkUnavailable = !capabilities.egressDestinations.isEmpty + let executorUnavailable = executorUnavailableReason + let unavailable = networkUnavailable || executorUnavailable != nil + let card = LocalExecutionConsentCard( + actionID: actionID.lowercased(), + registryVersion: registryVersion, + actionType: actionType, + actionHash: actionHash, + parametersHash: parametersHash, + normalizedParametersJSON: parametersJSON, + capabilitiesJSON: capabilitiesJSON, + workspaceBookmarkID: workspaceBookmarkID, + command: [action.executable] + action.arguments, + workspaceMode: capabilities.workspaceMode.rawValue, + egressDestinations: capabilities.egressDestinations, + sensitiveFileAccess: capabilities.sensitiveFileAccess, + sensitiveDisclosure: capabilities.sensitiveOutputDisclosure, + resultUpload: capabilities.resultUpload, + expiresAt: expiresAt, + selectedWorkspacePath: nil, + confirmations: [], + state: unavailable ? .unavailable : .pending, + error: networkUnavailable + ? "Network access is unavailable; this executor always runs offline." + : executorUnavailable + ) + tasks[taskIndex].chatHistory.append(ChatMessage( + id: actionID, + role: "local_execution_consent", + content: action.displayName, + localExecutionCard: card, + timestamp: Date() + )) + tasks[taskIndex].status = .awaitingApproval + persistTask(at: taskIndex) + } + + private var executorUnavailableReason: String? { + switch deviceConnection.executorCapabilityState { + case .available: + return nil + case .unsupportedOS: + return "Local VM execution requires macOS 26 or newer." + case .unsupportedArchitecture: + return "Local VM execution requires Apple silicon." + case .artifactsUnavailable: + return "The signed executor or its pinned VM artifacts are not verified." + case .virtualizationUnavailable: + return "The signed executor does not have the required virtualization entitlement." + } + } + + func chooseExecutionWorkspace(actionID: String) { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.canCreateDirectories = false + panel.prompt = "Select Workspace" + guard panel.runModal() == .OK, let url = panel.url else { return } + selectedExecutionWorkspaces[actionID] = url + updateLocalConsent(actionID) { + LocalConsentCardReducer().reduce($0, event: .selectedWorkspace(url.path)) + } + } + + func toggleExecutionConfirmation( + actionID: String, + confirmation: LocalConsentConfirmation + ) { + updateLocalConsent(actionID) { + LocalConsentCardReducer().reduce($0, event: .toggled(confirmation)) + } + } + + func approveLocalExecution(actionID: String) { + guard let workspace = selectedExecutionWorkspaces[actionID] else { return } + updateLocalConsent(actionID) { + LocalConsentCardReducer().reduce($0, event: .approve) + } + guard let card = localConsentCard(actionID), + card.state == .approving, + let actionUUID = UUID(uuidString: card.actionID), + let capabilitiesObject = try? JSONSerialization.jsonObject( + with: card.capabilitiesJSON + ), + let capabilities = try? ExecutionCapabilities( + json: ExecutorJSON(any: capabilitiesObject) + ) else { return } + Task { + do { + try await deviceConnection.decideLocalAction( + actionID: actionUUID, + actionHash: card.actionHash, + parametersHash: card.parametersHash, + capabilities: capabilities, + workspaceURL: workspace, + workspaceBookmarkID: card.workspaceBookmarkID, + highRiskShell: card.actionType == "shell.execute", + expiresAt: card.expiresAt, + approved: true + ) + await MainActor.run { + self.updateLocalConsent(actionID) { + LocalConsentCardReducer().reduce($0, event: .approved) + } + } + } catch { + await MainActor.run { + self.updateLocalConsent(actionID) { + LocalConsentCardReducer().reduce( + $0, + event: .failed(error.localizedDescription) + ) + } + } + } + } + } + + func rejectLocalExecution(actionID: String) { + guard let card = localConsentCard(actionID), + let actionUUID = UUID(uuidString: card.actionID), + let capabilitiesObject = try? JSONSerialization.jsonObject( + with: card.capabilitiesJSON + ), + let capabilities = try? ExecutionCapabilities( + json: ExecutorJSON(any: capabilitiesObject) + ) else { return } + updateLocalConsent(actionID) { + LocalConsentCardReducer().reduce($0, event: .reject) + } + Task { + try? await deviceConnection.decideLocalAction( + actionID: actionUUID, + actionHash: card.actionHash, + parametersHash: card.parametersHash, + capabilities: capabilities, + workspaceURL: nil, + workspaceBookmarkID: card.workspaceBookmarkID, + highRiskShell: card.actionType == "shell.execute", + expiresAt: card.expiresAt, + approved: false + ) + } + } + + private func localConsentCard(_ actionID: String) -> LocalExecutionConsentCard? { + for task in tasks { + if let message = task.chatHistory.first(where: { $0.id == actionID }) { + return message.localExecutionCard + } + } + return nil + } + + private func updateLocalConsent( + _ actionID: String, + transform: (LocalExecutionConsentCard) -> LocalExecutionConsentCard + ) { + for taskIndex in tasks.indices { + guard let messageIndex = tasks[taskIndex].chatHistory.firstIndex( + where: { $0.id == actionID } + ), let card = tasks[taskIndex].chatHistory[messageIndex].localExecutionCard else { + continue + } + tasks[taskIndex].chatHistory[messageIndex].localExecutionCard = transform(card) + persistTask(at: taskIndex) + return + } + } + private func processPendingAction(_ json: [String: Any]) { guard let actionId = json["action_id"] as? String, let sessionId = json["session_id"] as? String, @@ -1411,11 +1728,6 @@ class NotchViewModel: ObservableObject { await MainActor.run { self.pendingConnectionRequests[requestId]?.status = .approved self.updateConnectionRequestMessage(requestId, status: .approved) - self.wsSend?([ - "type": "connection_response", - "request_id": requestId, - "approved": true, - ]) } return } @@ -1425,11 +1737,6 @@ class NotchViewModel: ObservableObject { await MainActor.run { self.pendingConnectionRequests[requestId]?.status = .denied self.updateConnectionRequestMessage(requestId, status: .denied) - self.wsSend?([ - "type": "connection_response", - "request_id": requestId, - "approved": false, - ]) } } } @@ -1441,11 +1748,6 @@ class NotchViewModel: ObservableObject { updateConnectionRequestMessage(requestId, status: .denied) - wsSend?([ - "type": "connection_response", - "request_id": requestId, - "approved": false, - ]) } private func updateConnectionRequestMessage(_ requestId: String, status: ConnectionRequestStatus) { @@ -1859,9 +2161,12 @@ class NotchViewModel: ObservableObject { // POST to backend (refresh token first if needed) let auth = authManager + let requestUserID = auth?.session?.userId let historyForRequest = recentHistoryPayload(for: sid, currentMessage: message) Task { await auth?.ensureValidToken() + guard requestUserID != nil, auth?.session?.userId == requestUserID, + self.activeUserID == requestUserID else { return } guard let url = URL(string: "\(APIConfig.baseURL)/api/chat") else { return } var request = URLRequest(url: url) request.httpMethod = "POST" @@ -1881,6 +2186,7 @@ class NotchViewModel: ObservableObject { URLSession.shared.dataTask(with: request) { [weak self] data, response, error in DispatchQueue.main.async { guard let self = self, + self.activeUserID == requestUserID, let idx = self.tasks.firstIndex(where: { $0.id == sid }) else { return } if let error = error { withAnimation(.snappy(duration: 0.3)) { diff --git a/app/Sources/SecureSessionStore.swift b/app/Sources/SecureSessionStore.swift new file mode 100644 index 0000000..862f842 --- /dev/null +++ b/app/Sources/SecureSessionStore.swift @@ -0,0 +1,118 @@ +import Foundation +import Security + +enum SecureStoreError: Error, Equatable { + case missing + case duplicate + case interactionDenied + case invalidData + case unexpectedStatus(Int32) +} + +protocol KeychainDataClient { + func read(service: String, account: String) throws -> Data + func add(_ data: Data, service: String, account: String) throws + func update(_ data: Data, service: String, account: String) throws + func delete(service: String, account: String) throws +} + +struct DataProtectionKeychainClient: KeychainDataClient { + private func query(service: String, account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecUseDataProtectionKeychain as String: true, + ] + } + + func read(service: String, account: String) throws -> Data { + var value: CFTypeRef? + var request = query(service: service, account: account) + request[kSecReturnData as String] = true + request[kSecMatchLimit as String] = kSecMatchLimitOne + let status = SecItemCopyMatching(request as CFDictionary, &value) + guard status == errSecSuccess, let data = value as? Data else { + throw mapKeychainStatus(status) + } + return data + } + + func add(_ data: Data, service: String, account: String) throws { + var request = query(service: service, account: account) + request[kSecValueData as String] = data + request[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(request as CFDictionary, nil) + guard status == errSecSuccess else { throw mapKeychainStatus(status) } + } + + func update(_ data: Data, service: String, account: String) throws { + let status = SecItemUpdate( + query(service: service, account: account) as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + guard status == errSecSuccess else { throw mapKeychainStatus(status) } + } + + func delete(service: String, account: String) throws { + let status = SecItemDelete(query(service: service, account: account) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw mapKeychainStatus(status) + } + } +} + +private func mapKeychainStatus(_ status: OSStatus) -> SecureStoreError { + switch status { + case errSecItemNotFound: return .missing + case errSecDuplicateItem: return .duplicate + case errSecInteractionNotAllowed, errSecAuthFailed, errSecUserCanceled: return .interactionDenied + default: return .unexpectedStatus(status) + } +} + +final class SecureSessionStore { + static let service = "engineering.super.Perch.session" + static let account = "active-session" + + private let keychain: KeychainDataClient + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + init(keychain: KeychainDataClient = DataProtectionKeychainClient()) { + self.keychain = keychain + } + + func load() throws -> AuthSession { + do { + return try decoder.decode( + AuthSession.self, + from: keychain.read(service: Self.service, account: Self.account) + ) + } catch let error as SecureStoreError { + throw error + } catch { + throw SecureStoreError.invalidData + } + } + + func save(_ session: AuthSession) throws { + let data = try encoder.encode(session) + do { + try keychain.add(data, service: Self.service, account: Self.account) + } catch SecureStoreError.duplicate { + try keychain.update(data, service: Self.service, account: Self.account) + } + guard try load() == session else { throw SecureStoreError.invalidData } + } + + func rotate(from expectedUserID: String, to session: AuthSession) throws { + let existing = try load() + guard existing.userId == expectedUserID else { throw SecureStoreError.interactionDenied } + try save(session) + } + + func delete() throws { + try keychain.delete(service: Self.service, account: Self.account) + } +} diff --git a/app/Sources/Theme.swift b/app/Sources/Theme.swift index 7f8d865..3e45017 100644 --- a/app/Sources/Theme.swift +++ b/app/Sources/Theme.swift @@ -145,26 +145,41 @@ func relativeTimeString(_ date: Date, fallbackFormat: String = "MMM d") -> Strin // - never hand-paint sheens, gradient strokes, or borders — the material does that // - neighboring glass surfaces must share a GlassEffectContainer // -// `liquidGlass()` is a thin passthrough to `.glassEffect()` for the few remaining -// call sites that previously used the custom modifier. New code should call -// `.glassEffect(...)` directly with the proper shape. - extension View { + @ViewBuilder + func perchGlass( + tint: Color? = nil, + in shape: S + ) -> some View { + if #available(macOS 26.0, *) { + if let tint { + glassEffect(Glass.regular.tint(tint), in: shape) + } else { + glassEffect(.regular, in: shape) + } + } else { + background( + (tint ?? Color.white).opacity(tint == nil ? 0.08 : 0.18), + in: shape + ) + .overlay(shape.stroke(Color.white.opacity(0.12), lineWidth: 0.5)) + } + } + func liquidGlass( cornerRadius: CGFloat = 14, tint: Color? = nil, intensity: Double = 1.0, elevated: Bool = false ) -> some View { - let glass: Glass = tint.map { Glass.regular.tint($0.opacity(0.6)) } ?? Glass.regular - return self.glassEffect( - glass, + perchGlass( + tint: tint?.opacity(0.6), in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) ) } func glassCell(cornerRadius: CGFloat = 14) -> some View { - glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + perchGlass(in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) } /// Plain content card — flat dark fill, no glass. Per Apple's Liquid Glass @@ -192,7 +207,17 @@ private struct SmartScrollFade: ViewModifier { @State private var fadeTop: Bool = false @State private var fadeBottom: Bool = true + @ViewBuilder func body(content: Content) -> some View { + if #available(macOS 15.0, *) { + tracked(content: content) + } else { + content + } + } + + @available(macOS 15.0, *) + private func tracked(content: Content) -> some View { content .onScrollGeometryChange(for: ScrollEdges.self) { geo in let off = geo.contentOffset.y diff --git a/app/Sources/Views/AgentChatView.swift b/app/Sources/Views/AgentChatView.swift index 94d0071..9af8543 100644 --- a/app/Sources/Views/AgentChatView.swift +++ b/app/Sources/Views/AgentChatView.swift @@ -95,7 +95,7 @@ struct AgentChatView: View { .foregroundStyle(.white) .padding(.horizontal, 10) .frame(height: 24) - .glassEffect(.regular, in: .capsule) + .perchGlass(in: Capsule()) .contentShape(.capsule) .onTapGesture { withAnimation(DN.transition) { @@ -189,6 +189,28 @@ struct AgentChatView: View { ) } + case "local_execution_consent": + if let card = msg.localExecutionCard { + LocalExecutionConsentView( + card: card, + onChooseWorkspace: { + viewModel.chooseExecutionWorkspace(actionID: msg.id) + }, + onToggle: { + viewModel.toggleExecutionConfirmation( + actionID: msg.id, + confirmation: $0 + ) + }, + onApprove: { + viewModel.approveLocalExecution(actionID: msg.id) + }, + onReject: { + viewModel.rejectLocalExecution(actionID: msg.id) + } + ) + } + default: EmptyView() } @@ -233,7 +255,7 @@ struct AgentChatView: View { .foregroundStyle(.white) .padding(.horizontal, 14) .frame(height: 26) - .glassEffect(Glass.regular.tint(DN.activeAccent), in: .capsule) + .perchGlass(tint: DN.activeAccent, in: Capsule()) .contentShape(.capsule) .onTapGesture { viewModel.approveConnectionRequest(requestId) } @@ -242,7 +264,7 @@ struct AgentChatView: View { .foregroundStyle(.white) .padding(.horizontal, 14) .frame(height: 26) - .glassEffect(.regular, in: .capsule) + .perchGlass(in: Capsule()) .contentShape(.capsule) .onTapGesture { viewModel.denyConnectionRequest(requestId) } } @@ -319,7 +341,7 @@ struct AgentChatView: View { } .padding(.horizontal, 14) .padding(.vertical, 9) - .glassEffect(.regular, in: .capsule) + .perchGlass(in: Capsule()) .contentShape(.capsule) .onTapGesture { isMessageFocused = true } } @@ -330,10 +352,7 @@ struct AgentChatView: View { .font(.system(size: 11, weight: .bold)) .foregroundStyle(.white) .frame(width: 24, height: 24) - .glassEffect( - enabled ? Glass.regular.tint(DN.activeAccent) : Glass.regular, - in: .circle - ) + .perchGlass(tint: enabled ? DN.activeAccent : nil, in: Circle()) .opacity(enabled ? 1.0 : 0.55) .contentShape(.circle) .onTapGesture { if enabled { sendMessage() } } @@ -347,6 +366,160 @@ struct AgentChatView: View { } } +private struct LocalExecutionConsentView: View { + let card: LocalExecutionConsentCard + let onChooseWorkspace: () -> Void + let onToggle: (LocalConsentConfirmation) -> Void + let onApprove: () -> Void + let onReject: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Image(systemName: "lock.shield") + .foregroundStyle(DN.warning) + Text("LOCAL VM EXECUTION") + .font(DN.label(10)) + .foregroundStyle(DN.textPrimary) + Spacer() + Text(card.state.rawValue.uppercased()) + .font(DN.label(8)) + .foregroundStyle(statusColor) + } + Text(card.command.map(shellQuote).joined(separator: " ")) + .font(DN.mono(10)) + .foregroundStyle(DN.textSecondary) + .lineLimit(4) + .textSelection(.enabled) + + consentLine( + icon: card.workspaceMode == "read_only" ? "lock" : "pencil", + text: card.workspaceMode == "read_only" + ? "Selected workspace snapshot: read-only" + : "Selected workspace snapshot: read/write" + ) + consentLine(icon: "network.slash", text: "Network: entirely unavailable") + consentLine( + icon: card.sensitiveFileAccess ? "doc.badge.ellipsis" : "doc", + text: card.sensitiveFileAccess + ? "Sensitive file reads requested" + : "Sensitive files are not approved" + ) + consentLine( + icon: card.sensitiveDisclosure ? "exclamationmark.triangle" : "eye.slash", + text: card.sensitiveDisclosure + ? "Sensitive output may leave the VM" + : "Sensitive output is redacted" + ) + consentLine( + icon: card.resultUpload ? "arrow.up.circle" : "internaldrive", + text: card.resultUpload + ? "Bounded result will be uploaded" + : "Execution output remains on this Mac" + ) + + Button(action: onChooseWorkspace) { + HStack { + Image(systemName: "folder") + Text(card.selectedWorkspacePath ?? "SELECT WORKSPACE…") + .lineLimit(1) + Spacer() + } + } + .buttonStyle(.plain) + .font(DN.label(9)) + .foregroundStyle(DN.textPrimary) + .padding(8) + .background(DN.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .disabled(card.state != .pending) + + ForEach( + LocalConsentConfirmation.allCases.filter { + card.requiredConfirmations.contains($0) + }, + id: \.self + ) { confirmation in + Button { + onToggle(confirmation) + } label: { + HStack(spacing: 8) { + Image(systemName: card.confirmations.contains(confirmation) + ? "checkmark.square.fill" + : "square") + Text(confirmationLabel(confirmation)) + .font(DN.body(10)) + Spacer() + } + } + .buttonStyle(.plain) + .foregroundStyle(card.confirmations.contains(confirmation) + ? DN.warning + : DN.textSecondary) + .disabled(card.state != .pending) + } + + if let error = card.error { + Text(error) + .font(DN.body(9)) + .foregroundStyle(DN.accent) + } + + if card.state == .pending { + HStack { + Button("DENY", action: onReject) + .buttonStyle(.plain) + .foregroundStyle(DN.textSecondary) + Spacer() + Button("RUN ONCE", action: onApprove) + .buttonStyle(.plain) + .foregroundStyle(card.canApprove ? DN.warning : DN.textDisabled) + .disabled(!card.canApprove) + } + .font(DN.label(9)) + } + } + .padding(12) + .background(DN.surface) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(DN.borderVisible, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var statusColor: Color { + switch card.state { + case .approved: return DN.success + case .rejected, .failed, .unavailable: return DN.accent + default: return DN.warning + } + } + + private func consentLine(icon: String, text: String) -> some View { + HStack(spacing: 7) { + Image(systemName: icon).frame(width: 12) + Text(text) + } + .font(DN.body(9)) + .foregroundStyle(DN.textSecondary) + } + + private func confirmationLabel(_ value: LocalConsentConfirmation) -> String { + switch value { + case .shell: return "Approve this exact shell command once" + case .write: return "Allow writes to the disposable snapshot" + case .sensitiveRead: return "Allow sensitive file reads for this action" + case .sensitiveDisclosure: return "Allow sensitive output disclosure" + case .resultUpload: return "Allow this bounded result upload" + } + } + + private func shellQuote(_ value: String) -> String { + value.contains(" ") ? "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" : value + } +} + // MARK: - User bubble (right-aligned glass capsule) private struct UserBubble: View { @@ -413,7 +586,7 @@ private struct ThinkingBubble: View { } .padding(.horizontal, 14) .padding(.vertical, 10) - .glassEffect(.regular, in: .capsule) + .perchGlass(in: Capsule()) .onAppear { Timer.scheduledTimer(withTimeInterval: 0.35, repeats: true) { _ in withAnimation(.easeInOut(duration: 0.25)) { @@ -440,7 +613,7 @@ private struct ToolBubble: View { .font(.system(size: 10, weight: .semibold)) .foregroundStyle(DN.accent.opacity(0.8)) .frame(width: 22, height: 22) - .glassEffect(.regular, in: .circle) + .perchGlass(in: Circle()) Text(toolCompletedText(name)) .font(.system(size: 11, weight: .semibold)) @@ -482,7 +655,7 @@ private struct ToolBubble: View { .padding(.horizontal, 12) .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) - .glassEffect(.regular, in: .rect(cornerRadius: 12)) + .perchGlass(in: RoundedRectangle(cornerRadius: 12)) .contentShape(.rect(cornerRadius: 12)) .onTapGesture { guard hasOutput else { return } @@ -741,7 +914,7 @@ struct DraftCardView: View { .foregroundStyle(.white) .padding(.horizontal, 12) .frame(height: 24) - .glassEffect(.regular, in: .capsule) + .perchGlass(in: Capsule()) .contentShape(.capsule) .onTapGesture { onReject() } @@ -750,7 +923,7 @@ struct DraftCardView: View { .foregroundStyle(.white) .padding(.horizontal, 12) .frame(height: 24) - .glassEffect(Glass.regular.tint(DN.activeAccent), in: .capsule) + .perchGlass(tint: DN.activeAccent, in: Capsule()) .contentShape(.capsule) .onTapGesture { onApprove() } } diff --git a/app/Sources/Views/ChatModelSelectorView.swift b/app/Sources/Views/ChatModelSelectorView.swift index a63acf1..342b413 100644 --- a/app/Sources/Views/ChatModelSelectorView.swift +++ b/app/Sources/Views/ChatModelSelectorView.swift @@ -81,7 +81,7 @@ struct ChatModelSelectorView: View { .padding(.leading, 6) .padding(.trailing, 8) .frame(width: maxWidth, height: 26) - .glassEffect(.regular, in: .capsule) + .perchGlass(in: Capsule()) .contentShape(.capsule) } .menuStyle(.borderlessButton) diff --git a/app/Sources/Views/NotchContentView.swift b/app/Sources/Views/NotchContentView.swift index 371ab35..800dea5 100644 --- a/app/Sources/Views/NotchContentView.swift +++ b/app/Sources/Views/NotchContentView.swift @@ -362,7 +362,7 @@ private struct TodayPage: View { .padding(.horizontal, 14) .padding(.vertical, 9) .frame(maxWidth: .infinity) - .glassEffect(.regular, in: .capsule) + .perchGlass(in: Capsule()) .contentShape(Rectangle()) // `simultaneousGesture` (not `.onTapGesture`) so this still fires even // though the TextField above already claims its own tap — otherwise @@ -377,7 +377,7 @@ private struct TodayPage: View { .font(.system(size: 11, weight: .bold)) .foregroundStyle(.white) .frame(width: 24, height: 24) - .glassEffect(enabled ? Glass.regular.tint(DN.activeAccent) : Glass.regular, in: .circle) + .perchGlass(tint: enabled ? DN.activeAccent : nil, in: Circle()) .opacity(enabled ? 1 : 0.55) .contentShape(.circle) .onTapGesture { if enabled { submit() } } @@ -787,7 +787,7 @@ struct IconActionButton: View { Image(systemName: icon) .symbolRenderingMode(.hierarchical) } - .buttonStyle(.glass) + .buttonStyle(.bordered) .controlSize(.small) .tint(.clear) .help(label) diff --git a/app/Sources/Views/NotchShellView.swift b/app/Sources/Views/NotchShellView.swift index 22675c5..aa7893c 100644 --- a/app/Sources/Views/NotchShellView.swift +++ b/app/Sources/Views/NotchShellView.swift @@ -157,7 +157,7 @@ struct NotchShellView: View { // Liquid glass base let glass = Color.clear - .glassEffect(Glass.regular.tint(Color.black.opacity(0.18)), in: shape) + .perchGlass(tint: Color.black.opacity(0.18), in: shape) .clipShape(shape) // Black-to-transparent gradient overlaid on top so the physical notch @@ -230,18 +230,27 @@ struct NotchShellView: View { @ViewBuilder private var expandedContent: some View { - switch viewModel.viewState { - case .overview, .taskList, .agentChat, .agents: - NotchContentView(viewModel: viewModel) - case .stats: - StatsPanel(viewModel: viewModel) - case .processList: - ProcessListPanel(viewModel: viewModel) - case .settings: - SettingsPanel(viewModel: viewModel) - case .notifications: - NotificationsPanel(viewModel: viewModel) + VStack(spacing: 6) { + if viewModel.connectionState.needsAttention { + DeviceConnectionBanner(viewModel: viewModel) + } + Group { + switch viewModel.viewState { + case .overview, .taskList, .agentChat, .agents: + NotchContentView(viewModel: viewModel) + case .stats: + StatsPanel(viewModel: viewModel) + case .processList: + ProcessListPanel(viewModel: viewModel) + case .settings: + SettingsPanel(viewModel: viewModel) + case .notifications: + NotificationsPanel(viewModel: viewModel) + } + } } + .accessibilityElement(children: .contain) + .accessibilityLabel(viewModel.connectionAnnouncement) } @ViewBuilder @@ -322,7 +331,7 @@ struct NotchShellView: View { .foregroundStyle(.white) .padding(.horizontal, 6) .frame(height: 16) - .glassEffect(.regular, in: .capsule) + .perchGlass(in: Capsule()) Text(caption) .font(.system(size: 10)) .foregroundStyle(.secondary) @@ -392,10 +401,7 @@ struct NotchShellView: View { .foregroundStyle(.white) .padding(.horizontal, 12) .frame(height: 22) - .glassEffect( - isActive ? Glass.regular.tint(DN.activeAccent) : Glass.regular, - in: .capsule - ) + .perchGlass(tint: isActive ? DN.activeAccent : nil, in: Capsule()) .contentShape(.capsule) .onTapGesture(perform: action) } @@ -408,10 +414,7 @@ struct NotchShellView: View { .font(.system(size: 12, weight: .medium)) .foregroundStyle(.white) .frame(width: 26, height: 22) - .glassEffect( - isActive ? Glass.regular.tint(DN.activeAccent) : Glass.regular, - in: .capsule - ) + .perchGlass(tint: isActive ? DN.activeAccent : nil, in: Capsule()) .contentShape(.capsule) .onTapGesture(perform: action) .overlay(alignment: .topTrailing) { @@ -512,6 +515,123 @@ struct NotchShoulder: Shape { } } +private struct DeviceConnectionBanner: View { + @ObservedObject var viewModel: NotchViewModel + + var body: some View { + HStack(spacing: 8) { + Image(systemName: viewModel.connectionState.icon) + Text(viewModel.connectionState.title) + .font(.system(size: 10, weight: .semibold)) + Spacer() + if viewModel.connectionState.canRetry { + Button("Retry") { viewModel.retryDeviceConnection() } + .buttonStyle(.plain) + .font(.system(size: 10, weight: .semibold)) + } + if viewModel.connectionState.canCancel { + Button("Cancel") { viewModel.cancelDeviceConnection() } + .buttonStyle(.plain) + .font(.system(size: 10)) + } + } + .foregroundStyle(.white) + .padding(.horizontal, 10) + .frame(height: 26) + .background(Color.orange.opacity(0.24), in: Capsule()) + .accessibilityElement(children: .combine) + .accessibilityLabel(viewModel.connectionState.announcement) + } +} + +extension DeviceConnectionState { + var needsAttention: Bool { + switch self { + case .offline, .expired, .revoked, .interrupted, + .reenrollmentRequired, .unsupportedProtocol: + return true + default: + return false + } + } + + var canRetry: Bool { + switch self { + case .offline, .expired, .interrupted, .reenrollmentRequired, .cancelled: + return true + default: + return false + } + } + + var canCancel: Bool { + switch self { + case .enrolling, .connecting, .offline, .expired, .interrupted: + return true + default: + return false + } + } + + var canReenroll: Bool { + switch self { + case .revoked, .reenrollmentRequired: + return true + default: + return false + } + } + + var title: String { + switch self { + case .signedOut: return "Signed out" + case .enrolling: return "Enrolling this Mac" + case .connecting: return "Connecting" + case .connected: return "Securely connected" + case .offline: return "Offline" + case .expired: return "Session expired" + case .revoked: return "Device revoked" + case .interrupted: return "Connection interrupted" + case .reenrollmentRequired: return "Re-enrollment required" + case .unsupportedProtocol: return "Update required" + case .cancelled: return "Connection cancelled" + } + } + + var detail: String { + switch self { + case .offline(let reason), .revoked(let reason), + .interrupted(let reason), .reenrollmentRequired(let reason): + return reason + case .connected(let deviceID): + return "Authenticated outbound device channel · \(deviceID)" + case .connecting(let attempt): + return "Requesting a one-use gateway ticket · attempt \(attempt + 1)" + case .expired: + return "The one-use ticket or account session expired." + case .unsupportedProtocol: + return "This app cannot safely connect to the server protocol." + case .enrolling: + return "Creating a non-exportable device identity and binding it to this account." + case .signedOut: + return "Sign in to connect this Mac." + case .cancelled: + return "Automatic reconnection is paused." + } + } + + var icon: String { + switch self { + case .connected: return "lock.shield.fill" + case .connecting, .enrolling: return "arrow.triangle.2.circlepath" + case .signedOut, .cancelled: return "pause.circle" + case .unsupportedProtocol: return "arrow.down.app" + case .revoked: return "xmark.shield" + default: return "wifi.exclamationmark" + } + } +} + // MARK: - Notifications Panel struct NotificationsPanel: View { @@ -559,7 +679,7 @@ struct NotificationsPanel: View { .foregroundStyle(.white) .padding(.horizontal, 10) .frame(height: 20) - .glassEffect(.regular, in: .capsule) + .perchGlass(in: Capsule()) } .buttonStyle(.plain) } @@ -767,6 +887,10 @@ struct SettingsPanel: View { settingsToggle("Keep open in chat", $viewModel.settings.keepOpenInChat) } + section(title: "Device Connection") { + deviceConnectionSection + } + section(title: "Billing") { billingSection } @@ -809,6 +933,43 @@ struct SettingsPanel: View { // MARK: - Integrations grid + private var deviceConnectionSection: some View { + VStack(alignment: .leading, spacing: 8) { + Label( + viewModel.connectionState.title, + systemImage: viewModel.connectionState.icon + ) + .font(.system(size: 12, weight: .semibold)) + Text(viewModel.connectionState.detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let email = viewModel.authManager?.session?.email { + Text("Active account: \(email)") + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + HStack(spacing: 8) { + if viewModel.connectionState.canReenroll { + Button("Re-enroll") { viewModel.reenrollDevice() } + .buttonStyle(.bordered).controlSize(.small) + } + if viewModel.connectionState.canRetry { + Button("Retry") { viewModel.retryDeviceConnection() } + .buttonStyle(.bordered).controlSize(.small) + } + if viewModel.connectionState.canCancel { + Button("Cancel") { viewModel.cancelDeviceConnection() } + .buttonStyle(.bordered).controlSize(.small) + } + Button("Switch Account") { viewModel.authManager?.logout() } + .buttonStyle(.bordered).controlSize(.small) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(viewModel.connectionState.announcement) + } + private var integrationsSection: some View { let apps: [(type: String, name: String, icon: String)] = [ ("gmail", "Gmail", "envelope.fill"), @@ -901,13 +1062,13 @@ struct SettingsPanel: View { HStack(spacing: 8) { Button("Refresh") { viewModel.loadBillingStatus() } - .buttonStyle(.glass) + .buttonStyle(.bordered) .controlSize(.small) .tint(.clear) if viewModel.billingStatus?.requiresPurchase == true { Button("Buy $5") { viewModel.startCheckout() } - .buttonStyle(.glass) + .buttonStyle(.bordered) .controlSize(.small) .tint(DN.accent) } @@ -997,7 +1158,7 @@ struct SettingsPanel: View { } else { if serverKeyAllowed { Button("Use") { viewModel.deactivateAllProviders() } - .buttonStyle(.glass).controlSize(.small).tint(.clear) + .buttonStyle(.bordered).controlSize(.small).tint(.clear) } else { Text("Trial ended") .font(.caption) @@ -1095,7 +1256,7 @@ struct ProviderRow: View { Button("Use") { viewModel.activateProviderConfig(provider: providerType) } - .buttonStyle(.glass) + .buttonStyle(.bordered) .controlSize(.small) .tint(.clear) } @@ -1116,7 +1277,7 @@ struct ProviderRow: View { .textFieldStyle(.plain) .padding(.horizontal, 12) .padding(.vertical, 8) - .glassEffect(.regular, in: .capsule) + .perchGlass(in: Capsule()) Picker("Model", selection: Binding( get: { modelId.isEmpty ? defaultModel : modelId }, @@ -1135,7 +1296,7 @@ struct ProviderRow: View { let model = modelId.isEmpty ? defaultModel : modelId viewModel.verifyProviderKey(provider: providerType, apiKey: apiKey, modelId: model) } - .buttonStyle(.glass) + .buttonStyle(.bordered) .controlSize(.small) .tint(.clear) .disabled(apiKey.isEmpty || isVerifying) @@ -1146,7 +1307,7 @@ struct ProviderRow: View { viewModel.saveProviderConfig(provider: providerType, apiKey: apiKey, modelId: model) apiKey = "" } - .buttonStyle(.glass) + .buttonStyle(.bordered) .controlSize(.small) .tint(DN.activeAccent) .foregroundStyle(.white) @@ -1160,7 +1321,7 @@ struct ProviderRow: View { apiKey = "" modelId = defaultModel } - .buttonStyle(.glass) + .buttonStyle(.bordered) .controlSize(.small) .tint(.clear) } @@ -1223,10 +1384,10 @@ struct AppConnectionTile: View { } .padding(14) .frame(maxWidth: .infinity, minHeight: 90, alignment: .topLeading) - .glassEffect( - isConnected - ? Glass.regular.tint(Color.green.opacity(0.15)) - : (error != nil ? Glass.regular.tint(Color.orange.opacity(0.12)) : Glass.regular), + .perchGlass( + tint: isConnected + ? Color.green.opacity(0.15) + : (error != nil ? Color.orange.opacity(0.12) : nil), in: RoundedRectangle(cornerRadius: 18, style: .continuous) ) .animation(DN.transition, value: isConnected) @@ -1256,7 +1417,7 @@ struct AppConnectionTile: View { .foregroundStyle(.white) .padding(.horizontal, 10) .frame(height: 22) - .glassEffect(.regular, in: .capsule) + .perchGlass(in: Capsule()) .buttonStyle(.plain) } else { Button(error != nil ? "Retry" : "Connect") { @@ -1267,11 +1428,9 @@ struct AppConnectionTile: View { .foregroundStyle(.white) .padding(.horizontal, 10) .frame(height: 22) - .glassEffect( - error != nil - ? Glass.regular.tint(Color.orange.opacity(0.4)) - : Glass.regular.tint(DN.activeAccent), - in: .capsule + .perchGlass( + tint: error != nil ? Color.orange.opacity(0.4) : DN.activeAccent, + in: Capsule() ) .buttonStyle(.plain) } diff --git a/app/Sources/Views/OnboardingView.swift b/app/Sources/Views/OnboardingView.swift index 7efe2ed..3518a6f 100644 --- a/app/Sources/Views/OnboardingView.swift +++ b/app/Sources/Views/OnboardingView.swift @@ -326,16 +326,57 @@ struct OnboardingView: View { pageHeader("Create your account", "Sync setup, save chat history, and unlock scheduled tasks.") HStack(spacing: OB.itemSpacing) { - modeTab("Sign up", selected: authMode == .signup) { authMode = .signup; auth.error = nil } - modeTab("Sign in", selected: authMode == .login) { authMode = .login; auth.error = nil } + modeTab("Sign up", selected: authMode == .signup) { + authMode = .signup; auth.error = nil; auth.lifecycleState = .credentials + } + modeTab("Sign in", selected: authMode == .login) { + authMode = .login; auth.error = nil; auth.lifecycleState = .credentials + } } - VStack(spacing: OB.itemSpacing) { - if authMode == .signup { - inputField("Full Name", text: $fullName, icon: "person") + if case .checkEmail(let pendingEmail) = auth.lifecycleState { + verificationPanel(email: pendingEmail.isEmpty ? email : pendingEmail) + } else if case .verificationExpired(let pendingEmail) = auth.lifecycleState { + VStack(alignment: .leading, spacing: 12) { + Label("Verification link expired", systemImage: "clock.badge.exclamationmark") + .font(.system(size: 15, weight: .semibold)) + Text("Reopen secure signup to resend verification, or use a different email address.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + HStack { + pillButton("Resend in browser", icon: "safari", style: .accent) { + auth.reopenBrowserSignup(email: pendingEmail) + } + pillButton("Change email", style: .glass) { + email = "" + auth.lifecycleState = .credentials + } + } + } + .padding(14) + .perchGlass(in: RoundedRectangle(cornerRadius: OB.cardRadius)) + } else if auth.lifecycleState == .repairProvisioning { + VStack(alignment: .leading, spacing: 10) { + Label("Your email is verified, but account setup needs another attempt.", systemImage: "arrow.clockwise.circle") + Text("Sign in again to repair the profile, app bootstrap, and trial idempotently.") + .foregroundStyle(.secondary) + } + .font(.system(size: 12)) + .padding(14) + .perchGlass(in: RoundedRectangle(cornerRadius: OB.cardRadius)) + } else { + VStack(spacing: OB.itemSpacing) { + if authMode == .signup { + inputField("Email", text: $email, icon: "envelope") + Text("Signup opens in your browser so the CAPTCHA and verification flow stay on the trusted HTTPS origin.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + inputField("Email", text: $email, icon: "envelope") + inputField("Password", text: $password, icon: "lock", isSecure: true) + } } - inputField("Email", text: $email, icon: "envelope") - inputField("Password", text: $password, icon: "lock", isSecure: true) } if let error = auth.error { @@ -347,6 +388,32 @@ struct OnboardingView: View { } } + private func verificationPanel(email: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Label("Check your email", systemImage: "envelope.badge") + .font(.system(size: 15, weight: .semibold)) + Text("Open the verification link on this Mac or another device. Then return here and sign in.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + HStack { + pillButton("Reopen signup", icon: "safari", style: .glass) { + auth.reopenBrowserSignup(email: email) + } + pillButton("I've verified", icon: "checkmark.circle", style: .accent) { + self.email = email + authMode = .login + auth.returnToSignIn(email: email) + } + pillButton("Change email", style: .glass) { + self.email = "" + auth.lifecycleState = .credentials + } + } + } + .padding(14) + .perchGlass(in: RoundedRectangle(cornerRadius: OB.cardRadius)) + } + // MARK: - Model private var modelStep: some View { @@ -420,7 +487,7 @@ struct OnboardingView: View { } } .padding(OB.cardRadius) - .glassEffect(.regular, in: .rect(cornerRadius: OB.cardRadius)) + .perchGlass(in: RoundedRectangle(cornerRadius: OB.cardRadius)) } private var configuredProviderNotice: some View { @@ -429,7 +496,7 @@ struct OnboardingView: View { .foregroundStyle(OB.accent) .frame(maxWidth: .infinity, alignment: .leading) .padding(12) - .glassEffect(.regular, in: .rect(cornerRadius: OB.cardRadius)) + .perchGlass(in: RoundedRectangle(cornerRadius: OB.cardRadius)) } // MARK: - Apps @@ -469,7 +536,7 @@ struct OnboardingView: View { } } .frame(width: 30, height: 30) - .glassEffect(.regular, in: .circle) + .perchGlass(in: Circle()) Spacer() if connected { Image(systemName: "checkmark.circle.fill") @@ -512,7 +579,10 @@ struct OnboardingView: View { .frame(maxWidth: .infinity) .frame(height: 30) .contentShape(RoundedRectangle(cornerRadius: OB.cardInnerRadius, style: .continuous)) - .glassEffect(connected ? .regular : Glass.regular.tint(OB.accentSubtle), in: .rect(cornerRadius: OB.cardInnerRadius)) + .perchGlass( + tint: connected ? nil : OB.accentSubtle, + in: RoundedRectangle(cornerRadius: OB.cardInnerRadius) + ) } .buttonStyle(.plain) .focusable(false) @@ -520,7 +590,7 @@ struct OnboardingView: View { } .padding(12) .frame(minHeight: 150) - .glassEffect(.regular, in: .rect(cornerRadius: OB.cardRadius)) + .perchGlass(in: RoundedRectangle(cornerRadius: OB.cardRadius)) .onChange(of: viewModel.appLoading[app.appType]) { _, isLoading in if isLoading == false || isLoading == nil { appActionInProgress.remove(app.appType) @@ -548,7 +618,7 @@ struct OnboardingView: View { .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) .frame(width: 28, height: 28) - .glassEffect(.regular, in: .circle) + .perchGlass(in: Circle()) VStack(alignment: .leading, spacing: 2) { Text(title) @@ -572,7 +642,7 @@ struct OnboardingView: View { .padding(.horizontal, 12) .padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) - .glassEffect(.regular, in: .rect(cornerRadius: OB.cardRadius)) + .perchGlass(in: RoundedRectangle(cornerRadius: OB.cardRadius)) } // MARK: - Preferences @@ -594,7 +664,7 @@ struct OnboardingView: View { } } .padding(12) - .glassEffect(.regular, in: .rect(cornerRadius: OB.cardRadius)) + .perchGlass(in: RoundedRectangle(cornerRadius: OB.cardRadius)) VStack(spacing: OB.itemSpacing) { prefToggle("Open chat after sending", $openChatOnSend) @@ -625,7 +695,10 @@ struct OnboardingView: View { .padding(.horizontal, 10) .frame(height: 30) .contentShape(RoundedRectangle(cornerRadius: OB.cardInnerRadius, style: .continuous)) - .glassEffect(selected ? Glass.regular.tint(OB.accentSubtle) : .regular, in: .rect(cornerRadius: OB.cardInnerRadius)) + .perchGlass( + tint: selected ? OB.accentSubtle : nil, + in: RoundedRectangle(cornerRadius: OB.cardInnerRadius) + ) .opacity(atMax ? 0.35 : 1) } .buttonStyle(.plain) @@ -649,7 +722,7 @@ struct OnboardingView: View { .padding(.horizontal, 12) .padding(.vertical, 10) .frame(maxWidth: .infinity) - .glassEffect(.regular, in: .rect(cornerRadius: OB.cardRadius)) + .perchGlass(in: RoundedRectangle(cornerRadius: OB.cardRadius)) } // MARK: - Success @@ -660,7 +733,7 @@ struct OnboardingView: View { .font(.system(size: 28, weight: .semibold)) .foregroundStyle(.white) .frame(width: 58, height: 58) - .glassEffect(Glass.regular.tint(OB.accent.opacity(0.6)), in: .circle) + .perchGlass(tint: OB.accent.opacity(0.6), in: Circle()) Text("You're ready") .font(.system(size: 42, weight: .light, design: .rounded)) @@ -729,7 +802,10 @@ struct OnboardingView: View { ) } } - .glassEffect(style == .glass ? .regular : Glass.regular.tint(.clear), in: .rect(cornerRadius: OB.buttonRadius)) + .perchGlass( + tint: style == .glass ? nil : .clear, + in: RoundedRectangle(cornerRadius: OB.buttonRadius) + ) } .buttonStyle(PlainButtonStyle()) .focusable(false) @@ -743,7 +819,10 @@ struct OnboardingView: View { .frame(maxWidth: .infinity) .frame(height: OB.buttonHeight) .contentShape(RoundedRectangle(cornerRadius: OB.buttonRadius, style: .continuous)) - .glassEffect(selected ? Glass.regular.tint(OB.accentSubtle) : .regular, in: .rect(cornerRadius: OB.buttonRadius)) + .perchGlass( + tint: selected ? OB.accentSubtle : nil, + in: RoundedRectangle(cornerRadius: OB.buttonRadius) + ) } .buttonStyle(PlainButtonStyle()) .focusable(false) @@ -756,7 +835,7 @@ struct OnboardingView: View { .font(.system(size: 14, weight: .medium)) .foregroundStyle(.secondary) .frame(width: 32, height: 32) - .glassEffect(.regular, in: .circle) + .perchGlass(in: Circle()) VStack(alignment: .leading, spacing: 3) { Text(title) @@ -776,7 +855,10 @@ struct OnboardingView: View { .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(RoundedRectangle(cornerRadius: OB.cardRadius, style: .continuous)) - .glassEffect(selected ? Glass.regular.tint(OB.accentSubtle) : .regular, in: .rect(cornerRadius: OB.cardRadius)) + .perchGlass( + tint: selected ? OB.accentSubtle : nil, + in: RoundedRectangle(cornerRadius: OB.cardRadius) + ) } .buttonStyle(PlainButtonStyle()) .focusable(false) @@ -804,7 +886,7 @@ struct OnboardingView: View { .frame(height: OB.inputHeight) .frame(maxWidth: .infinity) .padding(.trailing, 12) - .glassEffect(.regular, in: .rect(cornerRadius: OB.cardInnerRadius)) + .perchGlass(in: RoundedRectangle(cornerRadius: OB.cardInnerRadius)) } // MARK: - Flow @@ -820,12 +902,16 @@ struct OnboardingView: View { } private var canSubmitAuth: Bool { - !email.isEmpty && !password.isEmpty && (authMode != .signup || !fullName.isEmpty) + if authMode == .signup { return !email.isEmpty } + return !email.isEmpty && !password.isEmpty } private var canContinue: Bool { switch step { - case .account: return canSubmitAuth && !auth.isLoading + case .account: + if case .checkEmail = auth.lifecycleState { return false } + if case .verificationExpired = auth.lifecycleState { return false } + return canSubmitAuth && !auth.isLoading case .model: return useDefaultModel || isProviderVerified || activeProvider != nil case .permissions: return true default: return true diff --git a/app/Sources/Views/QuickPromptView.swift b/app/Sources/Views/QuickPromptView.swift index 71217df..90509a4 100644 --- a/app/Sources/Views/QuickPromptView.swift +++ b/app/Sources/Views/QuickPromptView.swift @@ -21,7 +21,7 @@ struct QuickPromptView: View { } .padding(.horizontal, 14) .padding(.vertical, 9) - .glassEffect(.regular, in: .capsule) + .perchGlass(in: Capsule()) .contentShape(.capsule) .onTapGesture { isFocused = true } .frame(maxWidth: .infinity, alignment: .leading) @@ -47,10 +47,7 @@ struct QuickPromptView: View { .font(.system(size: 11, weight: .bold)) .foregroundStyle(.white) .frame(width: 24, height: 24) - .glassEffect( - enabled ? Glass.regular.tint(DN.activeAccent) : Glass.regular, - in: .circle - ) + .perchGlass(tint: enabled ? DN.activeAccent : nil, in: Circle()) .opacity(enabled ? 1 : 0.55) .contentShape(.circle) .onTapGesture { if enabled { submit() } } @@ -85,7 +82,7 @@ struct QuickPromptHintBar: View { .foregroundStyle(.white) .padding(.horizontal, 6) .frame(height: 16) - .glassEffect(.regular, in: .capsule) + .perchGlass(in: Capsule()) Text(caption) .font(.system(size: 10)) .foregroundStyle(.secondary) diff --git a/app/Sources/Views/StatsPanel.swift b/app/Sources/Views/StatsPanel.swift index 60b6298..241513f 100644 --- a/app/Sources/Views/StatsPanel.swift +++ b/app/Sources/Views/StatsPanel.swift @@ -498,7 +498,7 @@ struct ProcessListPanel: View { .font(.system(size: 12, weight: .medium)) .foregroundStyle(.white) .frame(width: 26, height: 22) - .glassEffect(.regular, in: .capsule) + .perchGlass(in: Capsule()) .contentShape(.capsule) .onTapGesture { monitor.refreshProcesses() } } @@ -530,10 +530,7 @@ struct ProcessListPanel: View { .foregroundStyle(.white) .padding(.horizontal, 12) .frame(height: 22) - .glassEffect( - active ? Glass.regular.tint(DN.activeAccent) : Glass.regular, - in: .capsule - ) + .perchGlass(tint: active ? DN.activeAccent : nil, in: Capsule()) .contentShape(.capsule) .onTapGesture { withAnimation(.easeOut(duration: 0.15)) { sortBy = field } @@ -587,7 +584,7 @@ struct ProcessListPanel: View { .foregroundStyle(.white) .padding(.horizontal, 12) .frame(height: 22) - .glassEffect(Glass.regular.tint(.red), in: .capsule) + .perchGlass(tint: .red, in: Capsule()) .contentShape(.capsule) .onTapGesture { monitor.forceKillProcess(pid: proc.pid) } } diff --git a/app/Sources/WebSocketServer.swift b/app/Sources/WebSocketServer.swift deleted file mode 100644 index 092483d..0000000 --- a/app/Sources/WebSocketServer.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Foundation -import Swifter - -class WebSocketServer { - private let server = HttpServer() - private let viewModel: NotchViewModel - private var sessions: [ObjectIdentifier: WebSocketSession] = [:] - - init(viewModel: NotchViewModel) { - self.viewModel = viewModel - setupRoutes() - // Give the ViewModel a way to send messages back - viewModel.wsSend = { [weak self] json in - self?.broadcast(json) - } - } - - private func setupRoutes() { - server["/ws"] = websocket( - text: { [weak self] _, text in - self?.handleMessage(text) - }, - connected: { [weak self] session in - let id = ObjectIdentifier(session) - self?.sessions[id] = session - print("[WS] Client connected (\(self?.sessions.count ?? 0) total)") - }, - disconnected: { [weak self] session in - let id = ObjectIdentifier(session) - self?.sessions.removeValue(forKey: id) - print("[WS] Client disconnected (\(self?.sessions.count ?? 0) total)") - } - ) - - server["/health"] = { _ in - .ok(.text("ok")) - } - } - - private func handleMessage(_ text: String) { - guard let data = text.data(using: .utf8) else { return } - - do { - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - print("[WS] Received non-object JSON") - return - } - - DispatchQueue.main.async { [weak self] in - self?.viewModel.processEvent(json) - } - } catch { - print("[WS] JSON parse error: \(error)") - } - } - - /// Send a JSON message to all connected WebSocket clients - func broadcast(_ json: [String: Any]) { - guard let data = try? JSONSerialization.data(withJSONObject: json), - let text = String(data: data, encoding: .utf8) else { return } - for (_, session) in sessions { - session.writeText(text) - } - } - - func start() { - do { - try server.start(7778, forceIPv4: true) - print("[Perch] WebSocket server running on ws://localhost:7778/ws") - } catch { - print("[Perch] Failed to start WebSocket server: \(error)") - } - } - - func stop() { - server.stop() - print("[Perch] WebSocket server stopped") - } -} diff --git a/app/Tests/PerchTests/AccountDataStoreTests.swift b/app/Tests/PerchTests/AccountDataStoreTests.swift new file mode 100644 index 0000000..aec7d8b --- /dev/null +++ b/app/Tests/PerchTests/AccountDataStoreTests.swift @@ -0,0 +1,108 @@ +import Foundation +import XCTest +@testable import Perch + +private final class MigrationKeychain: KeychainDataClient { + var value: Data? + func read(service: String, account: String) throws -> Data { + guard let value else { throw SecureStoreError.missing } + return value + } + func add(_ data: Data, service: String, account: String) throws { + if value != nil { throw SecureStoreError.duplicate } + value = data + } + func update(_ data: Data, service: String, account: String) throws { value = data } + func delete(service: String, account: String) throws { value = nil } +} + +final class AccountDataStoreTests: XCTestCase { + private var temporary: URL! + + override func setUpWithError() throws { + temporary = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: temporary, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: temporary) + } + + func testPartitionsAreRestrictiveAndNeverShareFiles() throws { + let store = AccountDataStore(rootURL: temporary.appendingPathComponent("Application Support")) + let a = try store.conversationsURL(for: "user-a") + let b = try store.conversationsURL(for: "user-b") + try store.writeSecurely(Data("a".utf8), to: a) + try store.writeSecurely(Data("b".utf8), to: b) + + XCTAssertNotEqual(a.deletingLastPathComponent(), b.deletingLastPathComponent()) + XCTAssertEqual(try permissions(a), 0o600) + XCTAssertEqual(try permissions(a.deletingLastPathComponent()), 0o700) + } + + func testMatchingMigrationIsIdempotentAndRemovesPlaintextOnlyAfterVerification() throws { + let root = temporary.appendingPathComponent("Application Support") + let legacy = temporary.appendingPathComponent(".danotch") + try FileManager.default.createDirectory(at: legacy, withIntermediateDirectories: true) + let session = AuthSession( + accessToken: "secret", refreshToken: "refresh", expiresAt: 100, + userId: "user-a", email: "a@example.com", fullName: "A" + ) + try JSONEncoder().encode(session).write(to: legacy.appendingPathComponent("auth.json")) + let conversations = #"{"conversations":[]}"#.data(using: .utf8)! + try conversations.write(to: legacy.appendingPathComponent("conversations.json")) + let keychain = MigrationKeychain() + let secure = SecureSessionStore(keychain: keychain) + try secure.save(session) + let store = AccountDataStore(rootURL: root) + + try store.migrateLegacyData( + activeSession: session, + legacyDirectory: legacy, + sessionStore: secure + ) + try store.migrateLegacyData( + activeSession: session, + legacyDirectory: legacy, + sessionStore: secure + ) + + XCTAssertTrue(FileManager.default.fileExists(atPath: try store.conversationsURL(for: "user-a").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: legacy.appendingPathComponent("auth.json").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: legacy.appendingPathComponent("conversations.json").path)) + } + + func testCrossAccountLegacyDataIsNeverImportedOrDeleted() throws { + let legacy = temporary.appendingPathComponent(".danotch") + try FileManager.default.createDirectory(at: legacy, withIntermediateDirectories: true) + let legacySession = AuthSession( + accessToken: "secret-a", refreshToken: "refresh-a", expiresAt: nil, + userId: "user-a", email: "a@example.com", fullName: "A" + ) + let active = AuthSession( + accessToken: "secret-b", refreshToken: "refresh-b", expiresAt: nil, + userId: "user-b", email: "b@example.com", fullName: "B" + ) + try JSONEncoder().encode(legacySession).write(to: legacy.appendingPathComponent("auth.json")) + try Data(#"{"conversations":[]}"#.utf8).write( + to: legacy.appendingPathComponent("conversations.json") + ) + let secure = SecureSessionStore(keychain: MigrationKeychain()) + try secure.save(active) + let store = AccountDataStore(rootURL: temporary.appendingPathComponent("support")) + + XCTAssertThrowsError(try store.migrateLegacyData( + activeSession: active, + legacyDirectory: legacy, + sessionStore: secure + )) { XCTAssertEqual($0 as? AccountDataError, .accountMismatch) } + XCTAssertFalse(FileManager.default.fileExists(atPath: (try store.conversationsURL(for: "user-b")).path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: legacy.appendingPathComponent("auth.json").path)) + } + + private func permissions(_ url: URL) throws -> Int { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return (attributes[.posixPermissions] as? NSNumber)?.intValue ?? 0 + } +} diff --git a/app/Tests/PerchTests/ContainerRuntimeTests.swift b/app/Tests/PerchTests/ContainerRuntimeTests.swift new file mode 100644 index 0000000..c261e28 --- /dev/null +++ b/app/Tests/PerchTests/ContainerRuntimeTests.swift @@ -0,0 +1,178 @@ +import CryptoKit +import Foundation +import XCTest +#if canImport(ExecutorCore) +@testable import ExecutorCore +#else +@testable import Perch +#endif + +final class ContainerRuntimeTests: XCTestCase { + func testSignedArtifactManifestPinsReviewedReleaseAndDigests() throws { + let resource = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Resources/ExecutorArtifacts.json") + let data = try Data(contentsOf: resource) + XCTAssertThrowsError(try ExecutorArtifactManifestVerifier().verify(data: data)) + var envelope = try JSONSerialization.jsonObject(with: data) as! [String: Any] + let payload = try XCTUnwrap(Data( + base64Encoded: envelope["signed_payload"] as! String + )) + let fixtureKey = P256.Signing.PrivateKey() + envelope["signature"] = try fixtureKey.signature(for: payload) + .derRepresentation + .base64EncodedString() + let signedFixture = try JSONSerialization.data(withJSONObject: envelope) + let manifest = try ExecutorArtifactManifestVerifier( + publicKeyPEM: fixtureKey.publicKey.pemRepresentation + ).verify(data: signedFixture) + XCTAssertEqual(manifest.containerizationVersion, "0.33.3") + XCTAssertEqual(manifest.containerizationCommit, "a2a1add6c7e1a1665e5397edc49d925c49090b3a") + XCTAssertEqual(manifest.architecture, "arm64") + XCTAssertEqual(Set(manifest.artifacts.map(\.kind)), Set([ + .kernelArchive, .kernel, .initImage, .workloadImage, + ])) + + envelope["signed_payload"] = Data("tampered".utf8).base64EncodedString() + XCTAssertThrowsError(try ExecutorArtifactManifestVerifier( + publicKeyPEM: fixtureKey.publicKey.pemRepresentation + ).verify( + data: JSONSerialization.data(withJSONObject: envelope) + )) + } + + func testGrantValidatesAllBindingsBeforeRuntime() throws { + let root = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let scope = try WorkspaceAccess(bookmarks: TestBookmarkResolver(root)).open( + bookmark: WorkspaceBookmark(data: Data("bookmark".utf8)), + mode: .readOnly + ) + let parameters: ExecutorJSON = .object(["path": .string("Sources"), "depth": .integer(2)]) + let registry = LocalActionRegistry.shared + let capabilities = try testCapabilities() + let actionID = UUID() + let deviceID = UUID() + let digest = "sha256:" + String(repeating: "d", count: 64) + let expiry = Date().addingTimeInterval(60) + let offer = LocalActionOffer( + actionID: actionID, + registryVersion: "1", + actionName: "workspace.inspect", + normalizedParameters: parameters, + parametersHash: registry.parametersHash(parameters), + capabilities: capabilities, + imageDigest: digest, + expiresAt: expiry + ) + let grant = ExecutionGrant( + grantID: UUID(), + actionID: actionID, + grantToken: String(repeating: "x", count: 43), + grantSignature: String(repeating: "y", count: 43), + actionHash: registry.actionHash( + registryVersion: "1", + name: "workspace.inspect", + parameters: parameters + ), + parametersHash: offer.parametersHash, + normalizedParameters: parameters, + capabilities: capabilities, + imageDigest: digest, + deviceID: deviceID, + fence: 7, + expiresAt: expiry, + transitionID: UUID() + ) + let validator = ExecutionGrantValidator(approvedImageDigest: digest) + XCTAssertNoThrow(try validator.validate( + offer: offer, + grant: grant, + connectedDeviceID: deviceID, + currentFence: 7, + workspace: scope + )) + XCTAssertThrowsError(try validator.validate( + offer: offer, + grant: grant, + connectedDeviceID: deviceID, + currentFence: 8, + workspace: scope + )) + let sensitiveRoot = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: sensitiveRoot) } + try Data("TOKEN=secret".utf8).write( + to: sensitiveRoot.appendingPathComponent(".env") + ) + let sensitiveScope = try WorkspaceAccess( + bookmarks: TestBookmarkResolver(sensitiveRoot) + ).open( + bookmark: WorkspaceBookmark(data: Data("sensitive".utf8)), + mode: .readOnly + ) + defer { sensitiveScope.close() } + XCTAssertThrowsError(try validator.validate( + offer: offer, + grant: grant, + connectedDeviceID: deviceID, + currentFence: 7, + workspace: sensitiveScope + )) + } + + func testUnavailableRuntimeHasNoHostFallback() async { + let runtime = UnavailableContainerRuntime(state: .unsupportedOS) + XCTAssertEqual(runtime.capabilityState, .unsupportedOS) + // No native Process/shell fallback is exposed by this runtime. + } + + func testOutputIsBoundedSanitizedAndRedacted() { + let writer = BoundedOutputWriter(limit: 64) + writer.append(Data(repeating: 65, count: 100)) + XCTAssertTrue(writer.snapshot().truncated) + + let hostile = Data("\u{1B}[31mapi_key=supersecretvalue\u{1B}[0m\u{202E}

Create your Perch account

Email verification is required before a trial or integrations are enabled.

`); }); - router.use(authLimiter); - // Sign up with email + password + // Public Supabase signup. No privileged auto-confirm and no session/trial is + // issued before the email link is followed and provisioning succeeds. router.post('/signup', async (req, res) => { + if (!config.containment.publicSignupEnabled) { + res.status(503).json({ + error: 'New account registration is temporarily unavailable.', + code: 'signup_frozen', + }); + return; + } const { email, password, full_name } = req.body; - console.log(`[auth] POST /signup email=${email} name=${full_name}`); + const captchaToken = req.body.captcha_token + ?? req.body['cf-turnstile-response'] + ?? req.body['h-captcha-response']; - if (!email || !password) { - res.status(400).json({ error: 'email and password are required' }); + if (typeof email !== 'string' || typeof password !== 'string' || password.length < 10) { + res.status(202).json({ message: GENERIC_SIGNUP_MESSAGE, state: 'check_email' }); return; } - - // Create user via admin API (auto-confirms, no email verification needed) - const { data: adminData, error: adminError } = await supabase.auth.admin.createUser({ - email, - password, - email_confirm: true, - }); - - if (adminError || !adminData.user) { - console.log(`[auth] Signup failed: ${adminError?.message}`); - res.status(400).json({ error: adminError?.message ?? 'Signup failed' }); + const emailDomain = email.trim().toLowerCase().split('@')[1] ?? ''; + if (config.signup.blockedDomains.has(emailDomain)) { + res.status(202).json({ message: GENERIC_SIGNUP_MESSAGE, state: 'check_email' }); return; } - - const userId = adminData.user.id; - console.log(`[auth] User created: ${userId}`); - - // Now sign them in to get a session with tokens - const { data: loginData, error: loginError } = await anonClient.auth.signInWithPassword({ - email, - password, - }); - - if (loginError || !loginData.session) { - console.log(`[auth] Auto-login failed after signup: ${loginError?.message}`); - res.status(500).json({ error: 'Account created but login failed — try signing in' }); + const captchaValid = await verifyCaptcha(String(captchaToken ?? ''), req.ip); + if (!captchaValid) { + res.status(202).json({ message: GENERIC_SIGNUP_MESSAGE, state: 'check_email' }); return; } - console.log(`[auth] Signup complete, session issued for ${email}`); - - const trialStartedAt = new Date(); - const trialEndsAt = new Date(trialStartedAt.getTime() + 14 * 24 * 60 * 60 * 1000); - - // Create user_profiles row - const { error: profileError } = await supabase.from('danotch_user_profiles').insert({ - id: userId, - email, - full_name: full_name || usernameFromEmail(email), - trial_started_at: trialStartedAt.toISOString(), - trial_ends_at: trialEndsAt.toISOString(), - billing_status: 'trialing', - }); - - if (profileError) { - console.error('[auth] Failed to create profile:', profileError.message); + try { + await Promise.all([ + quota.consume({ + capability: 'signup', + subject: requestQuotaSubject({ ip: req.ip }), + }), + quota.consume({ + capability: 'signup', + subject: requestQuotaSubject({ email }), + }), + ]); + } catch (error) { + if (error instanceof QuotaExceededError) res.setHeader('Retry-After', error.retryAfterSeconds); + res.status(503).json({ message: GENERIC_SIGNUP_MESSAGE, state: 'check_email' }); + return; } - - // Create connected_apps rows for all supported apps - const appRows = SUPPORTED_APPS.map((app) => ({ - user_id: userId, - app_type: app, - active: false, - })); - const { error: appsError } = await supabase.from('danotch_connected_apps').insert(appRows); - - if (appsError) { - console.error('[auth] Failed to create connected_apps:', appsError.message); + const emailHash = hashQuotaSubject(email); + const { error: enrollmentError } = await getAdminDb('bootstrap') + .from('danotch_signup_enrollments') + .upsert({ + email_hash: emailHash, + requested_at: new Date().toISOString(), + status: 'pending', + }, { onConflict: 'email_hash' }); + if (enrollmentError) { + res.status(503).json({ message: GENERIC_SIGNUP_MESSAGE, state: 'check_email' }); + return; } - - res.json({ - user: { - id: userId, - email, - full_name: full_name || usernameFromEmail(email), - billing_status: 'trialing', - trial_started_at: trialStartedAt.toISOString(), - trial_ends_at: trialEndsAt.toISOString(), - }, - session: { - access_token: loginData.session.access_token, - refresh_token: loginData.session.refresh_token, - expires_at: loginData.session.expires_at, + const { data: signupData } = await supabaseAuth.auth.signUp({ + email: email.trim().toLowerCase(), + password, + options: { + emailRedirectTo: `${config.publicBaseUrl}/auth/verified`, + data: { full_name: typeof full_name === 'string' ? full_name.trim().slice(0, 120) : '' }, }, }); + if (signupData.user?.id) { + await getAdminDb('bootstrap').from('danotch_signup_enrollments').update({ + user_id: signupData.user.id, + status: signupData.session ? 'blocked' : 'pending', + }).eq('email_hash', emailHash); + } + const browserRequest = req.is('application/x-www-form-urlencoded'); + if (browserRequest) { + res.type('html').status(202).send(browserStatePage( + 'Check your email', + 'Open the verification link on any device, then return to Perch and sign in.', + )); + return; + } + res.status(202).json({ message: GENERIC_SIGNUP_MESSAGE, state: 'check_email' }); }); // Log in with email + password router.post('/login', async (req, res) => { const { email, password } = req.body; - console.log(`[auth] POST /login email=${email}`); - if (!email || !password) { - res.status(400).json({ error: 'email and password are required' }); + res.status(401).json({ error: GENERIC_LOGIN_ERROR, code: 'invalid_credentials' }); return; } - const { data, error } = await anonClient.auth.signInWithPassword({ + const { data, error } = await supabaseAuth.auth.signInWithPassword({ email, password, }); + if (error?.code === 'email_not_confirmed') { + res.status(403).json({ + error: 'Verify your email before signing in.', + code: 'email_verification_required', + state: 'check_email', + }); + return; + } if (error || !data.session) { - res.status(401).json({ error: error?.message ?? 'Login failed' }); + res.status(401).json({ error: GENERIC_LOGIN_ERROR, code: 'invalid_credentials' }); + return; + } + if (!data.user || !isEmailVerified(data.user)) { + res.status(403).json({ + error: 'Verify your email before signing in.', + code: 'email_verification_required', + state: 'check_email', + }); return; } - // Fetch profile - const { data: profile, error: profileError } = await supabase + try { + await provisionVerifiedUser(data.user); + } catch { + res.status(503).json({ + error: 'Your account is verified but setup is not complete. Try again.', + code: 'provisioning_retry_required', + state: 'repair_provisioning', + }); + return; + } + + const { data: profile, error: profileError } = await createUserDb(data.session.access_token) .from('danotch_user_profiles') .select('full_name, avatar_url, plan, billing_status, trial_started_at, trial_ends_at, lifetime_purchased_at') .eq('id', data.user.id) .single(); if (profileError || !profile) { - res.status(404).json({ error: 'Profile not found. Please sign up again.' }); + res.status(503).json({ + error: 'Your account setup could not be completed. Try again.', + code: 'provisioning_retry_required', + state: 'repair_provisioning', + }); return; } @@ -170,6 +203,71 @@ export function createAuthRoutes(): Router { }); }); + router.get('/verified', (req, res) => { + const failed = req.query.error || req.query.error_code; + res.type('html').status(failed ? 400 : 200).send(browserStatePage( + failed ? 'Verification link expired' : 'Email verified', + failed + ? 'Return to Perch to resend the email or change the address.' + : 'Return to Perch and sign in. This works even when you opened the link on another device.', + )); + }); + + router.post('/resend', async (req, res) => { + const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''; + const token = String(req.body?.captcha_token ?? ''); + if (email && await verifyCaptcha(token, req.ip)) { + try { + await quota.consume({ + capability: 'signup', + subject: requestQuotaSubject({ ip: req.ip, email }), + }); + await supabaseAuth.auth.resend({ + type: 'signup', + email, + options: { emailRedirectTo: `${config.publicBaseUrl}/auth/verified` }, + }); + } catch { + // Anti-enumeration: quota, provider, and account-existence outcomes are + // intentionally indistinguishable. + } + } + res.status(202).json({ message: GENERIC_RESEND_MESSAGE, state: 'check_email' }); + }); + + router.post('/change-email', requireAuth, async (req, res) => { + const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''; + const bearer = req.headers.authorization?.replace(/^Bearer\s+/i, '') ?? ''; + if (!email || !bearer) { + res.status(400).json({ error: 'A valid new email is required.' }); + return; + } + const { error } = await createUserDb(bearer).auth.updateUser( + { email }, + { emailRedirectTo: `${config.publicBaseUrl}/auth/verified` }, + ); + if (error) { + res.status(202).json({ message: GENERIC_RESEND_MESSAGE, state: 'check_email' }); + return; + } + res.status(202).json({ message: GENERIC_RESEND_MESSAGE, state: 'check_email' }); + }); + + router.post('/complete-verification', requireAuth, async (req, res) => { + const bearer = req.headers.authorization?.replace(/^Bearer\s+/i, '') ?? ''; + const { data, error } = await supabaseAuth.auth.getUser(bearer); + if (error || !data.user || !isEmailVerified(data.user)) { + res.status(403).json({ state: 'check_email', code: 'email_verification_required' }); + return; + } + try { + await provisionVerifiedUser(data.user); + res.json({ state: 'ready' }); + } catch { + res.status(503).json({ state: 'repair_provisioning', code: 'provisioning_retry_required' }); + } + }); + // Refresh token router.post('/refresh', async (req, res) => { const { refresh_token } = req.body; @@ -179,12 +277,22 @@ export function createAuthRoutes(): Router { return; } - const { data, error } = await anonClient.auth.refreshSession({ + const { data, error } = await supabaseAuth.auth.refreshSession({ refresh_token, }); if (error || !data.session) { - res.status(401).json({ error: error?.message ?? 'Refresh failed' }); + res.status(401).json({ error: 'Session refresh failed.' }); + return; + } + if (!data.user || !isEmailVerified(data.user)) { + res.status(403).json({ error: 'Email verification required.', code: 'email_verification_required' }); + return; + } + try { + await provisionVerifiedUser(data.user); + } catch { + res.status(503).json({ error: 'Account setup is incomplete.', code: 'provisioning_retry_required' }); return; } @@ -200,9 +308,9 @@ export function createAuthRoutes(): Router { // Get current user profile (requires auth) router.get('/me', requireAuth, async (req, res) => { const userId = req.user!.sub; - const { data: profile, error } = await supabase + const { data: profile, error } = await userDb .from('danotch_user_profiles') - .select('*') + .select('id, email, full_name, avatar_url, plan, created_at, trial_started_at, trial_ends_at, lifetime_purchased_at, billing_status') .eq('id', userId) .single(); @@ -216,3 +324,17 @@ export function createAuthRoutes(): Router { return router; } + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[character] ?? character)); +} + +function browserStatePage(title: string, message: string): string { + return `${escapeHtml(title)}

${escapeHtml(title)}

${escapeHtml(message)}

You may close this tab.

`; +} diff --git a/backend/src/routes/devices.test.ts b/backend/src/routes/devices.test.ts new file mode 100644 index 0000000..bbff6fd --- /dev/null +++ b/backend/src/routes/devices.test.ts @@ -0,0 +1,287 @@ +import { generateKeyPairSync, sign } from 'node:crypto'; +import { createServer } from 'node:http'; +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import express, { type RequestHandler } from 'express'; +import { createDeviceRoutes } from './devices.ts'; +import { + DeviceService, + InMemoryDeviceStore, + deviceChallengeMessage, +} from '../devices/device-service.ts'; + +const servers: Array> = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); +}); + +function auth(userId: string, issuedAt = Date.now()): RequestHandler { + return (req, _res, next) => { + req.user = { sub: userId, email: `${userId}@example.test`, role: 'authenticated', authTime: issuedAt }; + next(); + }; +} + +async function fixture(options: { userId?: string; issuedAt?: number; maxDevices?: number } = {}) { + const now = { value: Date.now() }; + const store = new InMemoryDeviceStore(() => now.value); + const service = new DeviceService(store, { + signingSecret: 'test-ticket-secret-that-is-at-least-32-bytes', + issuer: 'https://api.example.test', + audience: 'wss://api.example.test/api/device-gateway', + challengeTtlMs: 60_000, + ticketTtlMs: 30_000, + maxDevicesPerUser: options.maxDevices ?? 2, + supportedProtocolVersions: [1], + now: () => now.value, + }); + const app = express(); + app.use(express.json({ limit: '16kb' })); + app.use('/api/devices', createDeviceRoutes({ + service, + requireAuth: auth(options.userId ?? 'user-a', options.issuedAt ?? now.value), + freshAuthMaxAgeMs: 5 * 60_000, + })); + const server = createServer(app); + servers.push(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert(address && typeof address !== 'string'); + return { baseUrl: `http://127.0.0.1:${address.port}`, service, store, now }; +} + +function keyPair() { + return generateKeyPairSync('ed25519'); +} + +function p256KeyPair() { + return generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); +} + +async function post(baseUrl: string, path: string, body: unknown) { + return fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +async function challenge(baseUrl: string, purpose: 'enrollment' | 'ticket', deviceId?: string) { + const response = await post(baseUrl, '/api/devices/challenges', { + purpose, + device_id: deviceId, + }); + assert.equal(response.status, 201); + return response.json() as Promise<{ challenge_id: string; nonce: string; expires_at: string }>; +} + +async function enroll( + baseUrl: string, + keys = keyPair(), + replacementDeviceId?: string, +) { + const issued = await challenge(baseUrl, 'enrollment'); + const publicKey = keys.publicKey.export({ type: 'spki', format: 'pem' }).toString(); + const signature = sign( + null, + Buffer.from(deviceChallengeMessage({ + purpose: 'enrollment', + challengeId: issued.challenge_id, + nonce: issued.nonce, + userId: 'user-a', + })), + keys.privateKey, + ).toString('base64url'); + const response = await post(baseUrl, '/api/devices/enroll', { + challenge_id: issued.challenge_id, + display_name: 'Test Mac', + public_key: { algorithm: 'Ed25519', format: 'spki-pem', value: publicKey }, + signature, + replacement_device_id: replacementDeviceId, + }); + return { response, keys }; +} + +test('fresh authentication and signed proof enroll one validated Ed25519 key', async () => { + const { baseUrl } = await fixture(); + const { response } = await enroll(baseUrl); + assert.equal(response.status, 201); + const body = await response.json() as { device: { id: string; status: string; key_algorithm: string } }; + assert.match(body.device.id, /^[0-9a-f-]{36}$/); + assert.equal(body.device.status, 'active'); + assert.equal(body.device.key_algorithm, 'Ed25519'); +}); + +test('fresh authentication and SHA-256 ECDSA proof enroll one P-256 key', async () => { + const { baseUrl } = await fixture(); + const issued = await challenge(baseUrl, 'enrollment'); + const keys = p256KeyPair(); + const publicKey = keys.publicKey.export({ type: 'spki', format: 'pem' }).toString(); + const signature = sign( + 'sha256', + Buffer.from(deviceChallengeMessage({ + purpose: 'enrollment', + challengeId: issued.challenge_id, + nonce: issued.nonce, + userId: 'user-a', + })), + keys.privateKey, + ).toString('base64url'); + const response = await post(baseUrl, '/api/devices/enroll', { + challenge_id: issued.challenge_id, + display_name: 'Secure Enclave Mac', + public_key: { algorithm: 'P-256', format: 'spki-pem', value: publicKey }, + signature, + }); + assert.equal(response.status, 201); + const body = await response.json() as { device: { id: string; key_algorithm: string } }; + assert.equal(body.device.key_algorithm, 'P-256'); + const ticketChallenge = await challenge(baseUrl, 'ticket', body.device.id); + const ticketSignature = sign( + 'sha256', + Buffer.from(deviceChallengeMessage({ + purpose: 'ticket', + challengeId: ticketChallenge.challenge_id, + nonce: ticketChallenge.nonce, + userId: 'user-a', + deviceId: body.device.id, + })), + keys.privateKey, + ).toString('base64url'); + const ticket = await post(baseUrl, `/api/devices/${body.device.id}/tickets`, { + challenge_id: ticketChallenge.challenge_id, + signature: ticketSignature, + protocol_versions: [1], + }); + assert.equal(ticket.status, 201); +}); + +test('enrollment rejects algorithm confusion and malformed P-256 keys and signatures', async () => { + const { baseUrl } = await fixture(); + const p256 = p256KeyPair(); + const ed25519 = keyPair(); + + for (const publicKey of [ + { + algorithm: 'Ed25519', + value: p256.publicKey.export({ type: 'spki', format: 'pem' }).toString(), + }, + { + algorithm: 'P-256', + value: ed25519.publicKey.export({ type: 'spki', format: 'pem' }).toString(), + }, + { + algorithm: 'P-256', + value: '-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----\n', + }, + ]) { + const issued = await challenge(baseUrl, 'enrollment'); + const response = await post(baseUrl, '/api/devices/enroll', { + challenge_id: issued.challenge_id, + display_name: 'Confused Mac', + public_key: { ...publicKey, format: 'spki-pem' }, + signature: Buffer.alloc(64).toString('base64url'), + }); + assert.equal(response.status, 400); + } + + const issued = await challenge(baseUrl, 'enrollment'); + const malformedSignature = await post(baseUrl, '/api/devices/enroll', { + challenge_id: issued.challenge_id, + display_name: 'Malformed Signature Mac', + public_key: { + algorithm: 'P-256', + format: 'spki-pem', + value: p256.publicKey.export({ type: 'spki', format: 'pem' }).toString(), + }, + signature: Buffer.from('not-a-der-signature').toString('base64url'), + }); + assert.equal(malformedSignature.status, 401); +}); + +test('enrollment rejects stale account authentication and invalid key proof', async () => { + const stale = await fixture({ issuedAt: Date.now() - 10 * 60_000 }); + const deniedChallenge = await post(stale.baseUrl, '/api/devices/challenges', { purpose: 'enrollment' }); + assert.equal(deniedChallenge.status, 401); + + const fresh = await fixture(); + const issued = await challenge(fresh.baseUrl, 'enrollment'); + const keys = keyPair(); + const publicKey = keys.publicKey.export({ type: 'spki', format: 'pem' }).toString(); + const deniedProof = await post(fresh.baseUrl, '/api/devices/enroll', { + challenge_id: issued.challenge_id, + display_name: 'Forged Mac', + public_key: { algorithm: 'Ed25519', format: 'spki-pem', value: publicKey }, + signature: Buffer.alloc(64).toString('base64url'), + }); + assert.equal(deniedProof.status, 401); +}); + +test('duplicate keys, device exhaustion, and atomic replacement preserve one binding', async () => { + const { baseUrl, store } = await fixture({ maxDevices: 1 }); + const first = await enroll(baseUrl); + assert.equal(first.response.status, 201); + const firstDevice = (await first.response.json() as { device: { id: string } }).device; + + const duplicate = await enroll(baseUrl, first.keys); + assert.equal(duplicate.response.status, 409); + + const exhausted = await enroll(baseUrl); + assert.equal(exhausted.response.status, 409); + + const replacement = await enroll(baseUrl, keyPair(), firstDevice.id); + assert.equal(replacement.response.status, 201); + const replacementId = (await replacement.response.json() as { device: { id: string } }).device.id; + assert.equal((await store.getDevice('user-a', firstDevice.id))?.status, 'revoked'); + assert.equal((await store.getDevice('user-a', replacementId))?.status, 'active'); +}); + +test('ticket issuance requires the enrolled device proof and negotiates a protocol', async () => { + const { baseUrl, service } = await fixture(); + const enrolled = await enroll(baseUrl); + const device = (await enrolled.response.json() as { device: { id: string } }).device; + await assert.rejects( + service.createChallenge('user-b', 'ticket', device.id), + /Active device not found/, + ); + const issued = await challenge(baseUrl, 'ticket', device.id); + const forged = await post(baseUrl, `/api/devices/${device.id}/tickets`, { + challenge_id: issued.challenge_id, + signature: Buffer.alloc(64).toString('base64url'), + protocol_versions: [1], + }); + assert.equal(forged.status, 401); + const signature = sign( + null, + Buffer.from(deviceChallengeMessage({ + purpose: 'ticket', + challengeId: issued.challenge_id, + nonce: issued.nonce, + userId: 'user-a', + deviceId: device.id, + })), + enrolled.keys.privateKey, + ).toString('base64url'); + const response = await post(baseUrl, `/api/devices/${device.id}/tickets`, { + challenge_id: issued.challenge_id, + signature, + protocol_versions: [1], + }); + assert.equal(response.status, 201); + const body = await response.json() as { ticket: string; protocol_version: number }; + assert.equal(body.protocol_version, 1); + assert.equal(body.ticket.split('.').length, 3); +}); + +test('device revocation and logout fencing are owner-scoped hooks', async () => { + const { baseUrl, store } = await fixture(); + const enrolled = await enroll(baseUrl); + const device = (await enrolled.response.json() as { device: { id: string } }).device; + const revoked = await fetch(`${baseUrl}/api/devices/${device.id}`, { method: 'DELETE' }); + assert.equal(revoked.status, 204); + assert.equal((await store.getDevice('user-a', device.id))?.status, 'revoked'); + + const logout = await post(baseUrl, '/api/devices/logout', {}); + assert.equal(logout.status, 204); +}); diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts new file mode 100644 index 0000000..13e1331 --- /dev/null +++ b/backend/src/routes/devices.ts @@ -0,0 +1,171 @@ +import { Router, type RequestHandler } from 'express'; +import { + DeviceError, + DeviceService, + isUuid, + publicDevice, + type ChallengePurpose, +} from '../devices/device-service.js'; +import { requestQuotaSubject, type QuotaStore } from '../security/quota-store.js'; + +interface DeviceRouteOptions { + service: DeviceService; + requireAuth: RequestHandler; + freshAuthMaxAgeMs: number; + now?: () => number; + onDeviceInvalidated?: (identity: { userId: string; deviceId?: string }) => void; + quota?: QuotaStore; +} + +function handleError(error: unknown, res: Parameters[1]): void { + if (error instanceof DeviceError) { + res.status(error.status).json({ error: error.message, code: error.code }); + return; + } + console.error('[devices] request failed', error); + res.status(500).json({ error: 'Device operation failed', code: 'device_operation_failed' }); +} + +export function createDeviceRoutes(options: DeviceRouteOptions): Router { + const router = Router(); + const now = options.now ?? Date.now; + + router.use(options.requireAuth); + + router.post('/challenges', async (req, res) => { + try { + const purpose = req.body?.purpose as ChallengePurpose; + if (purpose !== 'enrollment' && purpose !== 'ticket') { + throw new DeviceError('invalid_purpose', 400, 'purpose must be enrollment or ticket'); + } + if ( + purpose === 'enrollment' + && (!req.user?.authTime || now() - req.user.authTime > options.freshAuthMaxAgeMs) + ) { + throw new DeviceError( + 'fresh_auth_required', + 401, + 'Fresh account authentication is required for device enrollment', + ); + } + const challenge = await options.service.createChallenge( + req.user!.sub, + purpose, + req.body?.device_id, + ); + res.status(201).json({ + challenge_id: challenge.id, + purpose: challenge.purpose, + device_id: challenge.deviceId, + nonce: challenge.nonce, + expires_at: challenge.expiresAt, + signing_context: 'perch-device-challenge:1', + }); + } catch (error) { + handleError(error, res); + } + }); + + router.post('/enroll', async (req, res) => { + try { + if (!req.user?.authTime || now() - req.user.authTime > options.freshAuthMaxAgeMs) { + throw new DeviceError( + 'fresh_auth_required', + 401, + 'Fresh account authentication is required for device enrollment', + ); + } + if (!options.quota && process.env.NODE_ENV === 'production') { + throw new Error('Enrollment quota store is required in production'); + } + if (options.quota) { + await options.quota.consume({ + capability: 'enrollment', + subject: requestQuotaSubject({ + userId: req.user.sub, + }), + }); + } + const device = await options.service.enroll({ + userId: req.user.sub, + challengeId: req.body?.challenge_id, + displayName: req.body?.display_name, + publicKey: req.body?.public_key ?? {}, + signature: req.body?.signature, + replacementDeviceId: req.body?.replacement_device_id, + }); + if (typeof req.body?.replacement_device_id === 'string') { + options.onDeviceInvalidated?.({ + userId: req.user.sub, + deviceId: req.body.replacement_device_id, + }); + } + res.status(201).json({ device: publicDevice(device) }); + } catch (error) { + handleError(error, res); + } + }); + + router.get('/', async (req, res) => { + try { + const devices = await options.service.listDevices(req.user!.sub); + res.json({ devices: devices.map(publicDevice) }); + } catch (error) { + handleError(error, res); + } + }); + + router.post('/:deviceId/tickets', async (req, res) => { + try { + const deviceId = req.params.deviceId as string; + if (!isUuid(deviceId)) { + throw new DeviceError('invalid_device_id', 400, 'deviceId must be a UUID'); + } + const ticket = await options.service.issueTicket({ + userId: req.user!.sub, + deviceId, + challengeId: req.body?.challenge_id, + signature: req.body?.signature, + protocolVersions: req.body?.protocol_versions, + }); + res.status(201).json({ + ticket: ticket.ticket, + expires_at: ticket.expiresAt, + protocol_version: ticket.protocolVersion, + gateway_auth: { + authorization: 'Bearer ', + subprotocol_prefix: 'perch-ticket.', + query_parameters_allowed: false, + }, + }); + } catch (error) { + handleError(error, res); + } + }); + + router.delete('/:deviceId', async (req, res) => { + try { + const deviceId = req.params.deviceId as string; + if (!isUuid(deviceId)) { + throw new DeviceError('invalid_device_id', 400, 'deviceId must be a UUID'); + } + await options.service.revokeDevice(req.user!.sub, deviceId); + options.onDeviceInvalidated?.({ userId: req.user!.sub, deviceId }); + res.status(204).end(); + } catch (error) { + handleError(error, res); + } + }); + + router.post('/logout', async (req, res) => { + try { + await options.service.fenceUser(req.user!.sub); + options.onDeviceInvalidated?.({ userId: req.user!.sub }); + res.status(204).end(); + } catch (error) { + handleError(error, res); + } + }); + + return router; +} diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index 8a59a9f..30a5b4f 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { requireAuth } from '../middleware/auth.js'; -import { supabase } from '../lib/supabase.js'; +import { userDb as supabase } from '../lib/user-db.js'; export function createNotificationRoutes(): Router { const router = Router(); diff --git a/backend/src/routes/provider.ts b/backend/src/routes/provider.ts index da3e387..fd91737 100644 --- a/backend/src/routes/provider.ts +++ b/backend/src/routes/provider.ts @@ -1,11 +1,12 @@ import { Router } from 'express'; -import { rateLimit } from 'express-rate-limit'; import { requireAuth } from '../middleware/auth.js'; -import { supabase } from '../lib/supabase.js'; +import { userDb as supabase } from '../lib/user-db.js'; +import { getAdminDb } from '../lib/admin-db.js'; import { encrypt, decrypt } from '../providers/crypto.js'; import { createProvider } from '../providers/factory.js'; import { config } from '../config.js'; import type { ProviderType } from '../providers/types.js'; +import { SupabaseQuotaStore, requestQuotaSubject } from '../security/quota-store.js'; const VALID_PROVIDERS: ProviderType[] = ['anthropic', 'openai', 'openrouter']; @@ -19,6 +20,23 @@ type ModelOption = { export function createProviderRoutes(): Router { const router = Router(); + const providerQuota = new SupabaseQuotaStore(getAdminDb('provider')); + router.use(requireAuth); + router.use(async (req, res, next) => { + if (req.method === 'GET' && req.path !== '/models') { + next(); + return; + } + try { + await providerQuota.consume({ + capability: 'provider', + subject: requestQuotaSubject({ userId: req.user!.sub }), + }); + next(); + } catch { + res.status(503).json({ error: 'Provider operations are temporarily unavailable.' }); + } + }); // Get all provider configs for user (keys masked) router.get('/', requireAuth, async (req, res) => { @@ -44,7 +62,7 @@ export function createProviderRoutes(): Router { const userId = req.user!.sub; console.log(`[provider] GET /models userId=${userId}`); - const { data, error } = await supabase + const { data, error } = await getAdminDb('provider') .from('danotch_provider_configs') .select('provider, api_key_encrypted, model_id') .eq('user_id', userId) @@ -143,15 +161,7 @@ export function createProviderRoutes(): Router { }); // Verify a provider key with a minimal test call - const verifyLimiter = rateLimit({ - windowMs: 60 * 1000, // 1 minute - limit: 10, - standardHeaders: true, - legacyHeaders: false, - message: { error: 'Too many verification attempts, please slow down.' }, - }); - - router.post('/verify', verifyLimiter, requireAuth, async (req, res) => { + router.post('/verify', requireAuth, async (req, res) => { const { provider, api_key, model_id } = req.body; if (!provider || !VALID_PROVIDERS.includes(provider)) { @@ -177,7 +187,7 @@ export function createProviderRoutes(): Router { // This prevents marking a stored key as verified when the user was testing a // different raw key in the verify form. const userId = req.user!.sub; - const { data: savedConfig } = await supabase + const { data: savedConfig } = await getAdminDb('provider') .from('danotch_provider_configs') .select('api_key_encrypted') .eq('user_id', userId) @@ -195,7 +205,7 @@ export function createProviderRoutes(): Router { } if (matchesStored) { - await supabase + await getAdminDb('provider') .from('danotch_provider_configs') .update({ verified_at: new Date().toISOString() }) .eq('user_id', userId) diff --git a/backend/src/routes/runs.test.ts b/backend/src/routes/runs.test.ts new file mode 100644 index 0000000..1bf94f0 --- /dev/null +++ b/backend/src/routes/runs.test.ts @@ -0,0 +1,126 @@ +import { createServer } from 'node:http'; +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import express, { type RequestHandler } from 'express'; +import type { RunRouteStore } from './runs.ts'; +import type { DurableRunRecord } from '../protocol/durable-run-store.ts'; + +process.env.SUPABASE_URL ??= 'http://127.0.0.1:54321'; +process.env.SUPABASE_PUBLISHABLE_KEY ??= 'test-publishable-key'; +const { createRunRoutes } = await import('./runs.ts'); + +const runId = '10000000-0000-4000-8000-000000000001'; +const deviceId = '20000000-0000-4000-8000-000000000002'; +const servers: Array> = []; +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => + new Promise((resolve) => server.close(() => resolve())))); +}); + +function run(ownerId = 'owner-a'): DurableRunRecord { + return { + id: runId, + userId: ownerId, + deviceId, + state: 'waiting_for_device', + revision: 1, + input: {}, + checkpoint: null, + terminalCode: null, + terminalResult: null, + createdAt: '2026-07-21T00:00:00.000Z', + updatedAt: '2026-07-21T00:00:00.000Z', + terminalAt: null, + }; +} + +async function fixture(store: RunRouteStore, ownerId = 'owner-a') { + const auth: RequestHandler = (req, _res, next) => { + req.user = { + sub: ownerId, + email: `${ownerId}@example.test`, + role: 'authenticated', + authTime: Date.now(), + }; + next(); + }; + const app = express(); + app.use(express.json()); + app.use('/api/runs', createRunRoutes({ requireAuth: auth, store })); + const server = createServer(app); + servers.push(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert(address && typeof address !== 'string'); + return `http://127.0.0.1:${address.port}`; +} + +test('run list/get and snapshots remain owner scoped', async () => { + const owners: string[] = []; + const store: RunRouteStore = { + async list(ownerId) { + owners.push(ownerId); + return [run(ownerId)]; + }, + async get(ownerId, id) { + owners.push(ownerId); + return id === runId ? run(ownerId) : null; + }, + async cancel() { + throw new Error('not used'); + }, + async snapshot(ownerId, id) { + owners.push(ownerId); + return id === deviceId + ? { device: { id }, cursor: 8, runs: [], actions: [], grants: [] } + : null; + }, + }; + const baseUrl = await fixture(store); + assert.equal((await fetch(`${baseUrl}/api/runs`)).status, 200); + assert.equal((await fetch(`${baseUrl}/api/runs/${runId}`)).status, 200); + const snapshot = await fetch(`${baseUrl}/api/runs/devices/${deviceId}/snapshot`); + assert.equal(snapshot.status, 200); + assert.equal((await snapshot.json() as { cursor: number }).cursor, 8); + assert.deepEqual(owners, ['owner-a', 'owner-a', 'owner-a']); +}); + +test('explicit cancellation is idempotently delegated with authenticated owner', async () => { + const cancellations: Array> = []; + const store: RunRouteStore = { + async list() { return []; }, + async get() { return null; }, + async snapshot() { return null; }, + async cancel(ownerId, id, reason) { + cancellations.push({ ownerId, id, reason }); + return { ...run(ownerId), state: 'cancellation_requested' }; + }, + }; + const baseUrl = await fixture(store); + const response = await fetch(`${baseUrl}/api/runs/${runId}/cancel`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ reason: 'user requested' }), + }); + assert.equal(response.status, 200); + assert.deepEqual(cancellations, [{ + ownerId: 'owner-a', + id: runId, + reason: 'user requested', + }]); +}); + +test('snapshot and run lookup do not reveal a missing foreign-owner resource', async () => { + const store: RunRouteStore = { + async list() { return []; }, + async get() { return null; }, + async cancel() { throw new Error('not found'); }, + async snapshot() { return null; }, + }; + const baseUrl = await fixture(store, 'owner-b'); + assert.equal((await fetch(`${baseUrl}/api/runs/${runId}`)).status, 404); + assert.equal( + (await fetch(`${baseUrl}/api/runs/devices/${deviceId}/snapshot`)).status, + 404, + ); +}); diff --git a/backend/src/routes/runs.ts b/backend/src/routes/runs.ts new file mode 100644 index 0000000..cd777e4 --- /dev/null +++ b/backend/src/routes/runs.ts @@ -0,0 +1,190 @@ +import { randomUUID } from 'node:crypto'; +import { Router, type RequestHandler } from 'express'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import { + getOwnerRun, + listOwnerRuns, + type DurableRunRecord, +} from '../protocol/durable-run-store.js'; +import { getAdminDb } from '../lib/admin-db.js'; +import { userDb } from '../lib/user-db.js'; +import { isUuid } from '../devices/device-service.js'; + +export interface OwnerDeviceSnapshot { + device: Record; + cursor: number; + runs: Array>; + actions: Array>; + grants: Array>; +} + +export interface RunRouteStore { + list(ownerId: string): Promise; + get(ownerId: string, runId: string): Promise; + cancel(ownerId: string, runId: string, reason: string): Promise; + snapshot(ownerId: string, deviceId: string): Promise; +} + +export class SupabaseRunRouteStore implements RunRouteStore { + constructor(private readonly runner: SupabaseClient = getAdminDb('runner')) {} + + list(ownerId: string) { + return listOwnerRuns(ownerId); + } + + get(ownerId: string, runId: string) { + return getOwnerRun(ownerId, runId); + } + + async cancel(ownerId: string, runId: string, reason: string): Promise { + const run = await getOwnerRun(ownerId, runId); + if (!run) throw new RunRouteError(404, 'run_not_found', 'Run not found'); + if (!run.deviceId) { + throw new RunRouteError(409, 'run_not_device_bound', 'Run is not bound to a device'); + } + const { data, error } = await this.runner.rpc('danotch_cancel_run', { + p_cancellation_id: randomUUID(), + p_run_id: runId, + p_user_id: ownerId, + p_device_id: run.deviceId, + p_reason: reason, + }); + if (error || !data) { + if (/late|terminal/i.test(error?.message ?? '')) { + throw new RunRouteError(409, 'run_terminal', 'Terminal runs cannot be cancelled'); + } + throw new Error(error?.message ?? 'Failed to cancel run'); + } + return getOwnerRun(ownerId, runId) as Promise; + } + + async snapshot(ownerId: string, deviceId: string): Promise { + const { data: device, error: deviceError } = await userDb + .from('danotch_devices') + .select('id,display_name,status,current_fence,replay_cursor,enrolled_at,revoked_at') + .eq('id', deviceId) + .eq('user_id', ownerId) + .maybeSingle(); + if (deviceError) throw new Error(deviceError.message); + if (!device) return null; + const [runs, actions, grants] = await Promise.all([ + userDb + .from('danotch_runs') + .select('id,state,revision,checkpoint,terminal_code,waiting_expires_at,created_at,updated_at') + .eq('user_id', ownerId) + .eq('device_id', deviceId) + .order('created_at', { ascending: false }) + .limit(50), + userDb + .from('danotch_local_action_requests') + .select( + 'id,run_id,state,registry_version,action_type,action_hash,normalized_parameters,parameters_hash,capabilities,image_digest,expires_at', + ) + .eq('user_id', ownerId) + .eq('device_id', deviceId) + .not('state', 'in', '("completed","failed","cancelled","expired","rejected")'), + userDb + .from('danotch_execution_grants') + .select( + 'id,action_id,action_hash,parameters_hash,normalized_parameters,capabilities,image_digest,fence,expires_at,consumed_at,revoked_at', + ) + .eq('user_id', ownerId) + .eq('device_id', deviceId) + .is('consumed_at', null) + .is('revoked_at', null), + ]); + for (const result of [runs, actions, grants]) { + if (result.error) throw new Error(result.error.message); + } + return { + device: device as Record, + cursor: Number(device.replay_cursor), + runs: (runs.data ?? []) as Array>, + actions: (actions.data ?? []) as Array>, + grants: (grants.data ?? []) as Array>, + }; + } +} + +class RunRouteError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + } +} + +export function createRunRoutes(options: { + requireAuth: RequestHandler; + store?: RunRouteStore; +}): Router { + const router = Router(); + const store = options.store ?? new SupabaseRunRouteStore(); + router.use(options.requireAuth); + + router.get('/', async (req, res) => { + try { + res.json({ runs: await store.list(req.user!.sub) }); + } catch (error) { + handleError(error, res); + } + }); + + router.get('/devices/:deviceId/snapshot', async (req, res) => { + try { + const deviceId = req.params.deviceId as string; + if (!isUuid(deviceId)) { + throw new RunRouteError(400, 'invalid_device_id', 'deviceId must be a UUID'); + } + const snapshot = await store.snapshot(req.user!.sub, deviceId); + if (!snapshot) throw new RunRouteError(404, 'device_not_found', 'Device not found'); + res.json({ + protocol_version: 1, + generated_at: new Date().toISOString(), + snapshot, + cursor: snapshot.cursor, + }); + } catch (error) { + handleError(error, res); + } + }); + + router.get('/:runId', async (req, res) => { + try { + const runId = req.params.runId as string; + if (!isUuid(runId)) throw new RunRouteError(400, 'invalid_run_id', 'runId must be a UUID'); + const run = await store.get(req.user!.sub, runId); + if (!run) throw new RunRouteError(404, 'run_not_found', 'Run not found'); + res.json({ run }); + } catch (error) { + handleError(error, res); + } + }); + + router.post('/:runId/cancel', async (req, res) => { + try { + const runId = req.params.runId as string; + if (!isUuid(runId)) throw new RunRouteError(400, 'invalid_run_id', 'runId must be a UUID'); + const reason = req.body?.reason; + if (reason !== undefined && (typeof reason !== 'string' || reason.length > 500)) { + throw new RunRouteError(400, 'invalid_reason', 'reason must be at most 500 characters'); + } + res.json({ run: await store.cancel(req.user!.sub, runId, reason ?? '') }); + } catch (error) { + handleError(error, res); + } + }); + + return router; +} + +function handleError(error: unknown, res: Parameters[1]): void { + if (error instanceof RunRouteError) { + res.status(error.status).json({ error: error.message, code: error.code }); + return; + } + console.error('[runs] request failed', error); + res.status(500).json({ error: 'Run operation failed', code: 'run_operation_failed' }); +} diff --git a/backend/src/routes/scheduled-policy.ts b/backend/src/routes/scheduled-policy.ts new file mode 100644 index 0000000..823d7f0 --- /dev/null +++ b/backend/src/routes/scheduled-policy.ts @@ -0,0 +1,59 @@ +const EDITABLE_FIELDS = new Set([ + 'enabled', + 'name', + 'prompt', + 'cron', + 'interval_ms', + 'notify_user', + 'target_app', +]); + +type PatchResult = + | { ok: true; updates: Record } + | { ok: false; error: string }; + +export function validateScheduledPatch(body: unknown): PatchResult { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return { ok: false, error: 'PATCH body must be an object' }; + } + + const input = body as Record; + const fields = Object.keys(input); + if (fields.length === 0) { + return { ok: false, error: 'At least one editable field is required' }; + } + + const rejected = fields.filter((field) => !EDITABLE_FIELDS.has(field)); + if (rejected.length > 0) { + return { ok: false, error: `Protected or unknown fields: ${rejected.join(', ')}` }; + } + + if (input.enabled !== undefined && typeof input.enabled !== 'boolean') { + return { ok: false, error: 'enabled must be a boolean' }; + } + if (input.notify_user !== undefined && typeof input.notify_user !== 'boolean') { + return { ok: false, error: 'notify_user must be a boolean' }; + } + for (const field of ['name', 'prompt', 'cron'] as const) { + if (input[field] !== undefined && (typeof input[field] !== 'string' || input[field].length === 0)) { + return { ok: false, error: `${field} must be a non-empty string` }; + } + } + if ( + input.interval_ms !== undefined + && (typeof input.interval_ms !== 'number' + || !Number.isSafeInteger(input.interval_ms) + || input.interval_ms < 60_000) + ) { + return { ok: false, error: 'interval_ms must be an integer of at least 60000' }; + } + if ( + input.target_app !== undefined + && input.target_app !== null + && (typeof input.target_app !== 'string' || input.target_app.length === 0) + ) { + return { ok: false, error: 'target_app must be a non-empty string or null' }; + } + + return { ok: true, updates: { ...input } }; +} diff --git a/backend/src/routes/scheduled.test.ts b/backend/src/routes/scheduled.test.ts new file mode 100644 index 0000000..4a43d0f --- /dev/null +++ b/backend/src/routes/scheduled.test.ts @@ -0,0 +1,52 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { validateScheduledPatch } from './scheduled-policy.ts'; + +test('scheduler PATCH accepts only documented editable fields', () => { + const result = validateScheduledPatch({ + enabled: false, + name: 'Updated', + prompt: 'New prompt', + cron: '0 9 * * *', + interval_ms: 120_000, + notify_user: true, + target_app: 'gmail', + }); + + assert.equal(result.ok, true); + if (result.ok) { + assert.deepEqual(Object.keys(result.updates).sort(), [ + 'cron', + 'enabled', + 'interval_ms', + 'name', + 'notify_user', + 'prompt', + 'target_app', + ]); + } +}); + +test('scheduler PATCH rejects protected and unknown columns', () => { + for (const field of [ + 'id', + 'user_id', + 'run_count', + 'last_result', + 'last_run_at', + 'next_run_at', + 'created_at', + 'updated_at', + 'task_type', + 'lease_owner', + ]) { + const result = validateScheduledPatch({ enabled: false, [field]: 'forged' }); + assert.equal(result.ok, false, `${field} must be rejected`); + } +}); + +test('scheduler PATCH rejects empty and wrongly typed updates', () => { + assert.equal(validateScheduledPatch({}).ok, false); + assert.equal(validateScheduledPatch({ enabled: 'yes' }).ok, false); + assert.equal(validateScheduledPatch({ interval_ms: 10 }).ok, false); +}); diff --git a/backend/src/routes/scheduled.ts b/backend/src/routes/scheduled.ts index 477c97c..5ec2536 100644 --- a/backend/src/routes/scheduled.ts +++ b/backend/src/routes/scheduled.ts @@ -1,10 +1,31 @@ import { Router } from 'express'; import { requireAuth } from '../middleware/auth.js'; -import { supabase } from '../lib/supabase.js'; +import { userDb as supabase } from '../lib/user-db.js'; +import { getAdminDb } from '../lib/admin-db.js'; import { computeNextRun, scheduleToHuman } from '../scheduler/compute-next.js'; +import { validateScheduledPatch } from './scheduled-policy.js'; +import { SupabaseQuotaStore, requestQuotaSubject } from '../security/quota-store.js'; + +const scheduleQuota = new SupabaseQuotaStore(getAdminDb('scheduler')); export function createScheduledRoutes(): Router { const router = Router(); + router.use(requireAuth); + router.use(async (req, res, next) => { + if (req.method === 'GET') { + next(); + return; + } + try { + await scheduleQuota.consume({ + capability: 'scheduler', + subject: requestQuotaSubject({ userId: req.user!.sub }), + }); + next(); + } catch { + res.status(503).json({ error: 'Scheduling quota is temporarily unavailable.' }); + } + }); // List user's scheduled tasks router.get('/', requireAuth, async (req, res) => { @@ -35,7 +56,13 @@ export function createScheduledRoutes(): Router { router.patch('/:id', requireAuth, async (req, res) => { const userId = req.user!.sub; const taskId = req.params.id as string; - const updates = req.body; + const validation = validateScheduledPatch(req.body); + if (!validation.ok) { + res.status(400).json({ error: validation.error }); + return; + } + const updates = validation.updates; + let nextRunAt: string | undefined; console.log(`[scheduled] PATCH /${taskId} userId=${userId}`, updates); @@ -51,7 +78,12 @@ export function createScheduledRoutes(): Router { if (existing) { const cron = updates.cron ?? existing.cron; const interval = updates.interval_ms ?? existing.interval_ms; - updates.next_run_at = computeNextRun(existing.task_type, cron, interval).toISOString(); + try { + nextRunAt = computeNextRun(existing.task_type, cron, interval).toISOString(); + } catch { + res.status(400).json({ error: 'Invalid schedule' }); + return; + } } } @@ -67,6 +99,36 @@ export function createScheduledRoutes(): Router { res.status(500).json({ error: error.message }); return; } + if (nextRunAt) { + const { error: schedulingError } = await getAdminDb('scheduler') + .from('danotch_scheduled_tasks') + .update({ next_run_at: nextRunAt }) + .eq('id', taskId) + .eq('user_id', userId); + if (schedulingError) { + res.status(503).json({ error: 'Task updated but could not be rescheduled' }); + return; + } + } + if (updates.enabled === false) { + await getAdminDb('scheduler') + .from('danotch_scheduled_tasks') + .update({ + run_state: 'cancelled', + lease_owner: null, + lease_token: null, + lease_expires_at: null, + retry_at: null, + }) + .eq('id', taskId) + .eq('user_id', userId); + } else if (updates.enabled === true) { + await getAdminDb('scheduler') + .from('danotch_scheduled_tasks') + .update({ run_state: 'ready', attempt_count: 0, retry_at: null }) + .eq('id', taskId) + .eq('user_id', userId); + } res.json({ ok: true }); }); @@ -95,10 +157,14 @@ export function createScheduledRoutes(): Router { const taskId = req.params.id as string; console.log(`[scheduled] POST /${taskId}/run userId=${userId}`); - // Just reset next_run_at to now — the scheduler will pick it up - const { error } = await supabase + const { error } = await getAdminDb('scheduler') .from('danotch_scheduled_tasks') - .update({ next_run_at: new Date().toISOString() }) + .update({ + next_run_at: new Date().toISOString(), + run_state: 'ready', + attempt_count: 0, + retry_at: null, + }) .eq('id', taskId) .eq('user_id', userId); diff --git a/backend/src/routes/tasks.test.ts b/backend/src/routes/tasks.test.ts new file mode 100644 index 0000000..ad0f346 --- /dev/null +++ b/backend/src/routes/tasks.test.ts @@ -0,0 +1,22 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { InMemoryTaskStore } from '../agent/task-store.ts'; + +test('server generates task IDs when a client does not provide a scoped session ID', () => { + const store = new InMemoryTaskStore(); + const first = store.createOrUpdate('user-a', undefined, 'first'); + const second = store.createOrUpdate('user-a', undefined, 'second'); + + assert.match(first.id, /^[0-9a-f-]{36}$/); + assert.notEqual(first.id, second.id); +}); + +test('list/get accessors require the immutable owner ID', () => { + const store = new InMemoryTaskStore(); + store.createOrUpdate('user-a', 'same', 'A'); + store.createOrUpdate('user-b', 'same', 'B'); + + assert.deepEqual(store.list('user-a').map((task) => task.task), ['A']); + assert.equal(store.get('user-a', 'same')?.ownerId, 'user-a'); + assert.equal(store.get('user-a', 'missing'), undefined); +}); diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index 8c02777..92abb3c 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -8,17 +8,17 @@ import { EntitlementError } from '../billing/entitlements.js'; export function createTaskRoutes(notch: NotchBridge): Router { const router = Router(); - // ── In-memory tasks (real-time state) ── + // ── Durable owner-scoped runs ── - router.get('/tasks', requireAuth, (_req, res) => { - const tasks = getAllTasks(); + router.get('/tasks', requireAuth, async (req, res) => { + const tasks = await getAllTasks(req.user!.sub); console.log(`[tasks] GET /tasks → ${tasks.length} tasks`); res.json({ tasks }); }); - router.get('/tasks/:id', requireAuth, (req, res) => { + router.get('/tasks/:id', requireAuth, async (req, res) => { const taskId = req.params.id as string; - const task = getTask(taskId); + const task = await getTask(req.user!.sub, taskId); if (!task) { console.log(`[tasks] GET /tasks/${taskId} → not found`); res.status(404).json({ error: 'Task not found' }); @@ -41,11 +41,28 @@ export function createTaskRoutes(notch: NotchBridge): Router { }); router.post('/chat', chatLimiter, requireAuth, async (req, res) => { - const { message, session_id, conversation_id, model_id } = req.body; + const { message, session_id, conversation_id, model_id, device_id } = req.body; if (!message || typeof message !== 'string') { res.status(400).json({ error: 'message is required' }); return; } + if ( + device_id !== undefined + && (typeof device_id !== 'string' + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(device_id)) + ) { + res.status(400).json({ error: 'device_id must be a UUID' }); + return; + } + if ( + session_id !== undefined + && (typeof session_id !== 'string' + || session_id.length > 128 + || !/^[A-Za-z0-9_-]+$/.test(session_id)) + ) { + res.status(400).json({ error: 'session_id must be a safe opaque identifier' }); + return; + } const history = Array.isArray(req.body.history) ? req.body.history @@ -66,6 +83,8 @@ export function createTaskRoutes(notch: NotchBridge): Router { userId, conversationId: conversation_id, modelId: typeof model_id === 'string' ? model_id : undefined, + deviceId: typeof device_id === 'string' ? device_id : undefined, + idempotencyKey: typeof session_id === 'string' ? session_id : undefined, history, }); console.log(`[chat] Done → taskId=${task.id} conversationId=${task.threadId} status=${task.status}`); diff --git a/backend/src/scheduler/index.test.ts b/backend/src/scheduler/index.test.ts new file mode 100644 index 0000000..bde8c30 --- /dev/null +++ b/backend/src/scheduler/index.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; + +test('replicas claim schedules with skip-locked leases and fenced completion', async () => { + const sql = await readFile( + new URL('../../sql/011_identity_oauth_actions_scheduler.sql', import.meta.url), + 'utf8', + ); + assert.match(sql, /for update skip locked limit p_limit/); + assert.match(sql, /lease_owner = p_worker_id/); + assert.match(sql, /lease_token = gen_random_uuid\(\)/); + assert.match(sql, /task\.lease_token <> p_lease_token/); + assert.match(sql, /task\.lease_expires_at <= now\(\)/); + assert.match(sql, /danotch_renew_schedule_lease/); + assert.match(sql, /run_state = 'poisoned'/); + assert.match(sql, /'cancelled', 'poisoned'/); + assert.match(sql, /run_state = 'retry_wait'/); +}); + +test('device-local schedules enqueue durable bound runs and never invoke hosted providers', async () => { + const source = await readFile(new URL('./index.ts', import.meta.url), 'utf8'); + const localBranch = source.indexOf("if (task.execution_location === 'device_local')"); + const providerCall = source.indexOf('provider.complete'); + assert.ok(localBranch >= 0 && providerCall > localBranch); + const branchSource = source.slice(localBranch, providerCall); + assert.match(branchSource, /'queued_local'/); + assert.match(branchSource, /return;/); + assert.doesNotMatch(branchSource, /provider\.complete|executeComposioTool|host command execution/); + + const sql = await readFile( + new URL('../../sql/011_identity_oauth_actions_scheduler.sql', import.meta.url), + 'utf8', + ); + assert.match(sql, /execution_location in \('hosted', 'device_local'\)/); + assert.match(sql, /foreign key \(bound_device_id, user_id\)/); + assert.match(sql, /insert into public\.danotch_runs/); + assert.match(sql, /'execution_location', 'device_local'/); + assert.match(sql, /'schedule:' \|\| task\.id::text \|\| ':attempt:'/); +}); + +test('hosted scheduler source contains no local command execution path', async () => { + const source = await readFile(new URL('./index.ts', import.meta.url), 'utf8'); + assert.doesNotMatch(source, /bash_execute|execFile|spawn\(/); + assert.match(source, /danotch_renew_schedule_lease/); +}); diff --git a/backend/src/scheduler/index.ts b/backend/src/scheduler/index.ts index 11b74b1..51c2da9 100644 --- a/backend/src/scheduler/index.ts +++ b/backend/src/scheduler/index.ts @@ -1,12 +1,23 @@ -import { supabase } from '../lib/supabase.js'; +import { randomUUID } from 'node:crypto'; +import { getAdminDb } from '../lib/admin-db.js'; import { computeNextRun } from './compute-next.js'; import type { NotchBridge } from '../events/notch.js'; import { resolveProviderForUser } from '../billing/entitlements.js'; import { config } from '../config.js'; +import { SupabaseQuotaStore, requestQuotaSubject } from '../security/quota-store.js'; const TICK_INTERVAL = 30_000; // 30 seconds +const supabase = new Proxy({} as ReturnType, { + get(_target, property) { + const client = getAdminDb('scheduler') as unknown as Record; + const value = client[property]; + return typeof value === 'function' ? value.bind(client) : value; + }, +}); let schedulerTimer: ReturnType | null = null; +const schedulerWorkerId = `scheduler-${randomUUID()}`; +const schedulerQuota = new SupabaseQuotaStore(getAdminDb('scheduler')); export function startScheduler(notch: NotchBridge) { console.log('[scheduler] Started (tick every 30s)'); @@ -22,14 +33,13 @@ export function stopScheduler() { } } -async function tick(notch: NotchBridge) { +export async function tick(notch: NotchBridge, workerId = schedulerWorkerId) { try { - // Fetch all due tasks - const { data: dueTasks, error } = await supabase - .from('danotch_scheduled_tasks') - .select('*') - .eq('enabled', true) - .lte('next_run_at', new Date().toISOString()); + const { data: dueTasks, error } = await supabase.rpc('danotch_claim_due_schedules', { + p_worker_id: workerId, + p_limit: 25, + p_lease_seconds: 180, + }); if (error) { console.error('[scheduler] Query error:', error.message); @@ -40,15 +50,7 @@ async function tick(notch: NotchBridge) { console.log(`[scheduler] ${dueTasks.length} due task(s)`); for (const task of dueTasks) { - // Immediately update next_run_at to prevent double-pickup on next tick - const nextRun = computeNextRun(task.task_type, task.cron, task.interval_ms); - await supabase - .from('danotch_scheduled_tasks') - .update({ next_run_at: nextRun.toISOString() }) - .eq('id', task.id); - - // Fire-and-forget execution - executeTask(task, notch).catch((err) => { + executeTask(task, notch, workerId).catch((err) => { console.error(`[scheduler] Unhandled error in task ${task.id}:`, err); }); } @@ -57,22 +59,42 @@ async function tick(notch: NotchBridge) { } } -async function executeTask(task: Record, notch: NotchBridge) { +async function executeTask( + task: Record, + notch: NotchBridge, + workerId: string, +) { const taskId = task.id as string; const userId = task.user_id as string; const taskName = task.name as string; const prompt = task.prompt as string; const notifyUser = task.notify_user as boolean ?? false; - - // Re-fetch to check it still exists and is enabled - const { data: fresh } = await supabase - .from('danotch_scheduled_tasks') - .select('id, enabled') - .eq('id', taskId) - .single(); - - if (!fresh || !fresh.enabled) { - console.log(`[scheduler] Task ${taskId} skipped (deleted or disabled)`); + const leaseToken = task.lease_token as string; + const nextRun = computeNextRun( + task.task_type as string, + task.cron as string | null, + task.interval_ms as number | null, + ); + const stopLeaseHeartbeat = startLeaseHeartbeat(taskId, workerId, leaseToken); + try { + await schedulerQuota.consume({ + capability: 'scheduler', + subject: requestQuotaSubject({ userId }), + idempotencyKey: String(task.last_attempt_id), + }); + } catch { + await finishSchedule(taskId, workerId, leaseToken, 'retry', nextRun, { + status: 'quota_unavailable', + }); + stopLeaseHeartbeat(); + return; + } + if (task.execution_location === 'device_local') { + await finishSchedule(taskId, workerId, leaseToken, 'queued_local', nextRun, { + status: 'queued_for_bound_device', + device_id: task.bound_device_id, + }); + stopLeaseHeartbeat(); return; } @@ -86,7 +108,7 @@ async function executeTask(task: Record, notch: NotchBridge) { // Resolve the user's LLM provider (BYOK or server fallback) let providerName = 'unknown'; try { - const provider = (await resolveProviderForUser(userId)).provider; + const provider = (await resolveProviderForUser(userId, undefined, getAdminDb('scheduler'))).provider; providerName = `${provider.providerName}/${provider.modelId}`; // Build system prompt @@ -132,21 +154,23 @@ async function executeTask(task: Record, notch: NotchBridge) { console.error(`[scheduler] Task "${taskName}" failed (${providerName}):`, errorMsg); } - // Update task state - await supabase - .from('danotch_scheduled_tasks') - .update({ - last_run_at: new Date().toISOString(), - run_count: (task.run_count as number ?? 0) + 1, - last_result: { - status, - summary: resultText.slice(0, 500), - error: errorMsg ?? null, - notified: shouldNotify, - provider: providerName, - }, - }) - .eq('id', taskId); + const lastResult = { + status, + summary: resultText.slice(0, 500), + error: errorMsg ?? null, + notified: shouldNotify, + provider: providerName, + }; + const finish = await finishSchedule( + taskId, + workerId, + leaseToken, + status === 'completed' ? 'completed' : 'retry', + nextRun, + lastResult, + ); + stopLeaseHeartbeat(); + if (finish !== 'updated') return; if (!notifyUser) { console.log(`[scheduler] Task "${taskName}" ${status} via ${providerName} (silent)`); @@ -188,3 +212,48 @@ async function executeTask(task: Record, notch: NotchBridge) { } as any); } } + +function startLeaseHeartbeat(taskId: string, workerId: string, leaseToken: string): () => void { + let stopped = false; + const timer = setInterval(() => { + if (stopped) return; + void supabase.rpc('danotch_renew_schedule_lease', { + p_task_id: taskId, + p_worker_id: workerId, + p_lease_token: leaseToken, + p_lease_seconds: 180, + }).then(({ data, error }) => { + if (error || data !== true) { + console.error(`[scheduler] Lease renewal failed for ${taskId}; terminal write will be fenced`); + } + }); + }, 60_000); + timer.unref(); + return () => { + stopped = true; + clearInterval(timer); + }; +} + +async function finishSchedule( + taskId: string, + workerId: string, + leaseToken: string, + outcome: 'completed' | 'queued_local' | 'retry', + nextRun: Date, + result: Record, +): Promise { + const { data, error } = await supabase.rpc('danotch_finish_schedule_attempt', { + p_task_id: taskId, + p_worker_id: workerId, + p_lease_token: leaseToken, + p_outcome: outcome, + p_next_run_at: nextRun.toISOString(), + p_last_result: result, + }); + if (error) { + console.error(`[scheduler] Could not finish leased task ${taskId}:`, error.message); + return 'error'; + } + return String(data); +} diff --git a/backend/src/security/captcha.ts b/backend/src/security/captcha.ts new file mode 100644 index 0000000..966dd40 --- /dev/null +++ b/backend/src/security/captcha.ts @@ -0,0 +1,40 @@ +export type CaptchaProvider = 'turnstile' | 'hcaptcha'; + +export interface CaptchaConfig { + provider: CaptchaProvider; + secret: string; + expectedHostname?: string; +} + +export class CaptchaVerifier { + constructor( + private readonly config: CaptchaConfig, + private readonly fetcher: typeof fetch = fetch, + ) {} + + async verify(token: string, remoteIp?: string): Promise { + if (!token || !this.config.secret) return false; + const endpoint = this.config.provider === 'turnstile' + ? 'https://challenges.cloudflare.com/turnstile/v0/siteverify' + : 'https://hcaptcha.com/siteverify'; + const form = new URLSearchParams({ + secret: this.config.secret, + response: token, + }); + if (remoteIp) form.set('remoteip', remoteIp); + try { + const response = await this.fetcher(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: form, + signal: AbortSignal.timeout(8_000), + }); + if (!response.ok) return false; + const result = await response.json() as { success?: boolean; hostname?: string }; + if (result.success !== true) return false; + return !this.config.expectedHostname || result.hostname === this.config.expectedHostname; + } catch { + return false; + } + } +} diff --git a/backend/src/security/device-result-signature.test.ts b/backend/src/security/device-result-signature.test.ts new file mode 100644 index 0000000..976be5a --- /dev/null +++ b/backend/src/security/device-result-signature.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync, sign } from 'node:crypto'; +import { test } from 'node:test'; +import { + deviceResultSigningPayload, + verifyDeviceResultSignature, +} from './device-result-signature.ts'; + +test('device result signature binds session fence result and idempotency key', () => { + const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const value = { + messageId: '10000000-0000-4000-8000-000000000001', + deviceId: '20000000-0000-4000-8000-000000000002', + sessionId: '30000000-0000-4000-8000-000000000003', + fence: 7, + actionId: '40000000-0000-4000-8000-000000000004', + grantId: '50000000-0000-4000-8000-000000000005', + status: 'completed', + result: { stdout: 'ok', exit_code: 0 }, + }; + const signature = sign( + 'sha256', + deviceResultSigningPayload(value), + keys.privateKey, + ).toString('base64url'); + const publicKey = keys.publicKey.export({ type: 'spki', format: 'pem' }).toString(); + assert.equal(verifyDeviceResultSignature(value, publicKey, 'P-256', signature), true); + assert.equal(verifyDeviceResultSignature( + { ...value, fence: 8 }, + publicKey, + 'P-256', + signature, + ), false); + assert.equal(verifyDeviceResultSignature( + { ...value, result: { stdout: 'tampered' } }, + publicKey, + 'P-256', + signature, + ), false); +}); diff --git a/backend/src/security/device-result-signature.ts b/backend/src/security/device-result-signature.ts new file mode 100644 index 0000000..f258684 --- /dev/null +++ b/backend/src/security/device-result-signature.ts @@ -0,0 +1,53 @@ +import { createHash, createPublicKey, verify } from 'node:crypto'; +import { stableCanonicalJSON } from './execution-grant.js'; + +export interface SignedDeviceResult { + messageId: string; + deviceId: string; + sessionId: string; + fence: number; + actionId: string; + grantId: string; + status: string; + result: Record; +} + +export function deviceResultSigningPayload(value: SignedDeviceResult): Buffer { + const resultHash = createHash('sha256') + .update(stableCanonicalJSON(value.result)) + .digest('hex'); + return Buffer.from([ + 'perch-action-result', + '1', + value.messageId.toLowerCase(), + value.deviceId.toLowerCase(), + value.sessionId.toLowerCase(), + String(value.fence), + value.actionId.toLowerCase(), + value.grantId.toLowerCase(), + value.status, + resultHash, + ].join('\n')); +} + +export function verifyDeviceResultSignature( + value: SignedDeviceResult, + publicKeyPEM: string, + algorithm: string, + signature: string, +): boolean { + try { + const key = createPublicKey({ key: publicKeyPEM, format: 'pem', type: 'spki' }); + if ( + algorithm !== 'P-256' + || key.asymmetricKeyType !== 'ec' + || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1' + ) return false; + const bytes = Buffer.from(signature, 'base64url'); + return bytes.length >= 8 + && bytes.length <= 80 + && verify('sha256', deviceResultSigningPayload(value), key, bytes); + } catch { + return false; + } +} diff --git a/backend/src/security/execution-grant.ts b/backend/src/security/execution-grant.ts new file mode 100644 index 0000000..c363e82 --- /dev/null +++ b/backend/src/security/execution-grant.ts @@ -0,0 +1,69 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +export const EXECUTION_GRANT_SIGNED_FIELDS = [ + 'grant_id', + 'action_id', + 'action_hash', + 'parameters_hash', + 'registry_version', + 'action_type', + 'normalized_parameters', + 'capabilities', + 'image_digest', + 'workspace_bookmark_id', + 'result_disclosure_policy', + 'session_id', + 'device_key_fingerprint', + 'device_id', + 'fence', + 'expires_at', + 'transition_id', +] as const; + +function stable(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stable); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, stable(child)]), + ); + } + return value; +} + +export function stableCanonicalJSON(value: unknown): Buffer { + return Buffer.from(JSON.stringify(stable(value))); +} + +export function canonicalGrantBindings(payload: Record): Buffer { + const signed: Record = {}; + for (const field of EXECUTION_GRANT_SIGNED_FIELDS) { + if (!(field in payload)) throw new Error(`Missing signed grant field: ${field}`); + signed[field] = payload[field]; + } + return stableCanonicalJSON(signed); +} + +export function signExecutionGrant( + payload: Record, + grantToken: string, +): string { + return createHmac('sha256', Buffer.from(grantToken)) + .update(canonicalGrantBindings(payload)) + .digest('base64url'); +} + +export function verifyExecutionGrantSignature( + payload: Record, + grantToken: string, + signature: string, +): boolean { + try { + const expected = Buffer.from(signExecutionGrant(payload, grantToken), 'base64url'); + const actual = Buffer.from(signature, 'base64url'); + return actual.length === expected.length && timingSafeEqual(actual, expected); + } catch { + return false; + } +} diff --git a/backend/src/security/identity-state.ts b/backend/src/security/identity-state.ts new file mode 100644 index 0000000..efca5bc --- /dev/null +++ b/backend/src/security/identity-state.ts @@ -0,0 +1,7 @@ +import type { User } from '@supabase/supabase-js'; + +export function isEmailVerified( + user: Pick, +): boolean { + return Boolean(user.email_confirmed_at ?? user.confirmed_at); +} diff --git a/backend/src/security/no-host-process-execution.test.ts b/backend/src/security/no-host-process-execution.test.ts new file mode 100644 index 0000000..7c48b20 --- /dev/null +++ b/backend/src/security/no-host-process-execution.test.ts @@ -0,0 +1,50 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { hostedTools, executeHostedTool } from '../tools/local.ts'; + +const backendRoot = fileURLToPath(new URL('../../', import.meta.url)); + +async function sourceFiles(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all(entries.map(async (entry) => { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) return sourceFiles(fullPath); + return /\.(?:ts|js|mjs|cjs)$/.test(entry.name) ? [fullPath] : []; + })); + return nested.flat(); +} + +test('hosted tool registry exposes no shell or process executor', () => { + const names = hostedTools.map((tool) => tool.name); + assert.equal(names.includes('bash_execute'), false); + assert.equal(names.some((name) => /(?:shell|command|process|exec|spawn)/i.test(name)), false); +}); + +test('unknown hosted tools fail closed', async () => { + await assert.rejects( + executeHostedTool('bash_execute', { command: 'id' }), + /not registered for hosted execution/, + ); + await assert.rejects( + executeHostedTool('dynamic_process_launcher', {}), + /not registered for hosted execution/, + ); +}); + +test('backend application source has no child-process dependency', async () => { + const files = await sourceFiles(path.join(backendRoot, 'src')); + for (const file of files) { + if (file.endsWith('no-host-process-execution.test.ts')) continue; + const source = await readFile(file, 'utf8'); + assert.doesNotMatch(source, /(?:node:)?child_process/, file); + } +}); + +test('production start command enables Node permissions without child process grant', async () => { + const packageJson = JSON.parse(await readFile(path.join(backendRoot, 'package.json'), 'utf8')); + assert.match(packageJson.scripts.start, /--permission/); + assert.doesNotMatch(packageJson.scripts.start, /--allow-child-process/); +}); diff --git a/backend/src/security/quota-store.ts b/backend/src/security/quota-store.ts new file mode 100644 index 0000000..c934b27 --- /dev/null +++ b/backend/src/security/quota-store.ts @@ -0,0 +1,73 @@ +import { createHash } from 'node:crypto'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +export type QuotaCapability = + | 'signup' + | 'trial' + | 'provider' + | 'enrollment' + | 'oauth' + | 'scheduler' + | 'action' + | 'replay' + | 'storage'; + +export interface QuotaRequest { + capability: QuotaCapability; + subject: string; + cost?: number; + idempotencyKey?: string; +} + +export interface QuotaStore { + consume(request: QuotaRequest): Promise; +} + +export class QuotaUnavailableError extends Error { + readonly code = 'quota_unavailable'; +} + +export class QuotaExceededError extends Error { + readonly code = 'quota_exceeded'; + constructor(readonly retryAfterSeconds: number) { + super('This operation is temporarily unavailable. Please try again later.'); + } +} + +export class SupabaseQuotaStore implements QuotaStore { + constructor(private readonly db: SupabaseClient) {} + + async consume(request: QuotaRequest): Promise { + const { data, error } = await this.db.rpc('danotch_consume_capability_quota', { + p_capability: request.capability, + p_subject_hash: hashQuotaSubject(request.subject), + p_cost: request.cost ?? 1, + p_idempotency_key: request.idempotencyKey ?? null, + }); + if (error || !data) { + throw new QuotaUnavailableError(error?.message ?? 'Quota storage is unavailable'); + } + const result = data as { allowed?: boolean; retry_after_seconds?: number }; + if (result.allowed !== true) { + throw new QuotaExceededError(Math.max(1, Number(result.retry_after_seconds ?? 60))); + } + } +} + +export function hashQuotaSubject(subject: string): string { + return createHash('sha256').update(subject.trim().toLowerCase()).digest('hex'); +} + +export function requestQuotaSubject(input: { + ip?: string; + userId?: string; + email?: string; + deviceId?: string; +}): string { + return [ + input.userId ? `user:${input.userId}` : '', + input.deviceId ? `device:${input.deviceId}` : '', + input.email ? `email:${input.email}` : '', + input.ip ? `ip:${input.ip}` : '', + ].filter(Boolean).join('|') || 'unknown'; +} diff --git a/backend/src/security/u6-lifecycle.test.ts b/backend/src/security/u6-lifecycle.test.ts new file mode 100644 index 0000000..567da25 --- /dev/null +++ b/backend/src/security/u6-lifecycle.test.ts @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; +import { CaptchaVerifier } from './captcha.ts'; +import { + QuotaExceededError, + QuotaUnavailableError, + SupabaseQuotaStore, + hashQuotaSubject, +} from './quota-store.ts'; +import { isEmailVerified } from './identity-state.ts'; +import { + ACTION_REGISTRY_VERSION, + getActionDeliveryContract, +} from '../actions/registry.ts'; +import { normalizeActionParameters } from '../actions/parameters.ts'; + +test('CAPTCHA verification fails closed and enforces the configured hostname', async () => { + const accepted = new CaptchaVerifier( + { provider: 'turnstile', secret: 'secret', expectedHostname: 'signup.example.com' }, + async () => new Response(JSON.stringify({ success: true, hostname: 'signup.example.com' })), + ); + assert.equal(await accepted.verify('proof', '203.0.113.8'), true); + + const wrongHost = new CaptchaVerifier( + { provider: 'turnstile', secret: 'secret', expectedHostname: 'signup.example.com' }, + async () => new Response(JSON.stringify({ success: true, hostname: 'evil.example' })), + ); + assert.equal(await wrongHost.verify('proof'), false); + + const outage = new CaptchaVerifier( + { provider: 'hcaptcha', secret: 'secret' }, + async () => { throw new Error('outage'); }, + ); + assert.equal(await outage.verify('proof'), false); +}); + +test('distributed quota adapter distinguishes outage from a denied capability', async () => { + const unavailable = new SupabaseQuotaStore({ + async rpc() { return { data: null, error: { message: 'db unavailable' } }; }, + } as never); + await assert.rejects(() => unavailable.consume({ + capability: 'oauth', + subject: 'owner-1', + }), QuotaUnavailableError); + + const denied = new SupabaseQuotaStore({ + async rpc() { return { data: { allowed: false, retry_after_seconds: 42 }, error: null }; }, + } as never); + await assert.rejects(() => denied.consume({ + capability: 'action', + subject: 'owner-1', + }), (error: unknown) => error instanceof QuotaExceededError && error.retryAfterSeconds === 42); + assert.match(hashQuotaSubject('Owner-1'), /^[0-9a-f]{64}$/); +}); + +test('every costly production capability uses the distributed quota store', async () => { + const files = [ + ['../routes/auth.ts', "'signup'"], + ['../agent/runner.ts', "'provider'"], + ['../routes/devices.ts', "'enrollment'"], + ['../routes/apps.ts', "'oauth'"], + ['../tools/scheduled.ts', "'scheduler'"], + ['../actions/pending.ts', "'action'"], + ['../protocol/replay.ts', "'replay'"], + ['../protocol/durable-run-store.ts', "'storage'"], + ] as const; + for (const [relative, capability] of files) { + const source = await readFile(new URL(relative, import.meta.url), 'utf8'); + assert.match(source, /SupabaseQuotaStore|QuotaStore/); + assert.ok(source.includes(`capability: ${capability}`), `${relative} must quota ${capability}`); + } +}); + +test('only verified Supabase users qualify for provisioning', () => { + assert.equal(isEmailVerified({ email_confirmed_at: null, confirmed_at: null }), false); + assert.equal(isEmailVerified({ + email_confirmed_at: '2026-07-21T00:00:00Z', + confirmed_at: null, + }), true); +}); + +test('signup source uses public verification and never admin auto-confirm', async () => { + const source = await readFile(new URL('../routes/auth.ts', import.meta.url), 'utf8'); + assert.match(source, /auth\.signUp\(/); + assert.match(source, /verifyCaptcha\(/); + assert.match(source, /check_email/); + assert.doesNotMatch(source, /auth\.admin\.createUser|email_confirm\s*:\s*true/); + assert.doesNotMatch(source, /adminError\?\.message|loginError\?\.message/); +}); + +test('migration repairs partial provisioning and makes trial creation idempotent', async () => { + const sql = await readFile( + new URL('../../sql/011_identity_oauth_actions_scheduler.sql', import.meta.url), + 'utf8', + ); + assert.match(sql, /danotch_verified_provisioning/); + assert.match(sql, /email_confirmed_at into v_verified_at/); + assert.match(sql, /requested_at <= v_verified_at/); + assert.match(sql, /on conflict \(user_id, app_type\) do nothing/); + assert.match(sql, /if not provision\.trial_ready/); + assert.match(sql, /danotch_consume_capability_quota/); + assert.match(sql, /capability quota configuration unavailable/); +}); + +test('OAuth state, actions, and scheduler are durable and fail closed', async () => { + const sql = await readFile( + new URL('../../sql/011_identity_oauth_actions_scheduler.sql', import.meta.url), + 'utf8', + ); + assert.match(sql, /danotch_oauth_link_attempts_one_active_idx/); + assert.match(sql, /where status = 'pending' and consumed_at is null/); + assert.match(sql, /danotch_pending_action_contract_immutable/); + assert.match(sql, /for update skip locked/); + assert.match(sql, /danotch_finish_schedule_attempt/); + assert.match(sql, /execution_location in \('hosted', 'device_local'\)/); + assert.match(sql, /'source', 'scheduled_task'/); +}); + +test('normalized immutable action parameters and delivery semantics are exact', () => { + assert.deepEqual( + normalizeActionParameters({ z: 1, nested: { b: true, a: 'x' }, a: [2, 1] }), + { a: [2, 1], nested: { a: 'x', b: true }, z: 1 }, + ); + assert.throws(() => normalizeActionParameters({ bad: Number.NaN })); + const mutation = getActionDeliveryContract('GMAIL_SEND_EMAIL'); + assert.equal(ACTION_REGISTRY_VERSION, '1'); + assert.equal(mutation?.retry, 'never_after_dispatch'); + assert.equal(mutation?.reconciliation, 'manual_required'); +}); diff --git a/backend/src/security/verified-provisioning.ts b/backend/src/security/verified-provisioning.ts new file mode 100644 index 0000000..6b7006f --- /dev/null +++ b/backend/src/security/verified-provisioning.ts @@ -0,0 +1,25 @@ +import type { User } from '@supabase/supabase-js'; +import { COMPOSIO_APPS } from '../composio/tools.js'; +import { getAdminDb } from '../lib/admin-db.js'; +import { hashQuotaSubject } from './quota-store.js'; +import { isEmailVerified } from './identity-state.js'; + +export async function provisionVerifiedUser(user: User): Promise { + if (!isEmailVerified(user) || !user.email) { + throw new Error('verified_email_required'); + } + const metadataName = typeof user.user_metadata?.full_name === 'string' + ? user.user_metadata.full_name.trim() + : ''; + const fallbackName = user.email.split('@')[0]; + const { data, error } = await getAdminDb('bootstrap').rpc('danotch_provision_verified_user', { + p_user_id: user.id, + p_email: user.email, + p_full_name: metadataName || fallbackName, + p_trial_subject_hash: hashQuotaSubject(`verified-user:${user.id}`), + p_apps: COMPOSIO_APPS.map((app) => app.appType), + }); + if (error || !data) { + throw new Error(error?.message ?? 'verified_user_provisioning_failed'); + } +} diff --git a/backend/src/tools/local.ts b/backend/src/tools/local.ts index 9227d49..98c2765 100644 --- a/backend/src/tools/local.ts +++ b/backend/src/tools/local.ts @@ -1,12 +1,4 @@ import type { CanonicalTool } from '../providers/types.js'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import path from 'path'; - -const execAsync = promisify(exec); - -const BASH_TIMEOUT_MS = 30_000; -const BASH_MAX_BUFFER = 1024 * 1024; // 1MB const MAX_RESULT_CHARS = 5000; const MAX_SEARCH_CHARS = 2000; const MAX_ERROR_CHARS = 2000; @@ -48,26 +40,7 @@ function toErrorMessage(err: unknown): string { // ── Tool Definitions ── -export const localTools: CanonicalTool[] = [ - { - name: 'bash_execute', - description: - 'Execute a shell command on the user\'s local machine and return the output. Use for: checking files, running scripts, getting system info, installing packages, etc. Commands run in a bash shell. Be careful with destructive commands.', - input_schema: { - type: 'object', - properties: { - command: { - type: 'string', - description: 'The bash command to execute. Keep it concise. Avoid interactive commands.', - }, - cwd: { - type: 'string', - description: 'Optional working directory. Defaults to home directory.', - }, - }, - required: ['command'], - }, - }, +export const hostedTools: CanonicalTool[] = [ { name: 'web_search', description: @@ -102,66 +75,17 @@ export const localTools: CanonicalTool[] = [ // ── Tool Handlers ── -export async function executeLocalTool( +export async function executeHostedTool( toolName: string, input: Record ): Promise { switch (toolName) { - case 'bash_execute': - return bashExecute(input); case 'web_search': return webSearch(input); case 'web_fetch': return webFetch(input); default: - return JSON.stringify({ error: `Unknown tool: ${toolName}` }); - } -} - -function validateCwd(cwd: string): string | null { - if (!cwd || typeof cwd !== 'string') return 'cwd must be a non-empty string'; - const home = process.env.HOME; - if (!home) return 'HOME environment variable is not set'; - const resolved = path.resolve(cwd); - const homeResolved = path.resolve(home); - const homePrefix = homeResolved.endsWith(path.sep) ? homeResolved : homeResolved + path.sep; - if (resolved !== homeResolved && !resolved.startsWith(homePrefix)) { - return 'cwd must be inside the home directory'; - } - return null; -} - -async function bashExecute(input: Record): Promise { - const command = input.command as string; - const rawCwd = (input.cwd as string) || process.env.HOME || '/'; - - const cwdError = validateCwd(rawCwd); - if (cwdError) { - return `Error: ${cwdError}`; - } - const cwd = rawCwd; - - console.log(`[tool:bash] $ ${command}`); - - try { - const { stdout, stderr } = await execAsync(command, { - cwd, - timeout: BASH_TIMEOUT_MS, - maxBuffer: BASH_MAX_BUFFER, - shell: '/bin/bash', - }); - - const output = stdout.trim(); - const errors = stderr.trim(); - - let result = ''; - if (output) result += output; - if (errors) result += (result ? '\n\nSTDERR:\n' : '') + errors; - - return result.slice(0, MAX_RESULT_CHARS) || '(no output)'; - } catch (err: any) { - const msg = err.stderr?.trim() || err.stdout?.trim() || err.message; - return `Error (exit ${err.code ?? '?'}): ${msg}`.slice(0, MAX_ERROR_CHARS); + throw new Error(`Tool ${toolName} is not registered for hosted execution`); } } diff --git a/backend/src/tools/scheduled.ts b/backend/src/tools/scheduled.ts index 6963e17..747a12d 100644 --- a/backend/src/tools/scheduled.ts +++ b/backend/src/tools/scheduled.ts @@ -1,6 +1,11 @@ import type { CanonicalTool } from '../providers/types.js'; -import { supabase } from '../lib/supabase.js'; +import { userDb as supabase } from '../lib/user-db.js'; +import { getAdminDb } from '../lib/admin-db.js'; import { computeNextRun, isValidCron, cronToHuman, scheduleToHuman } from '../scheduler/compute-next.js'; +import { config } from '../config.js'; +import { SupabaseQuotaStore, requestQuotaSubject } from '../security/quota-store.js'; + +const scheduleQuota = new SupabaseQuotaStore(getAdminDb('scheduler')); // ── Tool Definitions ── @@ -38,8 +43,17 @@ export const scheduledTaskTools: CanonicalTool[] = [ type: 'boolean', description: 'If true, Claude will decide each run whether to actually notify the user (for conditional alerts like "tell me when stock hits X", "notify me if new email from Y"). If false (default), runs silently — results saved but no notification/peek. Set true when user says "notify me when...", "alert me if...", "let me know when...".', }, + execution_location: { + type: 'string', + enum: ['hosted', 'device_local'], + description: 'hosted only generates provider output and cannot execute commands. device_local queues work for one explicitly bound Mac.', + }, + bound_device_id: { + type: 'string', + description: 'Required UUID for device_local tasks. Local work never fails over to another device.', + }, }, - required: ['name', 'prompt', 'task_type'], + required: ['name', 'prompt', 'task_type', 'execution_location'], }, }, { @@ -86,6 +100,19 @@ export async function executeScheduledTool( input: Record, userId: string ): Promise { + if (toolName !== 'list_scheduled_tasks') { + try { + await scheduleQuota.consume({ + capability: 'scheduler', + subject: requestQuotaSubject({ + userId, + deviceId: typeof input.bound_device_id === 'string' ? input.bound_device_id : undefined, + }), + }); + } catch { + return JSON.stringify({ error: 'Scheduling quota is temporarily unavailable' }); + } + } switch (toolName) { case 'create_scheduled_task': return createTask(input, userId); @@ -101,6 +128,12 @@ export async function executeScheduledTool( } async function createTask(input: Record, userId: string): Promise { + if (!config.containment.costlyIntegrationsEnabled) { + return JSON.stringify({ + error: 'New scheduled tasks are temporarily unavailable.', + code: 'costly_integrations_frozen', + }); + } const name = input.name as string; const prompt = input.prompt as string; const taskType = input.task_type as string; @@ -108,11 +141,22 @@ async function createTask(input: Record, userId: string): Promi const intervalMs = input.interval_ms as number | undefined; const targetApp = input.target_app as string | undefined; const notifyUser = (input.notify_user as boolean) ?? false; + const executionLocation = input.execution_location as string; + const boundDeviceId = input.bound_device_id as string | undefined; // Validate if (!name || !prompt || !taskType) { return JSON.stringify({ error: 'name, prompt, and task_type are required' }); } + if (!['hosted', 'device_local'].includes(executionLocation)) { + return JSON.stringify({ error: 'execution_location must be hosted or device_local' }); + } + if (executionLocation === 'device_local' && !boundDeviceId) { + return JSON.stringify({ error: 'device_local tasks require bound_device_id' }); + } + if (executionLocation === 'hosted' && boundDeviceId) { + return JSON.stringify({ error: 'hosted tasks cannot bind a local execution device' }); + } if (taskType === 'scheduled' && (!cron || !isValidCron(cron))) { return JSON.stringify({ error: `Invalid or missing cron expression: "${cron}"` }); } @@ -121,7 +165,6 @@ async function createTask(input: Record, userId: string): Promi } const nextRunAt = computeNextRun(taskType, cron, intervalMs); - const { data, error } = await supabase .from('danotch_scheduled_tasks') .insert({ @@ -134,15 +177,25 @@ async function createTask(input: Record, userId: string): Promi target_app: targetApp ?? null, notify_user: notifyUser, enabled: true, - next_run_at: nextRunAt.toISOString(), + execution_location: executionLocation, + bound_device_id: boundDeviceId ?? null, }) - .select('id, name, next_run_at') + .select('id, name, next_run_at, execution_location, bound_device_id') .single(); if (error) { console.error('[tools:scheduled] Create failed:', error.message); return JSON.stringify({ error: error.message }); } + const { error: scheduleError } = await getAdminDb('scheduler') + .from('danotch_scheduled_tasks') + .update({ next_run_at: nextRunAt.toISOString() }) + .eq('id', data.id) + .eq('user_id', userId); + if (scheduleError) { + await supabase.from('danotch_scheduled_tasks').delete().eq('id', data.id); + return JSON.stringify({ error: 'Task could not be scheduled' }); + } const schedule = taskType === 'scheduled' && cron ? cronToHuman(cron) @@ -155,6 +208,8 @@ async function createTask(input: Record, userId: string): Promi schedule, next_run: nextRunAt.toISOString(), notify_user: notifyUser, + execution_location: executionLocation, + bound_device_id: boundDeviceId ?? null, message: `Scheduled task "${name}" created. ${schedule}. ${notifyUser ? 'Will notify you when condition is met.' : 'Runs silently.'} Next run: ${nextRunAt.toLocaleString()}.`, }); } @@ -162,7 +217,7 @@ async function createTask(input: Record, userId: string): Promi async function listTasks(userId: string): Promise { const { data, error } = await supabase .from('danotch_scheduled_tasks') - .select('id, name, prompt, task_type, cron, interval_ms, enabled, notify_user, last_run_at, next_run_at, run_count, last_result') + .select('id, name, prompt, task_type, cron, interval_ms, enabled, notify_user, execution_location, bound_device_id, run_state, last_run_at, next_run_at, run_count, last_result') .eq('user_id', userId) .order('created_at', { ascending: false }); @@ -179,6 +234,9 @@ async function listTasks(userId: string): Promise { last_run: t.last_run_at, next_run: t.next_run_at, run_count: t.run_count, + execution_location: t.execution_location, + bound_device_id: t.bound_device_id, + run_state: t.run_state, last_status: (t.last_result as Record)?.status ?? null, })); @@ -201,6 +259,7 @@ async function updateTask(input: Record, userId: string): Promi } const updates: Record = { updated_at: new Date().toISOString() }; + let nextRunAt: string | undefined; if (input.enabled !== undefined) updates.enabled = input.enabled; if (input.name) updates.name = input.name; if (input.prompt) updates.prompt = input.prompt; @@ -209,11 +268,11 @@ async function updateTask(input: Record, userId: string): Promi return JSON.stringify({ error: `Invalid cron: "${input.cron}"` }); } updates.cron = input.cron; - updates.next_run_at = computeNextRun('scheduled', input.cron as string).toISOString(); + nextRunAt = computeNextRun('scheduled', input.cron as string).toISOString(); } if (input.interval_ms) { updates.interval_ms = input.interval_ms; - updates.next_run_at = computeNextRun('poll', null, input.interval_ms as number).toISOString(); + nextRunAt = computeNextRun('poll', null, input.interval_ms as number).toISOString(); } const { error } = await supabase @@ -225,6 +284,14 @@ async function updateTask(input: Record, userId: string): Promi if (error) { return JSON.stringify({ error: error.message }); } + if (nextRunAt) { + const { error: scheduleError } = await getAdminDb('scheduler') + .from('danotch_scheduled_tasks') + .update({ next_run_at: nextRunAt }) + .eq('id', id) + .eq('user_id', userId); + if (scheduleError) return JSON.stringify({ error: 'Task updated but could not be rescheduled' }); + } return JSON.stringify({ success: true, message: `Task updated.` }); } diff --git a/backend/src/types.ts b/backend/src/types.ts index 00c07af..c06f71a 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -1,7 +1,30 @@ export type TaskStatus = 'running' | 'completed' | 'failed'; +export type DurableRunStatus = + | 'queued' + | 'provider_streaming' + | 'checkpointed' + | 'waiting_for_device' + | 'cancellation_requested' + | 'completed' + | 'failed' + | 'failed_recoverable' + | 'cancelled' + | 'expired'; + +export interface DurableRun { + id: string; + readonly ownerId: string; + readonly deviceId: string | null; + protocolVersion: 1; + status: DurableRunStatus; + revision: number; + input: Record; + checkpoint: Record | null; +} export interface Task { id: string; + readonly ownerId: string; task: string; description?: string; status: TaskStatus; diff --git a/docs/plans/2026-07-21-001-fix-production-security-hardening-plan.md b/docs/plans/2026-07-21-001-fix-production-security-hardening-plan.md new file mode 100644 index 0000000..d228e67 --- /dev/null +++ b/docs/plans/2026-07-21-001-fix-production-security-hardening-plan.md @@ -0,0 +1,403 @@ +--- +title: "fix: harden Perch for production" +type: fix +status: active +date: 2026-07-21 +deepened: 2026-07-21 +--- + +# fix: harden Perch for production + +## Summary + +Remove production remote-code-execution and cross-tenant paths, replace the localhost bridge with an authenticated outbound device channel, and confine local execution to an isolated VM. Establish reproducible database, macOS release, deployment, dependency, and accessibility gates before public launch. + +## Problem Frame + +The hosted backend can execute model-generated shell commands, tenant-owned data relies on service-role query discipline without RLS, and authenticated users can observe global in-memory tasks. External actions default to execution when classification fails. The macOS app exposes an unauthenticated socket, stores tokens and conversations in shared plaintext files, and cannot connect coherently to a hosted production backend. + +Signup, scheduler updates, OAuth attempts, migrations, dependencies, signing, release publication, and site accessibility also lack production controls. These weaknesses combine: inexpensive account creation reaches powerful tools, process-local state leaks across users, and the release pipeline cannot prove that the shipped app or database matches the reviewed system. + +--- + +## Requirements + +### Execution and action boundaries + +- R1. The hosted runtime must deny child-process creation and shell or user-supplied executable invocation at both application and deployment layers. +- R2. Local execution must run only inside a disposable Apple Containerization VM with pinned kernel, init, and image artifacts, bounded resources, selected mounts, network denied by default, and explicit disclosure before output leaves the device. +- R3. Local capabilities must use a versioned typed registry; an arbitrary shell is a separate high-risk action requiring exact, single-use consent. +- R4. Hosted external actions must use an exact versioned registry, and unknown tools, schemas, accounts, policy failures, or registry versions must deny execution. + +### Tenant, identity, and local-data isolation + +- R5. Ordinary tenant operations must use a caller-JWT Supabase client protected by RLS; public request processes must not possess a general service-role credential. +- R6. Every data surface must be classified as user-readable, user-writable, or server-authoritative; owner and state-machine policies must deny cross-account access and same-owner forged protocol transitions. +- R7. Signup must require Supabase email verification, a browser-hosted CAPTCHA proof, distributed abuse limits, and idempotent verified-user provisioning before trial access; provider spend, enrollment, OAuth, schedules, actions, and journal growth must also have fail-closed quotas. +- R8. Access tokens, refresh tokens, and device private keys must live in the Data Protection Keychain; local conversations and account data must be partitioned by immutable user ID. + +### Device and run protocol + +- R9. The macOS app must enroll a non-exportable device key after fresh user authentication, then connect outbound over `wss://` using an HTTPS-issued one-use ticket, signed challenge, connection fencing, payload limits, heartbeat, rotation, device limits, and revocation. +- R10. Runs, events, local-action requests, approvals, execution grants, acknowledgements, cancellation, and terminal results must be durable and owner/device scoped; a VM may start only from a one-use grant bound to the claimed action, capabilities, image, device, fence, and expiry. +- R11. Delivery must be at-least-once with idempotent reducers, ordered per-device sequences, a retained replay window, and snapshot resynchronization after retention expires. +- R12. A run requiring local execution must bind to its initiating device with no silent failover; logout or revocation fences the socket and cancels unstarted device work. + +### OAuth, actions, and scheduling + +- R13. Composio linking must use a pinned supported link API, PKCE where controlled by Perch, exact hosted HTTPS callbacks, one-time state bound to a user/device/app attempt, scope minimization, identifier retention limits, and non-destructive replacement. +- R14. Pending actions must bind immutable normalized parameters to one owner, account, registry version, expiry, and terminal decision; each registry entry must declare provider-specific idempotency, retry, and reconciliation semantics. +- R15. Scheduler mutations must use validated allowlisted fields, and execution must use durable leases so replicas cannot duplicate runs or execute local commands on the server. + +### Release and operations + +- R16. Production configuration must use validated HTTPS/WSS origins, Node 24 LTS, pinned supported Supabase and Composio baselines, separate least-privilege secret identities, rotation and revocation procedures, log redaction, and no insecure fallback. +- R17. Ordered migrations must bootstrap a clean database, use checksums and locking, verify without mutation, test RLS, and follow expand/migrate/contract rollout. +- R18. CI must block from the first hardening unit on backend, site, and Swift checks; tenant and protocol integration tests; lockfile and dependency review across npm, Swift, CI actions, and VM artifacts; SBOM/provenance; secret scanning; migration verification; and release-policy checks. +- R19. The macOS app and nested services must use Developer ID signing, Hardened Runtime, notarization, stapling, Gatekeeper verification, checksums, and provenance before publication. +- R20. Notarized immutable artifacts must publish to Vercel Blob before the site enables download; private source and issue links must not appear publicly. +- R21. The site must have zero blocking axe violations and meet WCAG 2.2 AA contrast, keyboard, focus, target, and reduced-motion criteria; the app must complete a release matrix for keyboard navigation, VoiceOver labels/order, contrast, reduced motion, and status announcements. + +--- + +## Key Technical Decisions + +- KTD1. **Hosted control plane, local execution plane:** Express owns identity, providers, scheduling, policy, and durable run state. The macOS app owns device consent and VM execution; the backend has no compatibility fallback for shell execution. +- KTD2. **Strong isolation without discarding safe features:** The main app retains its existing supported macOS range for chat, monitoring, stats, and hosted workflows. A separately availability-gated macOS 26 Apple-silicon executor target uses Apple Containerization; unsupported systems receive no weaker execution fallback. +- KTD3. **Least-privilege VM defaults:** A user-selected workspace is mounted read-only and may contain secrets; consent names the mounted scope and remote-output disclosure. Sensitive-file detection warns or denies, and network/write elevation requires a new parameter-bound approval. +- KTD4. **Separated database identities:** Request handlers use a publishable key plus caller JWT so RLS is authoritative. Privileged bootstrap, webhook, scheduler, fencing, and reconciliation run under separate operation-specific roles or workers that cannot perform arbitrary table access. +- KTD5. **Durable device protocol:** HTTPS challenge and enrollment bind the device key, HTTPS mints a signed one-use upgrade ticket, and WSS atomically consumes it while advancing the fence. Database state and one-use execution grants, not socket delivery, determine action and run outcomes. +- KTD6. **Initiating-device affinity:** Local work stays bound to the Mac that initiated the run. Offline work enters an expiring `waiting_for_device` state rather than moving to another device. +- KTD7. **Exact action policy:** Models propose actions but cannot authorize them. A registry entry fixes schema, execution location, consent, scopes, target constraints, idempotency, timeout, and redaction. +- KTD8. **Safe compatibility floor:** One release of backward readability applies only to the authenticated device protocol. The unauthenticated localhost bridge is never supported by the hosted control plane, and rollback cannot select an artifact that retains plaintext credentials or port 7778. +- KTD9. **Private development, public binaries:** Source and issue links are removed. CI publishes immutable notarized archives, checksums, and provenance to Vercel Blob through a protected release environment. +- KTD10. **Developer ID outside the App Sandbox:** The main app uses Hardened Runtime and minimum reviewed entitlements but does not claim App Sandbox compatibility while process monitoring and Music automation remain. The VM, not the main app sandbox, contains local command execution. +- KTD11. **Honest run recovery:** Tool boundaries and durable events are checkpointed, but an interrupted provider stream becomes an explicit recoverable terminal state unless the provider supports safe continuation. User-driven retry creates a new run rather than pretending exactly-once continuation. + +--- + +## High-Level Technical Design + +```mermaid +flowchart TB + User[User] --> MacApp[Signed macOS app] + MacApp -->|"HTTPS auth and control"| API[Hosted control plane] + MacApp -->|"Authenticated outbound WSS"| Gateway[Device gateway] + API --> UserDB[JWT scoped database client] + UserDB --> Postgres[Supabase Postgres with RLS] + API --> AdminServices[Narrow administrative services] + AdminServices --> Postgres + API --> Composio[Composio] + API --> Providers[LLM providers] + Gateway --> Journal[Durable run and event journal] + MacApp --> VM[Disposable execution VM] + VM --> Workspace[Selected workspace mount] +``` + +```mermaid +sequenceDiagram + participant Model + participant API as ControlPlane + participant DB as DurableJournal + participant App as BoundDevice + participant User + participant VM + + Model->>API: propose registered local action + API->>DB: persist offered action and expiry + API->>App: deliver versioned action event + App->>User: show exact command and capabilities + User-->>App: approve or reject + App->>API: signed decision with fence token + API->>DB: atomically claim approved action + API->>App: one-use execution grant + App->>App: validate grant, fence, image, capabilities, expiry + App->>VM: start pinned isolated environment + VM-->>App: bounded result or terminal failure + App->>API: signed idempotent result + API->>DB: persist terminal state + API-->>Model: tool result + App->>VM: destroy environment +``` + +```mermaid +flowchart TB + Contain[Contain dangerous legacy paths] --> Expand[Expand schema and protocols] + Expand --> Shadow[Shadow RLS and JWT scoped access] + Shadow --> GatewayRollout[Deploy device gateway] + GatewayRollout --> SignedClient[Ship signed client and migrate local data] + SignedClient --> Canary[Canary VM execution] + Canary --> WorkflowMigration[Migrate OAuth actions and scheduler] + WorkflowMigration --> PublicRelease[Promote notarized public artifact] + PublicRelease --> ContractLegacy[Remove legacy storage and transport] +``` + +--- + +## Scope Boundaries + +### In scope + +- All validated security, tenant-isolation, device transport, local execution, signup, OAuth, scheduler, dependency, packaging, deployment, site-link, asset, and accessibility findings. +- Data and protocol migrations needed to move existing accounts and clients safely. +- Release observability, rollback, audit, and operator verification required to enforce the new boundaries. +- Regression gates for the authenticated-chat and strict payment-entitlement controls delivered by `docs/plans/2026-07-10-001-fix-production-smoke-remediation-plan.md`; this plan is additive and supersedes only overlapping OAuth, action, migration, and release work. + +### Deferred to Follow-Up Work + +- Horizontal device-gateway scaling and shared pub/sub beyond one production replica. +- Native macOS arbitrary command execution outside the VM. +- App Store distribution, auto-update infrastructure, and open-sourcing the private repository. +- New product features unrelated to the audited production flows. + +--- + +## Implementation Units + +### U1. Contain exploitable legacy paths + +- **Goal:** Remove immediate hosted execution and cross-account/action-policy vulnerabilities before building replacement infrastructure. +- **Requirements:** R1, R4, R10, R13–R16, R18. +- **Dependencies:** None. +- **Files:** `.github/workflows/security-baseline.yml`, `backend/Dockerfile`, `backend/render.yaml`, `backend/src/tools/local.ts`, `backend/src/agent/runner.ts`, `backend/src/actions/allowlist.ts`, `backend/src/actions/registry.ts`, `backend/src/routes/tasks.ts`, `backend/src/routes/scheduled.ts`, `backend/src/routes/auth.ts`, `backend/src/composio/connection.ts`, `backend/src/types.ts`, `backend/src/security/no-host-process-execution.test.ts`, `backend/src/actions/registry.test.ts`, `backend/src/agent/runner.test.ts`, `backend/src/routes/tasks.test.ts`, `backend/src/routes/scheduled.test.ts`, `backend/src/composio/connection.test.ts`. +- **Approach:** Remove `bash_execute` and every backend process-execution import. Run the production service under the Node permission model without child-process permission in a minimal non-root image with no shell/toolchain, read-only filesystem, dropped privileges, and constrained egress. Replace verb classification with the minimal exact registry that U6 will extend. Add immutable task ownership, server-issued IDs, owner-scoped accessors, and allowlisted scheduler updates as containment pending durable run migration. Move Composio from the retired initiate flow to a pinned link baseline without deleting working connections. Freeze public signup, new trials, and costly integrations until verified provisioning and distributed quotas ship, while retaining capability-limited safe service for existing verified users. Establish baseline CI for tests, lockfiles, audits, migrations, and protocol schemas before later units deploy. +- **Execution note:** Write adversarial tests first for shell exposure, cross-user task reads/collisions, unknown external actions, and protected scheduler columns. +- **Patterns to follow:** Existing Node test runner in `backend/package.json`; pending-action ownership checks in `backend/src/actions/pending.ts`. +- **Test scenarios:** + - Authenticated chat cannot expose or invoke any hosted process executor, including through an unknown tool name. + - User A cannot list, fetch, update, or collide with user B’s in-memory task. + - Every curated Composio action is classified; unknown, version-mismatched, or metadata-failed actions deny. + - Scheduler PATCH accepts documented editable fields and rejects ownership, ID, result, count, and execution-time fields. + - Representative direct, dynamic, and dependency-mediated process launches fail in the production-equivalent runtime. + - Existing strict payment and authenticated-chat tests remain release-blocking. +- **Verification:** Source checks and runtime policy jointly enforce no hosted process execution, and all temporary task/action/scheduler boundaries fail closed. + +### U2. Establish reproducible schema and tenant isolation + +- **Goal:** Make a clean database reproducible and make RLS authoritative for ordinary user operations. +- **Requirements:** R5, R6, R17, R18. +- **Dependencies:** U1. +- **Files:** `backend/schema.sql`, `backend/sql/000_base_schema.sql`, `backend/sql/005_rls_and_constraints.sql`, `backend/scripts/migrate.mjs`, `backend/src/lib/supabase.ts`, `backend/src/lib/user-db.ts`, `backend/src/lib/admin-db.ts`, `backend/src/middleware/auth.ts`, tenant-facing files under `backend/src/routes/`, `backend/src/db/migrations.integration.test.ts`, `backend/src/db/rls.integration.test.ts`, `backend/src/db/admin-operation-matrix.test.ts`. +- **Approach:** Treat ordered migrations as authoritative and generate `backend/schema.sql` as a snapshot. Fingerprint and baseline existing databases before introducing `000`, record checksums under an advisory lock, and make verify read-only. Migrate to publishable/secret keys here. Add immutable owner constraints, explicit grants, user-write versus server-authoritative classifications, `USING` plus `WITH CHECK` policies, and indexed owner columns. Convert ordinary routes to caller-JWT clients; move privileged operations to separately credentialed roles/workers and fixed-search-path RPCs. +- **Execution note:** Characterize every current table/query first, then add cross-tenant tests before enabling each policy. +- **Patterns to follow:** Existing owner predicates in backend routes; ordered migration ledger in `backend/scripts/migrate.mjs`. +- **Test scenarios:** + - A clean database migrates from zero and a second run changes nothing. + - Verify detects missing, changed, or drifted migrations without creating or changing database objects. + - Anonymous, expired, user A, and user B contexts enforce read, insert, update, delete, and forged-owner denial. + - A valid owner cannot forge approval, acknowledgement, sequence, result, fence, or terminal-state transitions. + - Administrative bootstrap, webhook, scheduler claim, fencing, and reconciliation identities can perform only their operation matrix, while a compromised public route cannot obtain unrestricted access. + - An existing manually bootstrapped database can be fingerprinted and baselined without recreating objects or accepting unknown drift. +- **Verification:** CI can reset a database, apply migrations, pass pgTAP/integration isolation tests, and prove ordinary request paths do not use the secret client. + +### U3. Add durable runs and protocol state + +- **Goal:** Replace process-local task state with durable owner/device-scoped runs, events, actions, grants, and reducers. +- **Requirements:** R5, R6, R10–R12, R17, R18. +- **Dependencies:** U2. +- **Files:** `backend/sql/006_devices_runs_events.sql`, `backend/src/agent/runner.ts`, `backend/src/protocol/run-state.ts`, `backend/src/protocol/schemas.ts`, `backend/src/types.ts`, `backend/src/agent/runner.integration.test.ts`, `backend/src/protocol/run-state.test.ts`, `backend/src/protocol/schemas.test.ts`. +- **Approach:** Persist devices, runs, ordered events, acknowledgements, action requests, one-use execution grants, cancellation, and terminal results under server-authoritative policies. Define versioned state transitions and checkpoint only restart-safe boundaries. Interrupted provider streams become explicit recoverable terminal states. +- **Execution note:** Build state-transition, idempotency, forged-owner, and interruption tests before replacing in-memory state. +- **Patterns to follow:** Authenticated REST middleware; durable pending-action terminal transitions. +- **Test scenarios:** + - Duplicate or out-of-order events and results leave one valid durable transition. + - A valid owner cannot forge acknowledgements, approvals, grants, results, sequences, or terminal states. + - Restart at a safe checkpoint resumes journal delivery; interruption during provider streaming records a recoverable terminal state rather than replaying uncertain work. + - Cancellation, expiry, and terminal transitions reject late actions and results. +- **Verification:** Process restart preserves authoritative run state without cross-user leakage, fabricated transitions, or duplicate side effects. + +### U9. Enroll devices and authenticate the fenced gateway + +- **Goal:** Bind device keys to verified accounts and establish one current authenticated connection generation per device. +- **Requirements:** R5, R6, R9, R10, R12, R16–R18. +- **Dependencies:** U3. +- **Files:** `backend/src/app.ts`, `backend/src/index.ts`, `backend/src/config.ts`, `backend/src/routes/devices.ts`, `backend/src/events/device-gateway.ts`, `backend/src/routes/devices.test.ts`, `backend/src/events/device-gateway.test.ts`. +- **Approach:** Require fresh account authentication to enroll a bounded number of non-exportable device keys. Issue HTTPS challenges and signed one-use upgrade tickets, pass tickets in WSS headers, atomically consume them while advancing the fence, derive identity from the connection, and enforce heartbeat, revocation, payload, message, and byte limits. +- **Execution note:** Write enrollment, ticket-replay, stale-fence, and revocation-race tests before accepting application messages. +- **Patterns to follow:** Auth middleware and owner-scoped protocol state from U3. +- **Test scenarios:** + - Expired, reused, wrong-user, wrong-device, revoked, or unsigned tickets cannot establish a session. + - Duplicate enrollment, key replacement, account switching, device-limit exhaustion, and revocation races preserve one authoritative key binding. + - A newer fence invalidates the old socket and rejects its late messages, acknowledgements, and results. + - Malformed, oversized, flooded, unsupported-version, or backpressured clients disconnect without affecting another user. +- **Verification:** The gateway accepts only freshly authenticated enrolled devices and enforces the current fence for every message. + +### U10. Add replay, resynchronization, and recovery + +- **Goal:** Make gateway disconnects and restarts recoverable without claiming exactly-once delivery. +- **Requirements:** R10–R12, R18. +- **Dependencies:** U9. +- **Files:** `backend/src/events/device-gateway.ts`, `backend/src/protocol/replay.ts`, `backend/src/routes/runs.ts`, `backend/src/protocol/replay.test.ts`, `backend/src/routes/runs.test.ts`, `backend/src/events/device-gateway.recovery.test.ts`. +- **Approach:** Replay retained per-device sequences at least once from a persisted cursor, deduplicate through durable transition IDs, and return an authorized snapshot after retention expires. Add distributed reconnect/storage quotas, jittered backoff contracts, waiting-device expiry, explicit cancellation, and one-use execution grants emitted only after an atomic claim. +- **Execution note:** Test disconnection at every acknowledgement and grant boundary before wiring client recovery. +- **Patterns to follow:** Durable state transitions from U3 and fenced connections from U9. +- **Test scenarios:** + - Disconnect and gateway restart replay retained events without duplicate state or execution. + - Cursor expiry produces an owner-authorized snapshot and a new cursor. + - Approval races with cancellation, expiry, revocation, or a newer fence cannot mint a valid grant. + - A grant is parameter-bound, single-use, and rejected after cancellation, expiry, or fence change. + - An offline initiating device enters a visible expiring wait without automatic failover. +- **Verification:** Recovery tests prove at-least-once transport, idempotent durable outcomes, and bounded storage/reconnect behavior. + +### U4. Secure macOS identity, storage, and outbound connectivity + +- **Goal:** Migrate the app from plaintext shared files and inbound localhost sockets to account-partitioned storage and authenticated outbound WSS. +- **Requirements:** R8–R12, R16. +- **Dependencies:** U10. +- **Files:** `app/Perch.xcodeproj/`, `app/Perch.entitlements`, `app/Sources/AuthManager.swift`, `app/Sources/SecureSessionStore.swift`, `app/Sources/AccountDataStore.swift`, `app/Sources/DeviceIdentityStore.swift`, `app/Sources/DeviceConnection.swift`, `app/Sources/LocalConversationStore.swift`, `app/Sources/NotchViewModel.swift`, `app/Sources/WebSocketServer.swift`, `app/Tests/PerchTests/SecureSessionStoreTests.swift`, `app/Tests/PerchTests/AccountDataStoreTests.swift`, `app/Tests/PerchTests/DeviceConnectionTests.swift`, `app/Tests/PerchTests/EventRoutingTests.swift`. +- **Approach:** Establish the Xcode targets, Hardened Runtime baseline, development signing, and reviewed entitlement manifest before internal client rollout. Store session and non-exportable device credentials in the Data Protection Keychain. Move conversations and account state under Application Support partitions with atomic restrictive files. Migrate legacy data only after verifying the active user, clear memory before account switches, remove the local listener, and implement cancellable WSS lifecycle, cursor persistence, deduplication, sleep/wake recovery, logout fencing, and protocol-upgrade UX. Map offline, expired, revoked, interrupted, reenrollment, retry, and account-switch states to visible actions and durable recovery. +- **Execution note:** Start with migration crash/retry and account-switch leakage tests before changing persisted formats. +- **Patterns to follow:** Existing serial conversation-store queue; token-refresh flow in `AuthManager`. +- **Test scenarios:** + - A successful legacy migration imports one matching account, verifies the new stores, and removes plaintext secrets; interruption retries without duplication or loss. + - Account B never renders or sends account A’s conversations during login, logout, switching, or failed refresh. + - Keychain duplicate, interaction-denied, missing, rotated, and deleted credential states produce recoverable UI. + - Sleep/wake, network change, ticket loss, reconnect, cursor replay, revocation, and unsupported protocol versions reach explicit states. + - Offline and revoked devices show identity, reason, expiry, reconnect progress, cancel/retry actions, and accessible status announcements without silent failover. + - Account switching identifies the active account and safely resolves or cancels drafts, runs, and device sessions before new-account content loads. +- **Verification:** Plaintext token files are absent, account partitions remain isolated, and the app reconnects outbound to the hosted gateway without opening port 7778. + +### U5. Build the VM-backed local executor + +- **Goal:** Restore useful local execution without granting the hosted backend or native app unrestricted host access. +- **Requirements:** R1–R3, R10–R12, R19. +- **Dependencies:** U10, U4. +- **Files:** `app/Package.swift`, `app/Perch.xcodeproj/`, `app/Perch.entitlements`, `app/Executor.entitlements`, `app/Sources/Executor/ActionRegistry.swift`, `app/Sources/Executor/ExecutionConsent.swift`, `app/Sources/Executor/ContainerRuntime.swift`, `app/Sources/Executor/WorkspaceAccess.swift`, `app/Sources/Executor/ExecutionResult.swift`, `app/Resources/ExecutorArtifacts.json`, `app/Tests/PerchTests/LocalActionRegistryTests.swift`, `app/Tests/PerchTests/ExecutionConsentTests.swift`, `app/Tests/PerchTests/ContainerRuntimeTests.swift`, `app/Tests/PerchTests/WorkspaceAccessTests.swift`. +- **Approach:** Pin Apple Containerization and provision signed/checksummed kernel, init, and OCI image artifacts with explicit cache/bootstrap behavior and virtualization entitlement. Use digest-pinned disposable Linux VMs, explicit security-scoped workspace mounts, no host home or implicit credentials, read-only/no-network defaults, bounded CPU/memory/disk/process/output/time, whole-workload cancellation, cleanup, and signed fenced results. Resolve symlinks, hard links, nested mounts, sockets, and concurrent path changes at the trust boundary. Typed operations are normal; shell requires separate exact consent, and any write, egress, sensitive-file transmission, or result disclosure elevation requires a new approval. +- **Execution note:** Implement registry and isolation-policy tests before wiring execution; test escape boundaries on supported Apple-silicon hardware. +- **Patterns to follow:** Exact hosted action registry from U1; device action state machine from U3. +- **Test scenarios:** + - Unknown action, image digest mismatch, stale bookmark, symlink escape, unapproved write/network request, or wrong fence cannot start a VM action. + - Approved read-only typed work and approved high-risk shell work receive only the selected scope and documented environment. + - Representative repository analysis and test execution complete read-only; controlled writes and allowlisted dependency access require one clear elevation and produce reviewable output. + - First-use readiness explains VM isolation, artifact size/progress, workspace scope, unavailable capability, stale permission, and what result data may leave the device. + - Timeout, cancellation, app quit, VM crash, resource exhaustion, and result-ack loss terminate the workload and remain idempotently recoverable. + - Nothing outside the approved mount is visible; secret files, sockets, path swaps, IPv4/IPv6/DNS egress, hostile output, and renderer injection follow explicit deny/redaction/consent policy. +- **Verification:** A clean supported Mac completes a consented bounded action inside a verified disposable VM, while the documented hardware-backed escape, exfiltration, resource, and cleanup threat tests fail safely. + +### U6. Harden signup, OAuth, external actions, and scheduling + +- **Goal:** Make identity and third-party workflows durable, owner-bound, fail-closed, and retry-safe. +- **Requirements:** R4–R7, R13–R18. +- **Dependencies:** U2, U3, U4. +- **Files:** `backend/src/routes/auth.ts`, `backend/src/routes/apps.ts`, `backend/src/composio/connection.ts`, `backend/src/composio/tools.ts`, `backend/src/actions/registry.ts`, `backend/src/actions/pending.ts`, `backend/src/scheduler/index.ts`, `backend/sql/007_identity_oauth_actions_scheduler.sql`, `app/Sources/Views/OnboardingView.swift`, `backend/src/routes/auth.test.ts`, `backend/src/routes/apps.test.ts`, `backend/src/composio/connection.test.ts`, `backend/src/actions/pending.test.ts`, `backend/src/scheduler/index.test.ts`, `app/Tests/PerchTests/OnboardingAuthTests.swift`. +- **Approach:** Replace admin auto-confirm with public verified signup, hosted browser CAPTCHA proof, generic responses, distributed proxy-aware capability quotas, and idempotent provisioning. Define browser return, check-email, resend/change-email, expired-link, cross-device verification, reopening, and provisioning-repair states. Complete the pinned Composio migration started in U1, use parallel linking for replacement, bind one active attempt per user/app, minimize scopes/identifiers, and preserve a working account until replacement succeeds. Extend U1’s exact registry with immutable pending-action claims and provider-specific delivery semantics; non-queryable ambiguous outcomes become terminal reconciliation work. Use scheduler lease transactions with explicit retry, expiry, cancellation, poison-task states, and visible hosted-versus-device-local schedule classification. +- **Execution note:** Add lifecycle tests for partial provisioning, stale OAuth callbacks, duplicate approval, and scheduler failover before replacing current flows. +- **Patterns to follow:** Payment webhook trust boundary; existing pending-action state model; owner predicates from U2. +- **Test scenarios:** + - CAPTCHA-less, unverified, configured-blocklist, rate-limited, or partially provisioned signup cannot receive a trial or tool access. + - Quota-store outage denies costly signup, provider, enrollment, OAuth, scheduler, action, and replay/storage operations rather than failing open. + - Verified provisioning retries produce one profile and app bootstrap without requiring another signup. + - Forged, reused, expired, cancelled, cross-user, cross-device, or superseded OAuth state cannot activate an account. + - Failed reconnect preserves the previous usable account; Composio outage does not become disconnected. + - Duplicate/late action decisions and ambiguous provider results never create an automatic second attempt. + - Competing scheduler replicas claim a task once, recover expired leases, and queue local work for the bound device without server execution. +- **Verification:** End-to-end test accounts can verify, link, approve, schedule, reconnect, and recover without cross-account state or duplicate side effects. + +### U7. Upgrade and harden the production runtime + +- **Goal:** Remove vulnerable/obsolete dependency paths and make hosted operation observable and fail-safe. +- **Requirements:** R16–R18. +- **Dependencies:** U1–U6, U9, U10. +- **Files:** `backend/package.json`, `backend/package-lock.json`, `backend/tsconfig.json`, `backend/src/app.ts`, `backend/src/index.ts`, `backend/src/config.ts`, `backend/.env.example`, `backend/render.yaml`, `site/package.json`, `site/package-lock.json`, `backend/src/config.test.ts`, `backend/src/health.test.ts`, `README.md`, `AGENTS.md`. +- **Approach:** Standardize CI and production on Node 24 LTS, keep Express 4 on its latest supported patch during this hardening effort, remove unused dependency trees, and finish direct dependency upgrades after the Supabase/Composio baselines established earlier. Add strict schemas/body limits, trusted-proxy configuration, security headers, liveness/readiness separation, graceful drain, redacted correlated logging, secret-class ownership/rotation/revocation, and production configuration validation. Generate SBOMs and pin CI actions and VM artifacts. +- **Execution note:** Characterize runtime and dependency contracts before upgrades and update one dependency family at a time. +- **Patterns to follow:** Existing typed ESM imports and health route; current environment-driven config. +- **Test scenarios:** + - Startup fails for HTTP origins, localhost production endpoints, legacy/insecure secret fallbacks, or missing required configuration. + - Readiness fails for migration, database, gateway, or critical provider prerequisites while liveness remains process-focused. + - SIGTERM stops intake, fences/drains sockets and jobs, and exits by deadline without duplicate work. + - Dependency review blocks newly introduced high/critical advisories; accepted residual transitive risk requires a documented reachability exception. +- **Verification:** Supported production builds run on Node 24 with current APIs, no unused critical dependency tree, passing audit policy, and actionable health/telemetry. + +### U8. Establish signed release, deployment, site, and accessibility gates + +- **Goal:** Publish only verified production artifacts and prevent broken or inaccessible public surfaces. +- **Requirements:** R17–R21. +- **Dependencies:** U2–U7, U9, U10. +- **Files:** `.github/workflows/security-baseline.yml`, `.github/workflows/ci.yml`, `.github/workflows/release-macos.yml`, `.github/dependabot.yml`, `app/build.sh`, `app/Perch.xcodeproj/`, `app/Perch.entitlements`, `site/public/`, `site/src/components/site-config.ts`, `site/src/components/Download.tsx`, `site/src/components/Footer.tsx`, `site/src/components/Features.tsx`, `site/src/PerchSite.tsx`, `site/src/index.css`, `site/vercel.json`, `site/src/components/site-config.test.ts`, `site/tests/marketing.spec.ts`, `site/tests/accessibility.spec.ts`, `docs/runbooks/database-rollout.md`, `docs/runbooks/device-protocol-rollout.md`, `docs/runbooks/macos-release.md`, `docs/runbooks/security-rollback.md`. +- **Approach:** Extend the baseline gate from U1 into pinned least-privilege jobs for backend, site, Swift, clean migrations/RLS, dependency review, secret scanning, protocol compatibility, artifact inspection, and entitlement-manifest comparison. Protected release CI imports Developer ID credentials into a temporary Keychain, signs inside-out with Hardened Runtime, notarizes, staples, verifies, checksums, attests, scans archives for credentials/debug material, and uploads content-addressed immutable artifacts to Vercel Blob. Promotion is a new atomic site deployment referencing a versioned manifest, not a mutable Blob overwrite. Remove private source/issues links; add public support, changelog, security architecture, and vulnerability-reporting destinations. Gate release on assets, links, zero blocking axe findings, keyboard, reduced-motion, contrast, reproducible VoiceOver task completion, and clean-machine install evidence. +- **Execution note:** Build the unsigned test pipeline first, then exercise signing and publication in a protected prerelease environment before enabling the public CTA. +- **Patterns to follow:** Existing site build/lint scripts; `site/src/components/site-config.ts` as the external-link source of truth. +- **Test scenarios:** + - Pull requests cannot access release secrets and fail on broken builds, migrations, RLS, links/assets, protocol schemas, new dependency risk, or automated accessibility violations. + - Release refuses ad-hoc, unsigned, incorrectly entitled, unnotarized, unstapled, Gatekeeper-rejected, or checksum-mismatched artifacts. + - A published version is immutable; rollback repoints the stable manifest to a prior notarized artifact rather than overwriting bytes. + - Anonymous visitors can download the promoted artifact without repository access and cannot reach private Source, GitHub, or Issues destinations. + - Keyboard, screen-reader, focus, contrast, reduced-motion, and native VoiceOver test records cover representative onboarding, chat, approval, settings, and failure states. + - Download UI discloses system requirements, architecture, version, size, integrity metadata, installation guidance, unsupported-system behavior, and damaged-download recovery. +- **Verification:** A clean Mac downloads from the public site, verifies and launches the notarized app, enrolls its device, reconnects, and completes a VM-backed action; all release gates retain evidence. + +--- + +## Acceptance Examples + +- AE1. Given authenticated users A and B, when each exercises every tenant route and direct Data API operation, then neither can observe or mutate the other’s rows, runs, devices, events, actions, or local history. +- AE9. Given a valid owner JWT, when it attempts to forge an approval, acknowledgement, result, sequence, fence, or terminal state directly, then database privileges and transition guards reject it. +- AE2. Given a device reconnects while an older socket remains alive, when the new fence is committed, then all later acknowledgements or results from the old socket are rejected. +- AE3. Given a model proposes an unknown action or a known shell action without exact consent, when policy evaluates it, then no hosted or local execution begins. +- AE4. Given an approved VM action, when it requests an unapproved write, network destination, host path, credential, sensitive-file disclosure, hostile output, or excess resource, then the VM denies, redacts, or terminates it and records a bounded failure. +- AE5. Given a replacement OAuth flow fails or returns stale state, when status is reconciled, then the previous connected account remains usable and no new account activates. +- AE6. Given an existing user switches accounts after local-data migration, when the new session loads, then previous-account content never renders or leaves the device under the new identity. +- AE7. Given two scheduler replicas and one due task, when both poll concurrently, then one lease wins and at most one remote or queued local attempt is created. +- AE8. Given a release candidate fails signing, notarization, migration, isolation, dependency, link, asset, or accessibility policy, when release CI runs, then no artifact is promoted and no stable download deployment changes. + +--- + +## System-Wide Impact + +The plan changes the core trust boundary: models and hosted services may propose work, but only authenticated owner-scoped policy and the bound device may authorize local capability. Durable runs and events become shared infrastructure for chat, schedules, OAuth, approvals, notifications, and reconnect recovery. + +Database and protocol rollout must be expand/contract. The old app cannot use the new hosted topology, while the new app must never fall back to localhost backend execution. Production remains single-replica until a shared gateway pub/sub layer is implemented, but all durable state and fencing must already be replica-safe. + +--- + +## Risks and Dependencies + +- **Apple Containerization:** The executor requires macOS 26 on Apple silicon, while the main app keeps its existing compatibility floor. Kernel/init/image provenance, virtualization entitlement, availability isolation, and hardware-backed adversarial tests are prerequisites. +- **Composio migration:** The installed SDK predates current link/session APIs. Upgrade in staging and contract-test every curated action and connected-account transition. +- **RLS rollout:** Enabling policies before route conversion causes outages; converting clients before policy/grant verification can leave a false sense of isolation. +- **Swift/Xcode migration:** The Developer ID app remains outside App Sandbox for this release because current process monitoring and Music automation conflict with it. Hardened Runtime, least entitlements, VM isolation, and archive inspection remain mandatory. +- **Device protocol:** At-least-once delivery and schema compatibility require idempotent reducers and retained executors for pending older-version actions. +- **Secrets:** Apple signing credentials, Vercel Blob credentials, Supabase secret keys, provider keys, and OAuth credentials require protected environments and rotation runbooks. +- **Dependency policy:** Audit severity is not reachability. Exceptions must be narrow, expiring, and documented rather than forcing unsafe automated upgrades. + +--- + +## Phased Delivery and Rollback + +1. Freeze public signup/trials and costly integrations, establish baseline CI, and contain hosted shell execution, cross-user tasks, retired Composio initiation, fail-open actions, and scheduler mass assignment. +2. Expand schema, add RLS tests, and shadow caller-JWT access before revoking broad paths. +3. Deploy durable runs and the device gateway with local execution disabled. +4. Ship a signed internal client that migrates Keychain/account data and uses outbound WSS. +5. Canary the VM executor on internal devices and expand registry capabilities individually. +6. Migrate signup, OAuth, external actions, and scheduler leases. +7. Publish a notarized prerelease, verify a clean-machine flow, then promote and update the site. +8. Contract compatibility code only after the one-release window, zero observed legacy authenticated connections for the documented monitoring period, complete migration verification, and a tested safe-artifact rollback. + +Rollback may disable new capabilities or deploy a prior notarized artifact above the minimum safe client floor, but must not restore hosted command execution, unauthenticated sockets, plaintext credential storage, broad tenant access, or fail-open actions. Pre-hardening credentials and device tickets are revoked when the new protocol is enforced. + +--- + +## Documentation and Operational Notes + +- Keep operation matrices for database access, action policy, protocol states, and release credentials under `docs/runbooks/`. +- Record minimum client protocol and capability requirements in API responses and release notes. +- Document device enrollment/removal, local-data removal, action reconciliation, migration rollback, certificate compromise, Vercel Blob promotion, and security incident response. +- Update `README.md` and `AGENTS.md` when test, topology, signing, and migration claims become true. + +--- + +## Sources and Research + +- Existing remediation context: `docs/plans/2026-07-10-001-fix-production-smoke-remediation-plan.md` +- Supabase RLS and key guidance: https://supabase.com/docs/guides/database/postgres/row-level-security and https://supabase.com/docs/guides/getting-started/api-keys +- PostgreSQL row security: https://www.postgresql.org/docs/current/ddl-rowsecurity.html +- OWASP WebSocket Security: https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html +- OWASP AI Agent Security: https://cheatsheetseries.owasp.org/cheatsheets/AI_Agent_Security_Cheat_Sheet.html +- OAuth native-app and security guidance: https://www.rfc-editor.org/rfc/rfc8252.html and https://datatracker.ietf.org/doc/html/rfc9700.html +- Apple Containerization: https://github.com/apple/Containerization +- Apple App Sandbox and notarization: https://developer.apple.com/documentation/security/app-sandbox and https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution +- Composio link migration: https://docs.composio.dev/docs/auth-configuration/migrating-initiate-to-link +- GitHub Actions security: https://docs.github.com/en/actions/reference/security/secure-use +- WCAG 2.2: https://www.w3.org/TR/WCAG22/ diff --git a/docs/runbooks/database-rollout.md b/docs/runbooks/database-rollout.md new file mode 100644 index 0000000..30bbf8b --- /dev/null +++ b/docs/runbooks/database-rollout.md @@ -0,0 +1,107 @@ +# Runbook: Database Rollout + +**Scope:** Ordered migration deployment, idempotency verification, RLS validation, and rollback. + +--- + +## Pre-flight + +1. **Snapshot the current schema** (on staging first): + ```bash + cd backend + npm run db:snapshot + git diff -- schema.sql # must be empty if no pending migrations + ``` +2. **Fingerprint any manually bootstrapped database** before its first automated migration: + ```bash + npm run db:fingerprint + ``` + This records checksums without altering objects. Required once per existing environment; skip on clean deploys. +3. **Verify staging passes all RLS integration tests**: + ```bash + DATABASE_URL= npm run test:db + ``` +4. Confirm CI is green on the branch being deployed (migrations job and DB integration tests both pass). + +--- + +## Deployment — Expand/Migrate/Contract + +Perch migrations follow expand-then-contract. Never apply a contract migration to an environment where the old client version is still running. + +### Step 1 — Apply migrations + +```bash +cd backend +DATABASE_URL= npm run db:migrate +``` + +Expected output: each migration prints `applied: `. A second run of the same command must print `already applied: ` for all rows — **idempotency is required**. + +Run it twice to confirm: +```bash +DATABASE_URL= npm run db:migrate +DATABASE_URL= npm run db:migrate # must change nothing +``` + +### Step 2 — Verify (read-only) + +```bash +DATABASE_URL= npm run db:verify +``` + +This confirms checksums match and no objects have drifted. It **does not mutate** the database. + +### Step 3 — Schema snapshot drift check + +```bash +npm run db:snapshot +git diff -- schema.sql +``` + +The diff must be empty after a successful migration. If it is not, the snapshot generator and the migration are out of sync — halt and investigate. + +### Step 4 — RLS smoke test (staging only; against a populated staging DB) + +```bash +DATABASE_URL= npm run test:db +``` + +All tenant isolation and policy tests must pass before promoting to production. + +--- + +## Rollback + +**Migrations are forward-only.** To roll back: + +1. **Deploy the prior release** of the backend service. This restores the code that understands the previous schema. +2. If the migration added columns or tables (expand), the prior release ignores them safely — no schema change is required. +3. If the migration removed columns or tables (contract), rollback is not safe without a restore — this is why contract migrations are only applied after the one-release backward-compatibility window. +4. For emergency schema recovery from a point-in-time backup: + ``` + - Take a snapshot of the current state before restoring. + - Restore from Supabase point-in-time recovery. + - Re-apply only the non-destructive migrations from the restore point. + - Re-run db:verify to confirm checksums. + ``` + +--- + +## Monitoring + +After migration: +- Check Supabase dashboard for query errors or RLS rejections in the next 15 minutes. +- Verify `/health` on the backend returns `"db": "ok"` and `"migrations": "verified"`. +- Confirm no `500` errors on tenant-facing routes in the first 5 minutes of traffic. + +--- + +## Credential Rotation + +Database passwords and service keys are stored in environment secrets (never committed). Rotation steps: +1. Generate new credential in Supabase dashboard. +2. Update the secret in the deployment environment. +3. Redeploy the service (zero-downtime rolling deploy). +4. Verify the old credential no longer works by attempting a connection with it. +5. Record rotation in the incident/change log. diff --git a/docs/runbooks/device-protocol-rollout.md b/docs/runbooks/device-protocol-rollout.md new file mode 100644 index 0000000..f18b463 --- /dev/null +++ b/docs/runbooks/device-protocol-rollout.md @@ -0,0 +1,90 @@ +# Runbook: Device Protocol Rollout + +**Scope:** Rolling out the authenticated device gateway (WSS), device enrollment, ticket issuance, fence advancement, and handling the one-release backward-compatibility window. + +--- + +## Pre-flight + +1. Confirm the durable runs and events schema (migration `006_devices_runs_events.sql`) is applied and verified on the target environment. +2. Confirm CI is green on all protocol schema tests (`protocol-schema` job in `ci.yml`). +3. Confirm the prior release of the backend is deployed and healthy. + +--- + +## Rollout Phases + +### Phase 1 — Deploy gateway alongside legacy bridge (shadow mode) + +The gateway accepts WSS connections but the legacy localhost bridge (port 7778) is still present. Both process events in parallel so operators can compare behavior. + +**Actions:** +1. Deploy the new backend with `GATEWAY_SHADOW_MODE=true` in the environment. +2. Verify `/health` shows `"gateway": "shadow"`. +3. Confirm existing app clients continue to connect via the legacy bridge without errors. +4. Monitor gateway connection attempts in logs for early adopter devices. + +### Phase 2 — Internal app clients switch to WSS + +Deploy a signed internal build of the app that connects outbound over WSS using HTTPS-issued one-use tickets. The legacy bridge remains as a fallback for un-updated clients. + +**Actions:** +1. Verify device enrollment endpoint (`POST /api/devices/enroll`) responds for freshly authenticated users. +2. Test ticket issuance and atomic consumption on a test device. +3. Verify fence advancement rejects old sockets within the timeout window. +4. Confirm connection recovery (sleep/wake, network change) produces the correct reconnect sequence. + +### Phase 3 — Remove legacy bridge + +Remove port 7778 from the backend after the one-release backward-compatibility window. All enrolled production devices must be on the new protocol. + +**Actions:** +1. Confirm no authenticated connections to the legacy bridge have appeared in logs for the documented monitoring period (minimum 7 days after new app is available). +2. Set `LEGACY_BRIDGE_DISABLED=true`. Restart the service. +3. Verify old app versions cannot connect (expected: TCP refused or HTTP 404 on `/ws`). +4. Verify new app versions reconnect successfully after the restart. + +--- + +## Device Enrollment + +A device key is enrolled after fresh authentication. Device limits per account are enforced by the backend. + +**Operator actions:** +- View enrolled devices: query `devices` table filtered by `user_id`. +- Revoke a device: set `revoked_at` and `revoked_reason`; the gateway rejects new tickets for that device on next fence check. +- Reset device count: remove revoked rows; user re-enrolls on next fresh auth. + +--- + +## Fence and Ticket Management + +Each device has a monotonically increasing fence sequence. A ticket is single-use and bound to a specific device/fence pair. + +**Symptoms and responses:** + +| Symptom | Likely cause | Response | +|---------|-------------|----------| +| Client reconnects in a loop | Stale fence — ticket consumed but fence not advanced | Check `devices.fence_seq` vs the ticket's bound fence in logs | +| `403 ticket_reused` | Network retry on a successful upgrade | Client must request a new ticket; this is expected behavior | +| `403 device_limit` | Account has hit the enrollment cap | User must revoke an unused device via settings | +| `403 revoked` | Device was explicitly revoked | User must re-enroll with fresh authentication | + +--- + +## Rollback + +The device protocol rollback must not restore the localhost bridge or plaintext credentials. + +1. Deploy the prior backend version. If it did not include the gateway, the shadow-mode environment variable keeps the bridge dormant. +2. Pre-hardening credentials (cleartext tokens, port 7778 auth tokens) are **revoked** — do not restore them. Issue new tickets through the upgrade flow. +3. Enrolled device keys remain valid across a rollback (they are stored in Keychain, not in the backend). +4. New tickets cannot be issued by the rolled-back backend until it is re-deployed with gateway support. + +--- + +## Monitoring + +- Watch for `fence_mismatch` and `ticket_reused` log events — a spike indicates a client-server version skew. +- Alert on device enrollment failures exceeding 5% of attempts over a 5-minute window. +- Confirm `"gateway": "healthy"` in `/health` during the first 30 minutes after each deployment. diff --git a/docs/runbooks/macos-release.md b/docs/runbooks/macos-release.md new file mode 100644 index 0000000..3de91fe --- /dev/null +++ b/docs/runbooks/macos-release.md @@ -0,0 +1,137 @@ +# Runbook: macOS Release + +**Scope:** Producing a signed, notarized, stapled Perch.app; publishing to Vercel Blob; updating the stable download manifest; and verifying a clean-machine install. + +--- + +## Prerequisites + +- Apple Developer Program membership with a **Developer ID Application** certificate. +- App-specific password for the Apple ID used for notarytool. +- Vercel Blob token with `store:rw` scope for the release store. +- Access to the GitHub repository's **`release`** protected environment (where secrets live). +- A Mac with Xcode 16+ for local testing; CI uses `macos-15`. + +--- + +## Credential Setup (GitHub Secrets — `release` environment) + +| Secret | Description | +|--------|-------------| +| `DEVELOPER_ID_CERT_P12` | Base64-encoded `.p12` export of the Developer ID Application cert | +| `DEVELOPER_ID_CERT_PASSWORD` | Passphrase for the `.p12` archive | +| `APPLE_ID` | Apple ID email address | +| `APPLE_ID_PASSWORD` | App-specific password (not the main Apple ID password) | +| `APPLE_TEAM_ID` | 10-character Apple Developer Team ID | +| `VERCEL_BLOB_TOKEN` | Vercel Blob API token | + +**Never commit these values. Never share them in Slack or chat. Rotate them immediately if you suspect exposure.** + +Exporting the cert: +```bash +# On a Mac with the cert in Keychain: +security find-identity -v -p codesigning | grep "Developer ID Application" +# Note the SHA-1 hash and export from Keychain Access > My Certificates > Export +# or via: +security export -k login.keychain-db -t identities -f pkcs12 \ + -P "your-password" -o ~/Desktop/DeveloperID.p12 +base64 < ~/Desktop/DeveloperID.p12 | pbcopy # copy to DEVELOPER_ID_CERT_P12 secret +``` + +--- + +## Release via CI (recommended) + +1. Ensure CI is green on `main` (all jobs in `security-baseline.yml` and `ci.yml`). +2. Tag the release: + ```bash + git tag v1.2.3 + git push origin v1.2.3 + ``` +3. The `release-macos.yml` workflow triggers automatically on `v*.*.*` tags. +4. Monitor the **release** environment job. Confirm: + - All credential checks pass. + - Notarization returns `"status": "Accepted"`. + - Stapling and `spctl --assess` pass. + - The artifact URL and checksum are printed in the workflow summary. +5. Update the stable manifest (see below). + +--- + +## Release via CLI (operator fallback) + +Use `app/build.sh` directly only when CI is not available. This should be rare. + +```bash +# Import cert into Keychain first (see Credential Setup above) +security import DeveloperID.p12 -P "$CERT_PASSWORD" -T /usr/bin/codesign + +export DEVELOPER_ID_CERT="Developer ID Application: Your Name (TEAMID)" +export NOTARIZE=1 +export APPLE_ID="you@example.com" +export APPLE_ID_PASSWORD="xxxx-xxxx-xxxx-xxxx" +export APPLE_TEAM_ID="ABCDE12345" + +cd app +./build.sh +# Outputs Perch-.zip and Perch-.sha256 +``` + +Upload the resulting `.zip` to Vercel Blob manually: +```bash +curl -X PUT "https://blob.vercel-storage.com/releases/v1.2.3/Perch-v1.2.3-signed.zip" \ + -H "Authorization: Bearer $VERCEL_BLOB_TOKEN" \ + -H "x-content-type: application/octet-stream" \ + -H "x-cache-control-max-age: 31536000" \ + --data-binary @Perch-v1.2.3.zip +``` + +--- + +## Updating the Stable Manifest + +The site's download CTA reads `VITE_DOWNLOAD_URL` at build time. To promote an artifact: + +1. Obtain the immutable Vercel Blob URL from the CI summary or manual upload. +2. Set `VITE_DOWNLOAD_URL=` in the Vercel deployment environment. +3. Trigger a new site deployment (no code change needed — the env var update redeploys). +4. Verify the download CTA now shows the new artifact URL. + +**Rollback:** Point `VITE_DOWNLOAD_URL` at the prior notarized artifact's immutable Blob URL and redeploy the site. The prior artifact was never overwritten (content-addressed). + +--- + +## Clean-Machine Install Verification + +Before promoting any release, verify on a clean Mac (or a freshly created VM): + +1. Download the artifact from the public site URL. +2. Verify the SHA-256: + ```bash + shasum -a 256 Perch-v1.2.3-signed.zip + # compare to the published .sha256 file + ``` +3. Expand the archive and verify Gatekeeper: + ```bash + spctl --assess --type execute -vv Perch.app + # Expected: Perch.app: accepted source=Notarized Developer ID + ``` +4. Open Perch.app. Confirm: + - No "damaged or unverified" Gatekeeper dialog. + - Onboarding / login opens successfully. + - (Post-U9) Device enrollment completes and the WSS connection is established. + +--- + +## Certificate Compromise Response + +If the Developer ID certificate is compromised: + +1. **Immediately** revoke the certificate in the Apple Developer portal. +2. Generate a new Developer ID Application certificate and update all secrets. +3. Re-sign and re-notarize the latest release artifact. +4. Publish the new artifact with a patch version bump. +5. Update `VITE_DOWNLOAD_URL` to point at the re-notarized artifact. +6. Document the incident and the revocation date in the change log. + +Revoked certificates cause Gatekeeper to reject old artifacts on future OS updates, so timely re-release is important. diff --git a/docs/runbooks/security-rollback.md b/docs/runbooks/security-rollback.md new file mode 100644 index 0000000..04522e2 --- /dev/null +++ b/docs/runbooks/security-rollback.md @@ -0,0 +1,97 @@ +# Runbook: Security Rollback + +**Scope:** Rolling back a deployed version due to a security incident, failed migration, compromised credential, or a release that must be pulled. Covers both backend and macOS app rollbacks, with explicit constraints on what must NOT be restored. + +--- + +## General Principles + +1. **Rollback must not restore insecure capabilities.** Specifically, rolling back can never restore: + - Hosted shell/child-process execution (`bash_execute` removed in U1). + - The unauthenticated localhost bridge on port 7778. + - Plaintext credential storage (`~/.danotch/auth.json`). + - Broad service-role DB access for public request paths. + - Ad-hoc or unnotarized macOS artifacts for public distribution. + - Fail-open action policies or cross-user task visibility. + +2. **Content-addressed artifacts enable safe rollback.** Every Vercel Blob artifact has an immutable URL. Rollback repoints the stable manifest to a prior URL — it never overwrites bytes. + +3. **Database rollback is forward-only.** Schema migrations are not reversed; only the application layer rolls back. + +--- + +## Backend Rollback + +### Decision: can the prior release safely run on the current schema? + +- **Expand migrations** (new tables/columns): the prior release ignores them — safe to deploy. +- **Contract migrations** (removed tables/columns): the prior release may error — do not roll back past the release that introduced the contract. + +### Steps + +1. Identify the target release SHA or image tag (from CI artifacts or the deployment history). +2. Deploy the prior image to the backend service (Render, or your deployment platform). +3. Monitor `/health` for `"db": "ok"` and `"migrations": "verified"`. If either fails, stop and investigate. +4. Watch for RLS-rejection errors in the first 5 minutes of traffic. If present, the schema and code versions are incompatible — escalate. +5. Once stable, revoke any temporary credentials issued during the incident window (see Credential Rotation in `database-rollout.md`). + +--- + +## macOS App Rollback + +### Prior artifact is already available (content-addressed on Vercel Blob) + +1. Find the prior release's immutable Vercel Blob URL in the CI workflow summary or the Vercel Blob console. +2. Update `VITE_DOWNLOAD_URL` to the prior URL in the Vercel deployment environment. +3. Redeploy the site (no code change; env var update triggers a deploy). +4. Verify the download CTA links to the prior artifact. + +### If the prior artifact cannot be used (security floor) + +If the prior release violates a security floor (e.g., it contains hosted shell execution or the unauthenticated bridge), it **must not** be re-published. Instead: + +1. Set `VITE_DOWNLOAD_URL` to empty / unset, which renders the coming-soon state on the site. +2. Communicate the rollback timeline to users. +3. Produce a patch release that reverts only the specific regression while retaining all hardening from U1–U8. +4. Go through the full release gate: sign, notarize, attest, verify clean-machine install, publish to Blob, update manifest. + +--- + +## Device Protocol Rollback + +If the device gateway is rolled back: + +1. Existing enrolled device keys remain valid — they live in Keychain. +2. New ticket issuance is unavailable until the gateway is re-deployed. +3. Do not re-enable the localhost bridge. Devices in a disconnected state are expected; they will reconnect when the gateway is restored. +4. Revoke device tickets issued during the incident window if there is any suspicion of ticket compromise. + +--- + +## Credential Compromise Response + +| Credential | Immediate action | +|-----------|-----------------| +| Supabase service key | Rotate in Supabase dashboard; update `SUPABASE_SERVICE_KEY` secret; redeploy | +| ANTHROPIC_API_KEY | Revoke in Anthropic console; rotate | +| DEVELOPER_ID_CERT | Revoke in Apple Developer portal; re-sign+notarize latest artifact; re-publish (see macos-release.md) | +| VERCEL_BLOB_TOKEN | Revoke in Vercel dashboard; update secret; re-run release job | +| PROVIDER_KEY_SECRET | Rotate; all stored BYOK keys are re-encrypted on next use if the app supports key migration; otherwise prompt users to re-enter | +| COMPOSIO_API_KEY | Revoke; rotate; existing connections remain valid (they are OAuth-based) | + +All rotations must be followed by a forced redeploy of the backend to pick up the new secrets. + +--- + +## Incident Documentation + +For any security incident requiring a rollback, record: + +- Date/time of discovery and rollback. +- Root cause (brief). +- Versions affected. +- Credentials rotated. +- Evidence preserved (logs, artifacts, attestation records). +- Post-incident action items and owner. + +Keep this record in a private incident log, not in this repository. diff --git a/site/package-lock.json b/site/package-lock.json index 7fd914a..61edc2d 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -15,7 +15,9 @@ "react-dom": "^19.2.4" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint/js": "^9.39.4", + "@playwright/test": "^1.61.1", "@tailwindcss/vite": "^4.2.2", "@types/node": "^24.12.2", "@types/react": "^19.2.14", @@ -32,6 +34,19 @@ "vite": "^8.0.4" } }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -695,6 +710,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", @@ -1701,6 +1732,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3595,6 +3636,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", diff --git a/site/package.json b/site/package.json index 78330ab..bd03f7b 100644 --- a/site/package.json +++ b/site/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test:a11y": "playwright test" }, "dependencies": { "@fontsource/ibm-plex-mono": "^5.2.7", @@ -17,7 +18,9 @@ "react-dom": "^19.2.4" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@eslint/js": "^9.39.4", + "@playwright/test": "^1.61.1", "@tailwindcss/vite": "^4.2.2", "@types/node": "^24.12.2", "@types/react": "^19.2.14", diff --git a/site/playwright.config.ts b/site/playwright.config.ts new file mode 100644 index 0000000..808d815 --- /dev/null +++ b/site/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://localhost:4173', + trace: 'on-first-retry', + }, + webServer: { + // Uses the pre-built dist/ directory so lint+build must run first. + command: 'npx vite preview --port 4173', + url: 'http://localhost:4173', + reuseExistingServer: !process.env.CI, + cwd: __dirname, + timeout: 30_000, + }, +}); diff --git a/site/public/.well-known/security.txt b/site/public/.well-known/security.txt new file mode 100644 index 0000000..b60d986 --- /dev/null +++ b/site/public/.well-known/security.txt @@ -0,0 +1,6 @@ +Contact: mailto:security@perch.app +Preferred-Languages: en +Policy: https://perch.app/security +Expires: 2027-07-22T00:00:00.000Z +Encryption: mailto:security@perch.app +Acknowledgments: https://perch.app/security#acknowledgments diff --git a/site/src/components/Download.tsx b/site/src/components/Download.tsx index bd2a444..b20a3fa 100644 --- a/site/src/components/Download.tsx +++ b/site/src/components/Download.tsx @@ -9,11 +9,34 @@ function AppleIcon() { ); } -function GitHubIcon() { +function DownloadCTA() { + if (!SITE.downloadUrl) { + return ( +
+ + Coming soon +
+ ); + } + return ( - + ); } @@ -33,7 +56,6 @@ export default function Download() { className="absolute inset-0" style={{ background: 'rgba(0, 0, 0, 0.24)' }} /> -
@@ -52,17 +74,10 @@ export default function Download() {
- + - - - - View source + Get in touch
+ + {SITE.downloadUrl && ( +

+ macOS 14+ · Apple silicon · Notarized by Apple +

+ )}
diff --git a/site/src/components/Footer.tsx b/site/src/components/Footer.tsx index 5363468..90856c3 100644 --- a/site/src/components/Footer.tsx +++ b/site/src/components/Footer.tsx @@ -1,9 +1,10 @@ import { SITE } from './site-config'; const FOOTER_LINKS = [ - { label: 'GitHub', href: SITE.githubUrl }, - { label: 'Issues', href: SITE.issuesUrl }, { label: 'Download', href: '#download' }, + { label: 'Changelog', href: SITE.changelogUrl }, + { label: 'Security', href: SITE.securityArchitectureUrl }, + { label: 'Contact', href: `mailto:${SITE.supportEmail}` }, ]; const FOOTER_SURFACE = '#111111'; @@ -76,7 +77,12 @@ export default function Footer() {

Perch

-

Questions or bugs? Open an issue.

+

+ Questions?{' '} + + {SITE.supportEmail} + +