From 6df16afe3834014bfdabd89899755962fc722196 Mon Sep 17 00:00:00 2001 From: gimenes Date: Mon, 27 Jul 2026 16:28:40 -0300 Subject: [PATCH 01/55] feat(native): add Studio desktop app with a local Rust runtime Adds the macOS-first version of Studio as a Tauri v2 app in `apps/native`. The app mounts the existing production `apps/web` router and shell, while an in-process Rust runtime replaces the local half of `bunx decocms link`. - Hybrid local/upstream runtime: ordinary Studio data is proxied to the configured upstream deployment, while native transport gates route local threads, Claude Code/Codex dispatch, sandboxes, previews, and process execution to Rust. - Native authentication bridging desktop sign-in to the macOS Keychain. - Local agents: runs the user's own installed `claude` and `codex` CLIs and translates their streams for the existing chat UI. The CLIs are not bundled. - Durable local chats and sandboxes in native-owned SQLite, with worktree-backed repositories so N threads on one repo no longer pay N object stores. - Organization filesystem: the org's shared volumes are mounted per org via a bundled rclone, so agents read them with ordinary filesystem calls. - MCP connection OAuth completes in the system browser, because `window.open` returns null in a webview; the local API relays the authorization code back. - A `window.open` shim so every external link and editor deep link reaches the OS instead of silently doing nothing. Squashed from 91 commits on vibegui/tauri-multi-org-launcher and rebuilt on current main. The one structural conflict was `router.tsx`: main kept the app bootstrap in `index.tsx`, while this branch splits the entry into `index.web.tsx` (web) and `index.native.tsx` (desktop) with the route tree in `router.tsx`. Resolved by keeping the bootstrap in `index.web.tsx`, which carries main's PWA install capture unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/native.yml | 441 + .gitignore | 2 + AGENTS.md | 16 + README.md | 1 + .../src/api/routes/desktop-session-bridge.ts | 4 +- apps/native/.cargo/config.toml | 11 + apps/native/.gitignore | 4 + apps/native/Cargo.lock | 6821 ++++++++++++ apps/native/Cargo.toml | 84 + apps/native/README.md | 153 + apps/native/crates/harness/Cargo.toml | 47 + apps/native/crates/harness/src/detect.rs | 418 + .../crates/harness/src/events/claude.rs | 1125 ++ .../native/crates/harness/src/events/codex.rs | 425 + .../src/events/fixtures/claude_error.jsonl | 2 + .../src/events/fixtures/claude_partial.jsonl | 17 + .../src/events/fixtures/claude_simple.jsonl | 5 + .../src/events/fixtures/claude_tool_use.jsonl | 5 + .../src/events/fixtures/codex_error.jsonl | 3 + .../src/events/fixtures/codex_exec.jsonl | 4 + .../fixtures/codex_tool_and_error.jsonl | 9 + .../src/events/fixtures/codex_tool_use.jsonl | 6 + apps/native/crates/harness/src/events/mod.rs | 86 + apps/native/crates/harness/src/lib.rs | 57 + apps/native/crates/harness/src/parts.rs | 453 + apps/native/crates/harness/src/resolve.rs | 329 + apps/native/crates/harness/src/run.rs | 1923 ++++ apps/native/crates/harness/src/spawn.rs | 1156 ++ apps/native/crates/harness/src/tiers.rs | 209 + apps/native/crates/local-api/Cargo.toml | 73 + apps/native/crates/local-api/build.rs | 48 + apps/native/crates/local-api/src/auth.rs | 101 + .../crates/local-api/src/client_auth.rs | 400 + .../crates/local-api/src/config/classify.rs | 367 + .../crates/local-api/src/config/merge.rs | 193 + .../native/crates/local-api/src/config/mod.rs | 12 + .../crates/local-api/src/config/store.rs | 290 + .../crates/local-api/src/config/validate.rs | 354 + apps/native/crates/local-api/src/cors.rs | 175 + apps/native/crates/local-api/src/error.rs | 156 + .../local-api/src/events/broadcaster.rs | 95 + .../native/crates/local-api/src/events/mod.rs | 8 + .../crates/local-api/src/events/snapshot.rs | 151 + apps/native/crates/local-api/src/lib.rs | 1059 ++ .../crates/local-api/src/log_store/mod.rs | 54 + .../crates/local-api/src/log_store/store.rs | 641 ++ apps/native/crates/local-api/src/main.rs | 17 + apps/native/crates/local-api/src/mutation.rs | 519 + .../crates/local-api/src/process_group.rs | 896 ++ apps/native/crates/local-api/src/router.rs | 960 ++ .../crates/local-api/src/routes/bash.rs | 882 ++ .../crates/local-api/src/routes/config.rs | 135 + .../crates/local-api/src/routes/dispatch.rs | 1229 +++ .../crates/local-api/src/routes/events.rs | 543 + apps/native/crates/local-api/src/routes/fs.rs | 1503 +++ .../local-api/src/routes/fs/git_exclude.rs | 84 + .../local-api/src/routes/fs/glob_logic.rs | 517 + .../local-api/src/routes/fs/mcp_client.rs | 320 + .../local-api/src/routes/fs/safe_path.rs | 172 + .../local-api/src/routes/fs/tools_catalog.rs | 133 + .../src/routes/fs/tools_transaction.rs | 764 ++ .../native/crates/local-api/src/routes/git.rs | 2758 +++++ .../crates/local-api/src/routes/health.rs | 32 + .../src/routes/intercept/agent_sessions.rs | 231 + .../src/routes/intercept/decopilot.rs | 4422 ++++++++ .../src/routes/intercept/link_current.rs | 48 + .../local-api/src/routes/intercept/mod.rs | 335 + .../src/routes/intercept/preview_invoke.rs | 226 + .../src/routes/intercept/run_spool.rs | 751 ++ .../src/routes/intercept/sandbox_events.rs | 170 + .../src/routes/intercept/sandbox_fs.rs | 411 + .../src/routes/intercept/sandbox_lifecycle.rs | 616 ++ .../src/routes/intercept/sandbox_ops.rs | 260 + .../src/routes/intercept/thread_tools.rs | 1815 ++++ .../local-api/src/routes/intercept/watch.rs | 618 ++ .../local-api/src/routes/mcp_callback.rs | 309 + .../native/crates/local-api/src/routes/mod.rs | 26 + .../crates/local-api/src/routes/models.rs | 88 + .../crates/local-api/src/routes/orgfs.rs | 144 + .../crates/local-api/src/routes/proxy.rs | 1430 +++ .../crates/local-api/src/routes/repo_dir.rs | 219 + .../crates/local-api/src/routes/scripts.rs | 1073 ++ .../crates/local-api/src/routes/setup.rs | 971 ++ .../crates/local-api/src/routes/tasks.rs | 447 + .../crates/local-api/src/routes/threads.rs | 260 + .../crates/local-api/src/routes/threads/db.rs | 9411 +++++++++++++++++ .../crates/local-api/src/routes/upstream.rs | 2508 +++++ .../crates/local-api/src/routes/webdav.rs | 1012 ++ .../crates/local-api/src/routes/webdav/dav.rs | 574 + .../local-api/src/routes/webdav/junk.rs | 208 + .../local-api/src/routes/webdav/org_fs.rs | 416 + .../crates/local-api/src/routes/ws_proxy.rs | 676 ++ .../crates/local-api/src/sandbox/manager.rs | 2538 +++++ .../crates/local-api/src/sandbox/mod.rs | 59 + .../crates/local-api/src/sandbox/org_mount.rs | 627 ++ .../local-api/src/sandbox/org_prompt.rs | 134 + .../crates/local-api/src/sandbox/org_view.rs | 250 + .../crates/local-api/src/sandbox/persist.rs | 729 ++ .../crates/local-api/src/sandbox/registry.rs | 1139 ++ .../local-api/src/sandbox/repo_store.rs | 438 + .../crates/local-api/src/sandbox/target.rs | 243 + .../crates/local-api/src/setup/clone.rs | 1138 ++ apps/native/crates/local-api/src/setup/dev.rs | 1026 ++ .../crates/local-api/src/setup/install.rs | 408 + apps/native/crates/local-api/src/setup/mod.rs | 667 ++ apps/native/crates/local-api/src/shutdown.rs | 252 + apps/native/crates/local-api/src/state.rs | 78 + apps/native/crates/local-api/src/tasks/mod.rs | 16 + .../crates/local-api/src/tasks/registry.rs | 1283 +++ apps/native/crates/local-api/src/ui.rs | 240 + apps/native/crates/upstream/Cargo.toml | 75 + .../src/bin/decocms-keychain-helper.rs | 6 + apps/native/crates/upstream/src/bridge.rs | 656 ++ .../crates/upstream/src/browser_page.rs | 233 + apps/native/crates/upstream/src/clock.rs | 72 + apps/native/crates/upstream/src/cookie_jar.rs | 251 + .../crates/upstream/src/keychain_helper.rs | 423 + apps/native/crates/upstream/src/lib.rs | 71 + apps/native/crates/upstream/src/login.rs | 812 ++ apps/native/crates/upstream/src/pkce.rs | 101 + apps/native/crates/upstream/src/refresh.rs | 520 + apps/native/crates/upstream/src/register.rs | 129 + apps/native/crates/upstream/src/revalidate.rs | 72 + apps/native/crates/upstream/src/session.rs | 1835 ++++ apps/native/crates/upstream/src/tokens.rs | 1353 +++ apps/native/docs/org-fs-plan.md | 348 + apps/native/e2e/auth-matrix.e2e.test.ts | 112 + apps/native/e2e/authenticated-upstream.ts | 118 + .../e2e/claude-session-resume.e2e.test.ts | 172 + .../e2e/codex-session-resume.e2e.test.ts | 174 + .../e2e/decopilot-queue-recovery.e2e.test.ts | 1366 +++ .../native/e2e/dev-signing-runner.e2e.test.ts | 743 ++ .../e2e/dispatch-differential.e2e.test.ts | 276 + apps/native/e2e/dispatch-stream.e2e.test.ts | 575 + apps/native/e2e/dispatch.e2e.test.ts | 115 + .../fixtures/golden/fail.claude-raw.ndjson | 3 + .../fixtures/golden/simple.claude-raw.ndjson | 5 + .../fixtures/golden/tooluse.claude-raw.ndjson | 5 + .../e2e/fixtures/shutdown-process-tree.mjs | 69 + .../e2e/fixtures/stub-claude-resume.mjs | 84 + .../native/e2e/fixtures/stub-codex-resume.mjs | 87 + apps/native/e2e/fixtures/stub-harness.mjs | 772 ++ apps/native/e2e/fs-shutdown.e2e.test.ts | 184 + apps/native/e2e/git-sandbox.e2e.test.ts | 333 + apps/native/e2e/health.e2e.test.ts | 142 + apps/native/e2e/helpers.test.ts | 53 + apps/native/e2e/helpers.ts | 620 ++ apps/native/e2e/models.e2e.test.ts | 76 + .../e2e/real-ui-interception.e2e.test.ts | 1006 ++ .../e2e/real-ui-passthrough.e2e.test.ts | 252 + apps/native/e2e/sandbox-fs-bridge.e2e.test.ts | 359 + .../native/e2e/sandbox-resolution.e2e.test.ts | 395 + .../sandbox-restart-resurrection.e2e.test.ts | 843 ++ .../e2e/setup-log-retention.e2e.test.ts | 332 + .../native/e2e/shutdown-lifecycle.e2e.test.ts | 442 + apps/native/e2e/sse.e2e.test.ts | 57 + apps/native/e2e/threads.e2e.test.ts | 178 + .../e2e/upstream-auth-proxy.e2e.test.ts | 794 ++ apps/native/package.json | 25 + apps/native/rustfmt.toml | 6 + apps/native/scripts/boot-smoke-paths.test.ts | 43 + apps/native/scripts/boot-smoke-paths.ts | 41 + apps/native/scripts/boot-smoke.ts | 384 + .../scripts/ci/check-junit-allowlist.mjs | 135 + .../scripts/ci/daemon-parity-allowlist.json | 11 + .../scripts/ci/stub-seam-allowlist.json | 29 + .../native/scripts/create-dev-signing-cert.sh | 337 + apps/native/scripts/dev-runner.sh | 70 + apps/native/scripts/dev-signing-identity.sh | 97 + apps/native/scripts/fetch-rclone.sh | 95 + apps/native/src-tauri/.gitignore | 4 + apps/native/src-tauri/Cargo.toml | 46 + apps/native/src-tauri/build.rs | 16 + .../src-tauri/capabilities/default.json | 33 + apps/native/src-tauri/icon-src/app-icon.png | Bin 0 -> 24755 bytes apps/native/src-tauri/icons/128x128.png | Bin 0 -> 7030 bytes apps/native/src-tauri/icons/128x128@2x.png | Bin 0 -> 14384 bytes apps/native/src-tauri/icons/32x32.png | Bin 0 -> 1318 bytes apps/native/src-tauri/icons/64x64.png | Bin 0 -> 3090 bytes .../src-tauri/icons/Square107x107Logo.png | Bin 0 -> 5752 bytes .../src-tauri/icons/Square142x142Logo.png | Bin 0 -> 8010 bytes .../src-tauri/icons/Square150x150Logo.png | Bin 0 -> 8368 bytes .../src-tauri/icons/Square284x284Logo.png | Bin 0 -> 16184 bytes .../src-tauri/icons/Square30x30Logo.png | Bin 0 -> 1248 bytes .../src-tauri/icons/Square310x310Logo.png | Bin 0 -> 17443 bytes .../src-tauri/icons/Square44x44Logo.png | Bin 0 -> 1840 bytes .../src-tauri/icons/Square71x71Logo.png | Bin 0 -> 3536 bytes .../src-tauri/icons/Square89x89Logo.png | Bin 0 -> 4426 bytes apps/native/src-tauri/icons/StoreLogo.png | Bin 0 -> 2259 bytes .../android/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../icons/android/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2301 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 9801 bytes .../android/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 2214 bytes .../icons/android/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2226 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 6537 bytes .../android/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2108 bytes .../android/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 5504 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 13243 bytes .../mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 5127 bytes .../android/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 8900 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 20311 bytes .../mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 7920 bytes .../android/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 11912 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 27810 bytes .../mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 10511 bytes .../android/values/ic_launcher_background.xml | 4 + apps/native/src-tauri/icons/icon.icns | Bin 0 -> 226693 bytes apps/native/src-tauri/icons/icon.ico | Bin 0 -> 28724 bytes apps/native/src-tauri/icons/icon.png | Bin 0 -> 70835 bytes .../src-tauri/icons/ios/AppIcon-20x20@1x.png | Bin 0 -> 806 bytes .../icons/ios/AppIcon-20x20@2x-1.png | Bin 0 -> 1950 bytes .../src-tauri/icons/ios/AppIcon-20x20@2x.png | Bin 0 -> 1950 bytes .../src-tauri/icons/ios/AppIcon-20x20@3x.png | Bin 0 -> 3247 bytes .../src-tauri/icons/ios/AppIcon-29x29@1x.png | Bin 0 -> 1293 bytes .../icons/ios/AppIcon-29x29@2x-1.png | Bin 0 -> 3070 bytes .../src-tauri/icons/ios/AppIcon-29x29@2x.png | Bin 0 -> 3070 bytes .../src-tauri/icons/ios/AppIcon-29x29@3x.png | Bin 0 -> 5005 bytes .../src-tauri/icons/ios/AppIcon-40x40@1x.png | Bin 0 -> 1950 bytes .../icons/ios/AppIcon-40x40@2x-1.png | Bin 0 -> 4589 bytes .../src-tauri/icons/ios/AppIcon-40x40@2x.png | Bin 0 -> 4589 bytes .../src-tauri/icons/ios/AppIcon-40x40@3x.png | Bin 0 -> 7191 bytes .../src-tauri/icons/ios/AppIcon-512@2x.png | Bin 0 -> 77661 bytes .../src-tauri/icons/ios/AppIcon-60x60@2x.png | Bin 0 -> 7191 bytes .../src-tauri/icons/ios/AppIcon-60x60@3x.png | Bin 0 -> 10825 bytes .../src-tauri/icons/ios/AppIcon-76x76@1x.png | Bin 0 -> 4213 bytes .../src-tauri/icons/ios/AppIcon-76x76@2x.png | Bin 0 -> 9162 bytes .../icons/ios/AppIcon-83.5x83.5@2x.png | Bin 0 -> 9926 bytes .../autogenerated/auth_complete_session.toml | 11 + .../permissions/autogenerated/auth_login.toml | 11 + .../autogenerated/auth_logout.toml | 11 + .../autogenerated/auth_status.toml | 11 + .../autogenerated/local_api_info.toml | 11 + .../autogenerated/selftest_report.toml | 11 + apps/native/src-tauri/selftest/bundle.js | 592 ++ apps/native/src-tauri/src/auth.rs | 97 + apps/native/src-tauri/src/commands.rs | 146 + apps/native/src-tauri/src/control_origin.rs | 70 + apps/native/src-tauri/src/csp.rs | 40 + apps/native/src-tauri/src/lib.rs | 56 + apps/native/src-tauri/src/main.rs | 6 + apps/native/src-tauri/src/selftest.rs | 158 + apps/native/src-tauri/src/setup.rs | 369 + apps/native/src-tauri/src/shutdown.rs | 30 + apps/native/src-tauri/src/state.rs | 43 + apps/native/src-tauri/src/ui_assets.rs | 75 + apps/native/src-tauri/tauri.conf.json5 | 130 + apps/native/tsconfig.json | 11 + apps/web/README.md | 12 +- apps/web/index.html | 2 +- apps/web/index.native.html | 28 + apps/web/package.json | 6 + apps/web/public/native-boot.js | 20 + apps/web/src/components/auth-entry.tsx | 27 +- apps/web/src/components/auth-form-actions.ts | 78 + apps/web/src/components/chat/chat-context.tsx | 19 +- .../chat/connect-desktop-dialog.tsx | 18 +- .../src/components/connect/connect-dialog.tsx | 236 - apps/web/src/components/connect/mcp-url.ts | 14 - .../header/linked-desktop-indicator.tsx | 102 - .../components/sandbox/preview/preview.tsx | 35 +- .../sidebar/footer/sidebar-footer.tsx | 38 +- apps/web/src/components/unified-auth-form.tsx | 63 +- apps/web/src/desktop/auth-actions.test.ts | 147 + apps/web/src/desktop/auth-actions.ts | 111 + .../desktop/keychain-unavailable-screen.tsx | 32 + apps/web/src/desktop/sign-in-screen.tsx | 184 + apps/web/src/desktop/status-column.tsx | 25 + apps/web/src/desktop/use-desktop-auth.ts | 170 + apps/web/src/hooks/use-is-desktop-app.ts | 46 + apps/web/src/hooks/use-preferences.ts | 14 + apps/web/src/i18n/en/common.ts | 7 + apps/web/src/i18n/en/header.ts | 7 - apps/web/src/i18n/en/sandbox.ts | 2 + apps/web/src/i18n/pt-br/common.ts | 7 + apps/web/src/i18n/pt-br/header.ts | 7 - apps/web/src/i18n/pt-br/sandbox.ts | 3 + apps/web/src/i18n/use-t.ts | 17 +- apps/web/src/index.native.tsx | 113 + apps/web/src/index.web.tsx | 28 + .../src/layouts/org-shell-layout/index.tsx | 2 - apps/web/src/lib/desktop/mcp-oauth-adapter.ts | 65 + apps/web/src/lib/desktop/tauri-bridge.ts | 159 + .../src/lib/desktop/transport-rules.test.ts | 194 + apps/web/src/lib/desktop/transport-rules.ts | 137 + apps/web/src/lib/desktop/transport.ts | 79 + apps/web/src/lib/desktop/window-open-shim.ts | 89 + apps/web/src/lib/query-keys.ts | 6 + .../src/lib/vite-native-entry-rules.test.ts | 90 + apps/web/src/lib/vite-native-entry-rules.ts | 54 + apps/web/src/{index.tsx => router.tsx} | 40 +- apps/web/src/sdk/lib/mcp-oauth.ts | 92 +- apps/web/vite.config.ts | 134 +- bun.lock | 73 +- knip.jsonc | 6 + lefthook.yml | 8 + package.json | 1 + packages/e2e/fixtures/mcp-oauth.ts | 2 +- .../e2e/tests/desktop-session-bridge.spec.ts | 4 +- .../desktop-split-characterization.spec.ts | 121 + .../sandbox/daemon/daemon.e2e.helpers.test.ts | 16 + packages/sandbox/daemon/daemon.e2e.helpers.ts | 34 +- .../daemon/daemon.sse-shapes.e2e.test.ts | 335 + .../daemon/daemon.ws-proxy.e2e.test.ts | 229 + .../sandbox/daemon/e2e-stub/stub-daemon.mjs | 164 + 304 files changed, 94585 insertions(+), 533 deletions(-) create mode 100644 .github/workflows/native.yml create mode 100644 apps/native/.cargo/config.toml create mode 100644 apps/native/.gitignore create mode 100644 apps/native/Cargo.lock create mode 100644 apps/native/Cargo.toml create mode 100644 apps/native/README.md create mode 100644 apps/native/crates/harness/Cargo.toml create mode 100644 apps/native/crates/harness/src/detect.rs create mode 100644 apps/native/crates/harness/src/events/claude.rs create mode 100644 apps/native/crates/harness/src/events/codex.rs create mode 100644 apps/native/crates/harness/src/events/fixtures/claude_error.jsonl create mode 100644 apps/native/crates/harness/src/events/fixtures/claude_partial.jsonl create mode 100644 apps/native/crates/harness/src/events/fixtures/claude_simple.jsonl create mode 100644 apps/native/crates/harness/src/events/fixtures/claude_tool_use.jsonl create mode 100644 apps/native/crates/harness/src/events/fixtures/codex_error.jsonl create mode 100644 apps/native/crates/harness/src/events/fixtures/codex_exec.jsonl create mode 100644 apps/native/crates/harness/src/events/fixtures/codex_tool_and_error.jsonl create mode 100644 apps/native/crates/harness/src/events/fixtures/codex_tool_use.jsonl create mode 100644 apps/native/crates/harness/src/events/mod.rs create mode 100644 apps/native/crates/harness/src/lib.rs create mode 100644 apps/native/crates/harness/src/parts.rs create mode 100644 apps/native/crates/harness/src/resolve.rs create mode 100644 apps/native/crates/harness/src/run.rs create mode 100644 apps/native/crates/harness/src/spawn.rs create mode 100644 apps/native/crates/harness/src/tiers.rs create mode 100644 apps/native/crates/local-api/Cargo.toml create mode 100644 apps/native/crates/local-api/build.rs create mode 100644 apps/native/crates/local-api/src/auth.rs create mode 100644 apps/native/crates/local-api/src/client_auth.rs create mode 100644 apps/native/crates/local-api/src/config/classify.rs create mode 100644 apps/native/crates/local-api/src/config/merge.rs create mode 100644 apps/native/crates/local-api/src/config/mod.rs create mode 100644 apps/native/crates/local-api/src/config/store.rs create mode 100644 apps/native/crates/local-api/src/config/validate.rs create mode 100644 apps/native/crates/local-api/src/cors.rs create mode 100644 apps/native/crates/local-api/src/error.rs create mode 100644 apps/native/crates/local-api/src/events/broadcaster.rs create mode 100644 apps/native/crates/local-api/src/events/mod.rs create mode 100644 apps/native/crates/local-api/src/events/snapshot.rs create mode 100644 apps/native/crates/local-api/src/lib.rs create mode 100644 apps/native/crates/local-api/src/log_store/mod.rs create mode 100644 apps/native/crates/local-api/src/log_store/store.rs create mode 100644 apps/native/crates/local-api/src/main.rs create mode 100644 apps/native/crates/local-api/src/mutation.rs create mode 100644 apps/native/crates/local-api/src/process_group.rs create mode 100644 apps/native/crates/local-api/src/router.rs create mode 100644 apps/native/crates/local-api/src/routes/bash.rs create mode 100644 apps/native/crates/local-api/src/routes/config.rs create mode 100644 apps/native/crates/local-api/src/routes/dispatch.rs create mode 100644 apps/native/crates/local-api/src/routes/events.rs create mode 100644 apps/native/crates/local-api/src/routes/fs.rs create mode 100644 apps/native/crates/local-api/src/routes/fs/git_exclude.rs create mode 100644 apps/native/crates/local-api/src/routes/fs/glob_logic.rs create mode 100644 apps/native/crates/local-api/src/routes/fs/mcp_client.rs create mode 100644 apps/native/crates/local-api/src/routes/fs/safe_path.rs create mode 100644 apps/native/crates/local-api/src/routes/fs/tools_catalog.rs create mode 100644 apps/native/crates/local-api/src/routes/fs/tools_transaction.rs create mode 100644 apps/native/crates/local-api/src/routes/git.rs create mode 100644 apps/native/crates/local-api/src/routes/health.rs create mode 100644 apps/native/crates/local-api/src/routes/intercept/agent_sessions.rs create mode 100644 apps/native/crates/local-api/src/routes/intercept/decopilot.rs create mode 100644 apps/native/crates/local-api/src/routes/intercept/link_current.rs create mode 100644 apps/native/crates/local-api/src/routes/intercept/mod.rs create mode 100644 apps/native/crates/local-api/src/routes/intercept/preview_invoke.rs create mode 100644 apps/native/crates/local-api/src/routes/intercept/run_spool.rs create mode 100644 apps/native/crates/local-api/src/routes/intercept/sandbox_events.rs create mode 100644 apps/native/crates/local-api/src/routes/intercept/sandbox_fs.rs create mode 100644 apps/native/crates/local-api/src/routes/intercept/sandbox_lifecycle.rs create mode 100644 apps/native/crates/local-api/src/routes/intercept/sandbox_ops.rs create mode 100644 apps/native/crates/local-api/src/routes/intercept/thread_tools.rs create mode 100644 apps/native/crates/local-api/src/routes/intercept/watch.rs create mode 100644 apps/native/crates/local-api/src/routes/mcp_callback.rs create mode 100644 apps/native/crates/local-api/src/routes/mod.rs create mode 100644 apps/native/crates/local-api/src/routes/models.rs create mode 100644 apps/native/crates/local-api/src/routes/orgfs.rs create mode 100644 apps/native/crates/local-api/src/routes/proxy.rs create mode 100644 apps/native/crates/local-api/src/routes/repo_dir.rs create mode 100644 apps/native/crates/local-api/src/routes/scripts.rs create mode 100644 apps/native/crates/local-api/src/routes/setup.rs create mode 100644 apps/native/crates/local-api/src/routes/tasks.rs create mode 100644 apps/native/crates/local-api/src/routes/threads.rs create mode 100644 apps/native/crates/local-api/src/routes/threads/db.rs create mode 100644 apps/native/crates/local-api/src/routes/upstream.rs create mode 100644 apps/native/crates/local-api/src/routes/webdav.rs create mode 100644 apps/native/crates/local-api/src/routes/webdav/dav.rs create mode 100644 apps/native/crates/local-api/src/routes/webdav/junk.rs create mode 100644 apps/native/crates/local-api/src/routes/webdav/org_fs.rs create mode 100644 apps/native/crates/local-api/src/routes/ws_proxy.rs create mode 100644 apps/native/crates/local-api/src/sandbox/manager.rs create mode 100644 apps/native/crates/local-api/src/sandbox/mod.rs create mode 100644 apps/native/crates/local-api/src/sandbox/org_mount.rs create mode 100644 apps/native/crates/local-api/src/sandbox/org_prompt.rs create mode 100644 apps/native/crates/local-api/src/sandbox/org_view.rs create mode 100644 apps/native/crates/local-api/src/sandbox/persist.rs create mode 100644 apps/native/crates/local-api/src/sandbox/registry.rs create mode 100644 apps/native/crates/local-api/src/sandbox/repo_store.rs create mode 100644 apps/native/crates/local-api/src/sandbox/target.rs create mode 100644 apps/native/crates/local-api/src/setup/clone.rs create mode 100644 apps/native/crates/local-api/src/setup/dev.rs create mode 100644 apps/native/crates/local-api/src/setup/install.rs create mode 100644 apps/native/crates/local-api/src/setup/mod.rs create mode 100644 apps/native/crates/local-api/src/shutdown.rs create mode 100644 apps/native/crates/local-api/src/state.rs create mode 100644 apps/native/crates/local-api/src/tasks/mod.rs create mode 100644 apps/native/crates/local-api/src/tasks/registry.rs create mode 100644 apps/native/crates/local-api/src/ui.rs create mode 100644 apps/native/crates/upstream/Cargo.toml create mode 100644 apps/native/crates/upstream/src/bin/decocms-keychain-helper.rs create mode 100644 apps/native/crates/upstream/src/bridge.rs create mode 100644 apps/native/crates/upstream/src/browser_page.rs create mode 100644 apps/native/crates/upstream/src/clock.rs create mode 100644 apps/native/crates/upstream/src/cookie_jar.rs create mode 100644 apps/native/crates/upstream/src/keychain_helper.rs create mode 100644 apps/native/crates/upstream/src/lib.rs create mode 100644 apps/native/crates/upstream/src/login.rs create mode 100644 apps/native/crates/upstream/src/pkce.rs create mode 100644 apps/native/crates/upstream/src/refresh.rs create mode 100644 apps/native/crates/upstream/src/register.rs create mode 100644 apps/native/crates/upstream/src/revalidate.rs create mode 100644 apps/native/crates/upstream/src/session.rs create mode 100644 apps/native/crates/upstream/src/tokens.rs create mode 100644 apps/native/docs/org-fs-plan.md create mode 100644 apps/native/e2e/auth-matrix.e2e.test.ts create mode 100644 apps/native/e2e/authenticated-upstream.ts create mode 100644 apps/native/e2e/claude-session-resume.e2e.test.ts create mode 100644 apps/native/e2e/codex-session-resume.e2e.test.ts create mode 100644 apps/native/e2e/decopilot-queue-recovery.e2e.test.ts create mode 100644 apps/native/e2e/dev-signing-runner.e2e.test.ts create mode 100644 apps/native/e2e/dispatch-differential.e2e.test.ts create mode 100644 apps/native/e2e/dispatch-stream.e2e.test.ts create mode 100644 apps/native/e2e/dispatch.e2e.test.ts create mode 100644 apps/native/e2e/fixtures/golden/fail.claude-raw.ndjson create mode 100644 apps/native/e2e/fixtures/golden/simple.claude-raw.ndjson create mode 100644 apps/native/e2e/fixtures/golden/tooluse.claude-raw.ndjson create mode 100644 apps/native/e2e/fixtures/shutdown-process-tree.mjs create mode 100644 apps/native/e2e/fixtures/stub-claude-resume.mjs create mode 100644 apps/native/e2e/fixtures/stub-codex-resume.mjs create mode 100755 apps/native/e2e/fixtures/stub-harness.mjs create mode 100644 apps/native/e2e/fs-shutdown.e2e.test.ts create mode 100644 apps/native/e2e/git-sandbox.e2e.test.ts create mode 100644 apps/native/e2e/health.e2e.test.ts create mode 100644 apps/native/e2e/helpers.test.ts create mode 100644 apps/native/e2e/helpers.ts create mode 100644 apps/native/e2e/models.e2e.test.ts create mode 100644 apps/native/e2e/real-ui-interception.e2e.test.ts create mode 100644 apps/native/e2e/real-ui-passthrough.e2e.test.ts create mode 100644 apps/native/e2e/sandbox-fs-bridge.e2e.test.ts create mode 100644 apps/native/e2e/sandbox-resolution.e2e.test.ts create mode 100644 apps/native/e2e/sandbox-restart-resurrection.e2e.test.ts create mode 100644 apps/native/e2e/setup-log-retention.e2e.test.ts create mode 100644 apps/native/e2e/shutdown-lifecycle.e2e.test.ts create mode 100644 apps/native/e2e/sse.e2e.test.ts create mode 100644 apps/native/e2e/threads.e2e.test.ts create mode 100644 apps/native/e2e/upstream-auth-proxy.e2e.test.ts create mode 100644 apps/native/package.json create mode 100644 apps/native/rustfmt.toml create mode 100644 apps/native/scripts/boot-smoke-paths.test.ts create mode 100644 apps/native/scripts/boot-smoke-paths.ts create mode 100644 apps/native/scripts/boot-smoke.ts create mode 100644 apps/native/scripts/ci/check-junit-allowlist.mjs create mode 100644 apps/native/scripts/ci/daemon-parity-allowlist.json create mode 100644 apps/native/scripts/ci/stub-seam-allowlist.json create mode 100755 apps/native/scripts/create-dev-signing-cert.sh create mode 100755 apps/native/scripts/dev-runner.sh create mode 100644 apps/native/scripts/dev-signing-identity.sh create mode 100755 apps/native/scripts/fetch-rclone.sh create mode 100644 apps/native/src-tauri/.gitignore create mode 100644 apps/native/src-tauri/Cargo.toml create mode 100644 apps/native/src-tauri/build.rs create mode 100644 apps/native/src-tauri/capabilities/default.json create mode 100644 apps/native/src-tauri/icon-src/app-icon.png create mode 100644 apps/native/src-tauri/icons/128x128.png create mode 100644 apps/native/src-tauri/icons/128x128@2x.png create mode 100644 apps/native/src-tauri/icons/32x32.png create mode 100644 apps/native/src-tauri/icons/64x64.png create mode 100644 apps/native/src-tauri/icons/Square107x107Logo.png create mode 100644 apps/native/src-tauri/icons/Square142x142Logo.png create mode 100644 apps/native/src-tauri/icons/Square150x150Logo.png create mode 100644 apps/native/src-tauri/icons/Square284x284Logo.png create mode 100644 apps/native/src-tauri/icons/Square30x30Logo.png create mode 100644 apps/native/src-tauri/icons/Square310x310Logo.png create mode 100644 apps/native/src-tauri/icons/Square44x44Logo.png create mode 100644 apps/native/src-tauri/icons/Square71x71Logo.png create mode 100644 apps/native/src-tauri/icons/Square89x89Logo.png create mode 100644 apps/native/src-tauri/icons/StoreLogo.png create mode 100644 apps/native/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 apps/native/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png create mode 100644 apps/native/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 apps/native/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png create mode 100644 apps/native/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png create mode 100644 apps/native/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 apps/native/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png create mode 100644 apps/native/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png create mode 100644 apps/native/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 apps/native/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png create mode 100644 apps/native/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png create mode 100644 apps/native/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 apps/native/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 apps/native/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png create mode 100644 apps/native/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 apps/native/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 apps/native/src-tauri/icons/android/values/ic_launcher_background.xml create mode 100644 apps/native/src-tauri/icons/icon.icns create mode 100644 apps/native/src-tauri/icons/icon.ico create mode 100644 apps/native/src-tauri/icons/icon.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-20x20@1x.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-20x20@2x-1.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-20x20@2x.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-20x20@3x.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-29x29@1x.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-29x29@2x-1.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-29x29@2x.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-29x29@3x.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-40x40@1x.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-40x40@2x-1.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-40x40@2x.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-40x40@3x.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-512@2x.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-60x60@2x.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-60x60@3x.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-76x76@1x.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-76x76@2x.png create mode 100644 apps/native/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png create mode 100644 apps/native/src-tauri/permissions/autogenerated/auth_complete_session.toml create mode 100644 apps/native/src-tauri/permissions/autogenerated/auth_login.toml create mode 100644 apps/native/src-tauri/permissions/autogenerated/auth_logout.toml create mode 100644 apps/native/src-tauri/permissions/autogenerated/auth_status.toml create mode 100644 apps/native/src-tauri/permissions/autogenerated/local_api_info.toml create mode 100644 apps/native/src-tauri/permissions/autogenerated/selftest_report.toml create mode 100644 apps/native/src-tauri/selftest/bundle.js create mode 100644 apps/native/src-tauri/src/auth.rs create mode 100644 apps/native/src-tauri/src/commands.rs create mode 100644 apps/native/src-tauri/src/control_origin.rs create mode 100644 apps/native/src-tauri/src/csp.rs create mode 100644 apps/native/src-tauri/src/lib.rs create mode 100644 apps/native/src-tauri/src/main.rs create mode 100644 apps/native/src-tauri/src/selftest.rs create mode 100644 apps/native/src-tauri/src/setup.rs create mode 100644 apps/native/src-tauri/src/shutdown.rs create mode 100644 apps/native/src-tauri/src/state.rs create mode 100644 apps/native/src-tauri/src/ui_assets.rs create mode 100644 apps/native/src-tauri/tauri.conf.json5 create mode 100644 apps/native/tsconfig.json create mode 100644 apps/web/index.native.html create mode 100644 apps/web/public/native-boot.js create mode 100644 apps/web/src/components/auth-form-actions.ts delete mode 100644 apps/web/src/components/connect/connect-dialog.tsx delete mode 100644 apps/web/src/components/header/linked-desktop-indicator.tsx create mode 100644 apps/web/src/desktop/auth-actions.test.ts create mode 100644 apps/web/src/desktop/auth-actions.ts create mode 100644 apps/web/src/desktop/keychain-unavailable-screen.tsx create mode 100644 apps/web/src/desktop/sign-in-screen.tsx create mode 100644 apps/web/src/desktop/status-column.tsx create mode 100644 apps/web/src/desktop/use-desktop-auth.ts create mode 100644 apps/web/src/hooks/use-is-desktop-app.ts create mode 100644 apps/web/src/index.native.tsx create mode 100644 apps/web/src/index.web.tsx create mode 100644 apps/web/src/lib/desktop/mcp-oauth-adapter.ts create mode 100644 apps/web/src/lib/desktop/tauri-bridge.ts create mode 100644 apps/web/src/lib/desktop/transport-rules.test.ts create mode 100644 apps/web/src/lib/desktop/transport-rules.ts create mode 100644 apps/web/src/lib/desktop/transport.ts create mode 100644 apps/web/src/lib/desktop/window-open-shim.ts create mode 100644 apps/web/src/lib/vite-native-entry-rules.test.ts create mode 100644 apps/web/src/lib/vite-native-entry-rules.ts rename apps/web/src/{index.tsx => router.tsx} (95%) create mode 100644 packages/e2e/tests/desktop-split-characterization.spec.ts create mode 100644 packages/sandbox/daemon/daemon.e2e.helpers.test.ts create mode 100644 packages/sandbox/daemon/daemon.sse-shapes.e2e.test.ts create mode 100644 packages/sandbox/daemon/daemon.ws-proxy.e2e.test.ts create mode 100644 packages/sandbox/daemon/e2e-stub/stub-daemon.mjs diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml new file mode 100644 index 0000000000..7599c771cb --- /dev/null +++ b/.github/workflows/native.yml @@ -0,0 +1,441 @@ +name: Native + +# Phase 5 of the desktop migration contract (see the desktop migration contract §5.1). +# Gates the Rust/Tauri desktop app (apps/native/) — a separate cargo +# workspace, independent of the Bun workspaces the other workflows cover. +# +# Path-filtered (not the `changes`-job PR-diff pattern test.yml/e2e.yml/ +# sandbox-daemon.yml use) because this workflow is NOT yet a required +# branch-protection check — apps/native is still behind the migration flag +# (the desktop migration contract's `desktopAppAvailable`, default off) and this branch hasn't +# merged to main. A never-scheduled REQUIRED check blocks merges forever if +# skipped via workflow-level `paths:` (see test.yml's comment on its +# `changes` job) — but an opt-in, non-required check like this one is exactly +# what daemon-e2e-windows.yml already does with plain `paths:`, so this +# mirrors that convention. If/when this becomes a required check, switch to +# the `changes`-job pattern first. +on: + push: + branches: + - main + paths: + - "apps/native/**" + - "apps/web/**" + - "packages/sandbox/daemon/**" + - "packages/shared/**" + - ".github/workflows/native.yml" + pull_request: + branches: + - main + paths: + - "apps/native/**" + - "apps/web/**" + - "packages/sandbox/daemon/**" + - "packages/shared/**" + - ".github/workflows/native.yml" + # stub-seam-nightly keeps the DAEMON_E2E_CMD swap seam (packages/sandbox/ + # daemon/e2e-stub/) from rotting even on weeks with no apps/native PRs — + # see the desktop migration contract §5.1's "nightly job running the daemon-e2e stub-seam + # check". `schedule` + `workflow_dispatch` fire regardless of `paths:` + # (path filters only apply to push/pull_request), which is intentional + # here — every job below is gated by `if:` on `github.event_name` instead + # (see each job). + schedule: + - cron: "17 3 * * *" # nightly, arbitrary off-hour offset to dodge the top-of-hour stampede + workflow_dispatch: {} + +concurrency: + group: native-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + BUN_VERSION: "1.3.14" + +jobs: + # --------------------------------------------------------------------- + # 1. rust-checks — fmt / clippy / cargo test across the whole cargo + # workspace (crates/local-api, crates/harness, crates/upstream, + # src-tauri). macOS-only (matches the other jobs and the app's actual + # v1 target — the desktop migration contract's "v1 is macOS-only"); no ubuntu leg because + # src-tauri's deps (tao/wry/muda/objc2/...) are macOS-framework-linked + # and won't compile on Linux without a large extra apt-get surface + # this phase doesn't need to carry. + # --------------------------------------------------------------------- + rust-checks: + if: github.event_name != 'schedule' + runs-on: macos-latest + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry + target dir + uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/native + + - name: cargo fmt --check + working-directory: apps/native + run: cargo fmt --all -- --check + + - name: Placeholder frontendDist for generate_context! + # `tauri::generate_context!` (compiled by clippy + test below) panics if + # the configured `frontendDist` (../../web/dist/native) is absent. This + # job intentionally does NOT build the frontend, so drop a minimal + # placeholder — the real bundle is produced in the `tauri-build` job. + run: | + mkdir -p apps/web/dist/native + printf '' > apps/web/dist/native/index.html + + # `tauri-build` resolves `externalBin` in `src-tauri/build.rs`, so ANY + # cargo command touching the workspace needs the sidecar on disk — not + # just `tauri build`. It is git-ignored and fetched at build time, so + # without this every cargo job fails with "resource path + # binaries/rclone- doesn't exist". + - name: Cache bundled rclone + uses: actions/cache@v4 + with: + path: apps/native/src-tauri/binaries + key: rclone-${{ runner.os }}-${{ hashFiles('apps/native/scripts/fetch-rclone.sh') }} + + - name: Fetch bundled rclone + working-directory: apps/native + run: ./scripts/fetch-rclone.sh + + - name: cargo clippy -D warnings + working-directory: apps/native + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: cargo test --workspace + working-directory: apps/native + run: cargo test --workspace + + # --------------------------------------------------------------------- + # 2. daemon-e2e-vs-rust — the parity oracle: build the release local-api + # binary, point packages/sandbox's curated 5-file daemon e2e suite at + # it via DAEMON_E2E_CMD, and assert the EXACT documented 133 pass / 7 + # fail outcome — not just the counts. apps/native/docs/ + # the native parity contract pins the 7 failing test names as intentional + # auth/CORS tightening (+ one harness self-check artifact); a NEW + # failure (regression) or a VANISHED one (doc gone stale) both fail + # this job via apps/native/scripts/ci/check-junit-allowlist.mjs. + # + # `daemon.proxy.e2e.test.ts` + `daemon.ws-proxy.e2e.test.ts` (12 tests, + # formerly "all pass") are DELIBERATELY excluded from this curated set + # as of the port-router split (`local_api::ServerHandle`'s dedicated + # PREVIEW listener) — this harness (`daemon.e2e.helpers.ts`, owned by + # `packages/sandbox`, out of bounds to edit here) hard-codes a SINGLE + # port for the whole daemon and has no way to discover/target a SECOND + # one, so it structurally cannot drive local-api's now-separate preview + # listener. See `the native parity contract`'s "Reverse proxy + + # WS proxy" exclusion section for the full rationale and this crate's + # own substitute oracle coverage (`apps/native/e2e/git-sandbox.e2e.test.ts`, + # `real-ui-passthrough.e2e.test.ts`). + # --------------------------------------------------------------------- + daemon-e2e-vs-rust: + if: github.event_name != 'schedule' + runs-on: macos-latest + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + target dir + uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/native + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Install dependencies + run: bun install + + # The fs `grep` route (in the curated suite's daemon.e2e.test.ts) + # shells out to ripgrep; install it so the test asserts results + # strictly instead of tolerating "grep unavailable" — same reasoning + # as sandbox-daemon.yml / daemon-e2e-windows.yml's ripgrep installs. + - name: Install ripgrep + run: brew install ripgrep + + - name: Build release local-api binary + working-directory: apps/native + run: cargo build --release --bin local-api + + - name: Run curated daemon e2e subset against local-api + id: daemon-parity + # 7 documented failures are EXPECTED here (see the native parity contract) — + # `|| true` keeps this step green so the next step (the actual + # gate) can inspect the junit output and fail precisely on a SET + # mismatch, not merely on bun test's own non-zero exit. + # + # `daemon.proxy.e2e.test.ts` / `daemon.ws-proxy.e2e.test.ts` are NOT + # in this list — see the job header comment above and + # the native parity contract for why (the port-router split moved that + # traffic to a second listener this single-port harness can't + # reach). + run: | + LOCAL_API_BIN="$GITHUB_WORKSPACE/apps/native/target/release/local-api" + export DAEMON_E2E_CMD="[\"$LOCAL_API_BIN\"]" + cd packages/sandbox + bun test daemon/daemon.e2e.test.ts daemon/daemon.sse-shapes.e2e.test.ts \ + daemon/daemon.git.e2e.test.ts daemon/daemon.tools.e2e.test.ts \ + daemon/daemon.dispatch.e2e.test.ts \ + --reporter=junit --reporter-outfile="$RUNNER_TEMP/daemon-parity.xml" || true + + - name: Assert exact fail-set match against the pinned allowlist + run: | + bun run --cwd apps/native check:junit-allowlist -- \ + "$RUNNER_TEMP/daemon-parity.xml" \ + "$GITHUB_WORKSPACE/apps/native/scripts/ci/daemon-parity-allowlist.json" + + - name: Upload junit report (debugging aid) + if: always() + uses: actions/upload-artifact@v4 + with: + name: daemon-parity-junit + path: ${{ runner.temp }}/daemon-parity.xml + if-no-files-found: warn + retention-days: 7 + + # --------------------------------------------------------------------- + # 3. contract-suite — apps/native/e2e's own black-box contract suite + # (LOCAL_API_E2E_CMD) against the release local-api binary. Unlike the + # parity oracle above, this suite is OWNED by this implementation + # (the native local-API contract is the spec it pins), so + # the bar is simply "all green" — no allowlist needed. + # --------------------------------------------------------------------- + contract-suite: + if: github.event_name != 'schedule' + runs-on: macos-latest + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + target dir + uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/native + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Install dependencies + run: bun install + + - name: Build release local-api binary + working-directory: apps/native + run: cargo build --release --bin local-api + + - name: Run local-api contract suite + working-directory: apps/native + run: | + export LOCAL_API_E2E_CMD="[\"$GITHUB_WORKSPACE/apps/native/target/release/local-api\"]" + bun test e2e + + # --------------------------------------------------------------------- + # 4. tauri-build — bundles the debug .app, uploads it as an artifact, and + # boot-smokes it. macOS code-signing / notarization / updater-artifact + # signing are handled ENTIRELY by the Tauri CLI itself once the + # matching env vars are non-empty (https://v2.tauri.app/distribute/ + # sign/macos/) — this job does not hand-roll `security`/`notarytool` + # calls, it just wires the documented secret names through as env and + # lets `tauri build` no-op into an unsigned/ad-hoc build when they're + # absent (the fork-safe default: every secret below is empty on a + # fork PR, so signing/notarization are skipped, never failed). + # --------------------------------------------------------------------- + tauri-build: + if: github.event_name != 'schedule' + runs-on: macos-latest + timeout-minutes: 45 + env: + # --- macOS code signing + notarization (Apple Developer ID) --------- + # All optional. Human-required setup (cert enrollment, notarization + # credentials) — see the native release contract (§5.3, + # dormant scaffolding). Required secret names, each consumed only by + # the `tauri build` step below via env: + # APPLE_CERTIFICATE - base64-encoded .p12 Developer ID cert + # APPLE_CERTIFICATE_PASSWORD - password for the .p12 above + # APPLE_SIGNING_IDENTITY - cert Common Name, e.g. "Developer ID + # Application: ()" — + # not in the brief's original list but + # REQUIRED by Tauri's signing flow + # alongside the cert itself; documented + # here so it isn't missed when the + # other 5 Apple secrets are configured + # APPLE_ID - Apple ID used for notarization + # APPLE_PASSWORD - app-specific password for APPLE_ID + # APPLE_TEAM_ID - Developer Team ID + # DELIBERATELY NOT WIRED HERE. This job only ever produces a DEBUG + # bundle for boot-smoke, which runs the binary directly and gains + # nothing from a Developer ID. Exporting the secrets made the Tauri CLI + # attempt `security import` on every build in this job — including the + # one boot-smoke launches itself — and it failed with "failed codesign + # application: failed to import keychain certificate", from a cert that + # is present but not importable. Wiring them cost a green CI and bought + # nothing. Signing belongs to the separate, secret-gated release job. + # --- Tauri updater artifact signing (separate keypair; unrelated to + # Apple code signing above) — only takes effect once + # tauri.conf.json5 configures the `updater` bundle target, which + # is still dormant (the native release contract + # §"Updater"). Wired here now so no further workflow edit is + # needed once that config lands. + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + target dir + uses: Swatinem/rust-cache@v2 + with: + workspaces: apps/native + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Install dependencies + run: bun install + + # rclone is bundled as an `externalBin` (the org filesystem's NFS + # mount) and deliberately NOT committed — 78 MB per architecture. + # Keyed on the fetch script's hash, so bumping the pinned version in + # that script busts the cache automatically. + - name: Cache bundled rclone + uses: actions/cache@v4 + with: + path: apps/native/src-tauri/binaries + key: rclone-${{ runner.os }}-${{ hashFiles('apps/native/scripts/fetch-rclone.sh') }} + + # Explicit for the same reason as the frontend bundle below: + # `beforeBuildCommand` also runs it, but a dedicated step attributes a + # download or checksum failure to itself instead of to `tauri build`. + - name: Fetch bundled rclone + working-directory: apps/native + run: ./scripts/fetch-rclone.sh + + # Explicit step (not relying solely on tauri.conf.json5's own + # `beforeBuildCommand: bun run build:native`) for a clearer CI step + # boundary/log — slightly redundant with that hook, harmless (the + # second invocation is a fast no-op rebuild of already-fresh output). + - name: Build desktop frontend bundle + run: bun run --cwd apps/web build:native + + - name: tauri build --debug + working-directory: apps/native + # This build is NEVER signed, whether or not the secrets exist. + # boot-smoke runs the binary directly and a Developer ID buys it + # nothing, while attempting the import couples this job to the health + # of six repo secrets — which is exactly how it has been failing: + # "failed codesign application: failed to import keychain certificate", + # from a cert that is present but not importable. Signing belongs to + # the separate, secret-gated release job (see the native release + # contract). + run: bunx @tauri-apps/cli@latest build --debug + + - name: Upload .app artifact + uses: actions/upload-artifact@v4 + with: + name: decocms-desktop-debug-app + path: apps/native/target/debug/bundle/macos/decocms-desktop.app + if-no-files-found: error + retention-days: 14 + + # Boot-smoke consideration (the desktop migration contract §5.1): CI macOS runners + # are full logged-in GUI VMs (not headless like Linux — no Xvfb + # needed), and DESKTOP_SELFTEST=1 builds the "main" window + # `.visible(false)` specifically so this never pops a real window + # (see the native Tauri integration's "Self-test mode" section) — confirmed + # locally: `bun run --cwd apps/native smoke:boot` passes end-to-end + # (all 7 self-test checks + the orphan-process check) without ever + # showing a window. Included here rather than documented as + # local-only. Residual risk not fully provable from a dev machine: + # `authStatusInvoke`'s status check touches macOS Keychain via the + # `keyring` crate (crates/upstream) — GH's macos-latest runners + # provide an unlocked default login keychain so a read-only + # "no stored session" status check is expected to work the same way + # it does locally, but if this step ever starts failing ONLY in CI + # with a Keychain-access error, add a + # `security unlock-keychain -p runner ~/Library/Keychains/login.keychain-db` + # step immediately before this one rather than reverting it to + # local-only. + - name: Boot smoke (DESKTOP_SELFTEST=1, hidden window) + run: bun run --cwd apps/native smoke:boot + + # --------------------------------------------------------------------- + # 5. stub-seam-nightly — keeps the DAEMON_E2E_CMD swap-seam mechanics + # (spawn / env contract / health-poll / SIGKILL-teardown) exercised + # even on weeks with no apps/native PRs, per the desktop migration contract §5.1. + # Runs the plain Node stub (packages/sandbox/daemon/e2e-stub/ + # stub-daemon.mjs), not the Rust binary — ubuntu-latest is fine since + # nothing here is macOS-specific (no cargo build at all). Asserts the + # documented 76 pass / 27 fail table from the daemon parity contract via the + # same exact-set allowlist checker as job 2 — this table already + # documents (and this job tolerates, by pinning it into the allowlist + # like every other entry) the one env-leakage self-check failure + # (`swappable spawn target > defaults to ... when DAEMON_E2E_CMD is + # unset`) the README's "Friction found in the seam" section explains. + # --------------------------------------------------------------------- + stub-seam-nightly: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Install dependencies + run: bun install + + - name: Run daemon.e2e.test.ts against the e2e-stub seam probe + # 27 documented failures are EXPECTED (this stub implements only a + # slice of the daemon's route surface — see the daemon parity contract's + # "What stub-daemon.mjs implements"). `|| true` for the same reason + # as job 2's parity step — the next step is the real gate. + run: | + STUB="$GITHUB_WORKSPACE/packages/sandbox/daemon/e2e-stub/stub-daemon.mjs" + export DAEMON_E2E_CMD="[\"node\", \"$STUB\"]" + cd packages/sandbox + bun test daemon/daemon.e2e.test.ts \ + --reporter=junit --reporter-outfile="$RUNNER_TEMP/stub-seam.xml" || true + + - name: Assert exact fail-set match against the pinned allowlist + run: | + bun run --cwd apps/native check:junit-allowlist -- \ + "$RUNNER_TEMP/stub-seam.xml" \ + "$GITHUB_WORKSPACE/apps/native/scripts/ci/stub-seam-allowlist.json" + + - name: Upload junit report (debugging aid) + if: always() + uses: actions/upload-artifact@v4 + with: + name: stub-seam-junit + path: ${{ runner.temp }}/stub-seam.xml + if-no-files-found: warn + retention-days: 7 diff --git a/.gitignore b/.gitignore index e8cb09f66f..cc7ac42f8c 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,8 @@ packages/e2e/playwright-report/ # so we can un-ignore specific subdirs below — git won't traverse into an # ignored directory. /docs/* +# Committed engineering plans (desktop migration etc.) +!/docs/plans/ # Superpowers workspace artifacts (brainstorming + writing-plans output) # are kept local to the working tree, not committed. diff --git a/AGENTS.md b/AGENTS.md index 6a9cf0d031..3a4249b2ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,8 +25,24 @@ bun run --cwd=apps/api dev:server # Run documentation site locally bun run docs:dev + +# Native desktop app (Tauri) dev loop — HMR via Vite on port 4420 +bun run --cwd=apps/native dev ``` +**One-time macOS setup for `apps/native` devs**: run +`bun run --cwd=apps/native dev:signing:setup` once. It creates or reuses the +local self-signed `decocms-dev` code-signing identity; no Apple Developer +account is required. It also builds, signs, and installs one fixed +`decocms-keychain-helper` under the user's Application Support directory. +Debug app rebuilds talk to that unchanged helper over JSON stdin/stdout, so +Keychain sees one stable executable; tokens never use argv, logs, or a +filesystem fallback. The native dev runner still signs the app itself and +fails closed if signing drifts, but the fixed helper—not the app's self-signed +designated requirement—is what makes debug Keychain access stable. Debug +sessions stay in the Keychain-only `com.decocms.studio.dev` namespace; release +sessions stay in `com.decocms.studio`. + ### Testing & Quality ```bash # Run all tests (Bun test runner) diff --git a/README.md b/README.md index 68ffdd2be7..3b1840721b 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,7 @@ Every tool call gets input/output validation, access control, audit logging, and | --- | --- | | [`apps/api`](./apps/api/README.md) | Hono API, authentication, tools, storage, migrations, and the `deco` CLI | | [`apps/docs`](./apps/docs/README.md) | Astro documentation site | +| [`apps/native`](./apps/native/README.md) | Tauri desktop app and local Rust runtime | | [`apps/web`](./apps/web/README.md) | Vite and React 19 administration interface | ### Packages diff --git a/apps/api/src/api/routes/desktop-session-bridge.ts b/apps/api/src/api/routes/desktop-session-bridge.ts index 8ca824a86a..efeda6d9d2 100644 --- a/apps/api/src/api/routes/desktop-session-bridge.ts +++ b/apps/api/src/api/routes/desktop-session-bridge.ts @@ -2,7 +2,7 @@ * Desktop system-browser session bridge. * * `POST /api/auth/desktop/session-from-oauth` — the one Studio-side change - * `apps/desktop/docs/real-ui-auth-recon.md` concluded was unavoidable to fix + * the native authentication contract concluded was unavoidable to fix * the Google/GitHub/SAML system-browser desktop login path: an MCP OAuth * access token (the `oauthAccessToken` row both desktop login paths * ultimately hold) satisfies org-scoped `/api/:org/*` routes but is REJECTED @@ -20,7 +20,7 @@ * `Set-Cookie` header — this caller is a native app process, not a browser * that would capture one) so it can forward that value itself, byte for * byte, as `Cookie: better-auth.session_token=` — see - * `apps/desktop/crates/upstream/src/login.rs`'s + * `apps/native/crates/upstream/src/login.rs`'s * `mint_session_from_access_token`, the Rust-side caller. * * ## Auth guard diff --git a/apps/native/.cargo/config.toml b/apps/native/.cargo/config.toml new file mode 100644 index 0000000000..8c7f34e3ca --- /dev/null +++ b/apps/native/.cargo/config.toml @@ -0,0 +1,11 @@ +# macOS dev-build signing hook. Cargo routes each locally-executed binary +# through the runner, but the script signs only `decocms-desktop`; test binaries +# pass through unchanged. The app uses the local self-signed `decocms-dev` +# identity with an explicit certificate-hash + bundle-identifier requirement so +# Keychain ACLs remain stable across rebuilds. Signing fails closed instead of +# launching an ad-hoc binary. +[target.aarch64-apple-darwin] +runner = "scripts/dev-runner.sh" + +[target.x86_64-apple-darwin] +runner = "scripts/dev-runner.sh" diff --git a/apps/native/.gitignore b/apps/native/.gitignore new file mode 100644 index 0000000000..124e199b8d --- /dev/null +++ b/apps/native/.gitignore @@ -0,0 +1,4 @@ +/target/ +**/target/ +/.dev-signing-identity +/src-tauri/binaries/ diff --git a/apps/native/Cargo.lock b/apps/native/Cargo.lock new file mode 100644 index 0000000000..5e52d87099 --- /dev/null +++ b/apps/native/Cargo.lock @@ -0,0 +1,6821 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "apple-native-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "base64 0.22.1", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.24.0", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "decocms-desktop" +version = "0.1.0" +dependencies = [ + "async-trait", + "local-api", + "rand 0.8.7", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-mcp-bridge", + "tauri-plugin-opener", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", + "upstream", + "uuid", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.3+spec-1.1.0", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "harness" +version = "0.1.0" +dependencies = [ + "bytes", + "futures", + "futures-util", + "portable-pty", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-layer", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b009b6744c1445efd7244084e25e498636412effb6760b55067553baa925cc7" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "keyring" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0298a59b384c540e408a600c8b375a09b49c3f97debc080e2c30675d79a6368a" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "local-api" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "bytes", + "futures", + "futures-util", + "globset", + "harness", + "hyper", + "hyper-util", + "ignore", + "notify", + "rand 0.8.7", + "regex", + "reqwest 0.12.28", + "rusqlite", + "serde", + "serde_json", + "sha2", + "subtle", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite 0.23.1", + "tower 0.4.13", + "tower-http 0.5.2", + "tracing", + "tracing-subscriber", + "upstream", + "urlencoding", + "uuid", + "walkdir", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.13.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-location 0.3.2", + "objc2-core-text", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-user-notifications 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-web-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68bc69301064cebefc6c4c90ce9cba69225239e4b8ff99d445a2b5563797da65" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases 0.2.2", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases 0.2.2", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower 0.5.3", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower 0.5.3", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serial2" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "core-foundation 0.10.1", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "objc2-web-kit 0.3.2", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-mcp-bridge" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee9c726f2293f54dc9a61739886967a75c9f1701d17a3dcb8a4544f7353d4861" +dependencies = [ + "base64 0.22.1", + "block2 0.5.1", + "futures-util", + "image", + "jni", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "objc2-web-kit 0.2.2", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite 0.28.0", + "uuid", + "webview2-com", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2 0.6.4", + "objc2-ui-kit 0.3.2", + "objc2-web-kit 0.3.2", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "json5", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 1.1.3+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.3+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +dependencies = [ + "bytes", + "libc", + "mio 1.2.2", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.23.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.28.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower 0.5.3", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tray-icon" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.7", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.7", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "upstream" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "dirs", + "keyring", + "rand 0.8.7", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "urlencoding", + "uuid", + "wait-timeout", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2 0.6.2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "objc2-web-kit 0.3.2", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "winnow 1.0.4", +] diff --git a/apps/native/Cargo.toml b/apps/native/Cargo.toml new file mode 100644 index 0000000000..005906eb48 --- /dev/null +++ b/apps/native/Cargo.toml @@ -0,0 +1,84 @@ +[workspace] +resolver = "2" +members = ["crates/local-api", "crates/harness", "crates/upstream", "src-tauri"] + +[workspace.package] +edition = "2021" +version = "0.1.0" +license = "MIT" +publish = false + +# Warn locally (fast inner-loop feedback); CI runs +# `cargo clippy -- -D warnings` from apps/native/, which promotes every +# warning below to a hard failure. Do not add `deny` here — a `deny` in the +# manifest breaks `cargo build`/`check` too, not just `clippy`, which is +# stricter than intended for local iteration. +[workspace.lints.rust] +unsafe_code = "deny" + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } + +# --- Dependency versions, pinned once here and inherited via `.workspace = true` +# in crates/local-api/Cargo.toml. Over-provisioned on purpose (bootstrap +# directive): every family implementer building on this skeleton pulls from +# this table instead of editing it — Cargo.toml is a shared file family +# owners must not touch (see the native module-ownership contract). +[workspace.dependencies] +axum = { version = "0.7", features = ["ws", "macros"] } +tokio = { version = "1", features = ["full"] } +tower = { version = "0.4", features = ["util"] } +tower-http = { version = "0.5", features = ["trace", "cors", "fs", "timeout"] } +hyper = { version = "1", features = ["full"] } +hyper-util = { version = "0.1", features = ["full", "client-legacy", "http1"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +rusqlite = { version = "0.31", features = ["bundled"] } +reqwest = { version = "0.12", default-features = false, features = [ + "json", + "stream", + "rustls-tls", +] } +globset = "0.4" +walkdir = "2" +ignore = "0.4" +notify = "6" +regex = "1" +subtle = "2" +rand = "0.8" +futures = "0.3" +futures-util = "0.3" +bytes = "1" +urlencoding = "2" +uuid = { version = "1", features = ["v4"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +async-trait = "0.1" +# Inbound WS upgrades go through axum's own `ws` feature (above); this is for +# a family that needs a WS *client* role (e.g. proxying an outbound upgrade). +tokio-tungstenite = "0.23" +tempfile = "3" +# Phase 3 (`src-tauri/`, the Tauri shell) additions — distinct lines to +# minimize merge friction with `crates/upstream` (built in parallel), which +# is expected to add its own (`keyring`, etc.) below these rather than +# interleaving. +tauri = { version = "2", features = ["config-json5"] } +# Dev-only MCP bridge for @hypothesi/tauri-mcp-server (registered under +# `#[cfg(debug_assertions)]` in src-tauri/src/lib.rs — never in release). +tauri-plugin-mcp-bridge = "0.12" +tauri-plugin-opener = "2" +tauri-build = { version = "2", features = ["config-json5"] } +# `crates/upstream`'s OAuth/Keychain-specific deps (`keyring`, `sha2`, +# `base64`, `dirs`) are declared directly in `crates/upstream/Cargo.toml` +# instead of here, following the same "single-consumer dep stays local" +# precedent `crates/harness/Cargo.toml` set for `portable-pty` — see that +# crate's Cargo.toml doc comment. + +# Phase 2's `crates/harness` PTY spawn (see +# the desktop migration contract) declares `portable-pty` (and +# `libc`, for process-group signaling) directly in its OWN +# `crates/harness/Cargo.toml` rather than here — this table stays the +# family-agnostic dependency set every OTHER crate inherits via +# `.workspace = true`; local-api itself never spawns a PTY directly and has +# no reason to pull either dependency in. diff --git a/apps/native/README.md b/apps/native/README.md new file mode 100644 index 0000000000..dae78873fb --- /dev/null +++ b/apps/native/README.md @@ -0,0 +1,153 @@ +# Studio Native + +Packages the existing Studio web application as a Tauri desktop app with a +local Rust runtime. + +| Attribute | Value | +| --- | --- | +| Workspace | `@decocms/native` (`apps/native`) | +| Kind | Tauri desktop application and local runtime | +| Runtime | Rust, WebKit, and the bundled React application | +| Distribution | Private workspace package; signed macOS application | + +## Overview + +Studio Native embeds the production `apps/web` UI without forking its product +shell. An in-process Axum server serves the bundled UI and native API from one +stable loopback origin, proxies upstream Studio requests, and intercepts the +local-only thread, harness, sandbox, filesystem, Git, task, and preview +surfaces. + +The current release targets macOS. The Rust crates and browser contracts are +kept portable so Windows can be added without redesigning the application +boundary. + +## Responsibilities + +- Start and supervise the local Rust API inside the Tauri process. +- Serve the native Vite bundle and API from one `localhost` origin. +- Authenticate local control requests with an HttpOnly `SameSite=Strict` + cookie. +- Store upstream Studio sessions in the operating-system credential store. +- Detect and run the user's Claude Code and Codex installations. +- Persist native threads and provider session identifiers in SQLite. +- Create, recover, start, stop, and restart local Git-backed sandboxes. +- Retain subprocess output in bounded files and replay their tails to xterm.js. +- Proxy sandbox previews without stripping the sandbox application's cookies or + authorization headers. + +## Usage + +From the repository root, install dependencies and start the native HMR loop: + +```bash +bun install +bun run --cwd=apps/native dev +``` + +The command starts native-mode Vite from `apps/web` on +`http://localhost:4420`, builds and launches the Rust shell, and proxies native +control routes to the embedded API. + +Build the native web bundle independently with: + +```bash +bun run --cwd=apps/web build:native +``` + +Build the Tauri application with: + +```bash +bunx tauri build --config apps/native/src-tauri/tauri.conf.json5 +``` + +## Architecture + +```text +Tauri window + | + v +http://localhost: + | + +-- bundled apps/web UI + +-- local control API + +-- upstream Studio proxy ----> studio.decocms.com + +-- sandbox preview proxy ----> user dev server + | + +-- SQLite threads and sandbox registry + +-- Keychain session + +-- Claude Code / Codex processes +``` + +Key paths: + +| Path | Purpose | +| --- | --- | +| `src-tauri/` | Tauri shell, stable control origin, bundled assets, CSP, and native commands | +| `crates/local-api/` | Axum control API, proxy, thread store, sandbox manager, and setup pipeline | +| `crates/harness/` | Claude Code and Codex detection, process execution, and stream translation | +| `crates/upstream/` | OAuth, token refresh, Keychain access, and upstream session handling | +| `e2e/` | Black-box native API, sandbox, provider-resume, and recovery tests | +| `scripts/` | Dev signing, boot smoke, and release helpers | + +The packaged UI is built by `apps/web`; the hosted Hono backend remains in +`apps/api`. Browser-safe contracts shared by both live in `@decocms/shared`. + +## Development + +Run the focused verification gates from the repository root: + +```bash +bun run --cwd=apps/web check +bun run --cwd=apps/api check +bun run --cwd=apps/native check +cd apps/native && cargo fmt --check +cd apps/native && cargo clippy --workspace --all-targets -- -D warnings +cd apps/native && cargo test --workspace +bun run --cwd=apps/native smoke:boot +``` + +### Dev sessions & the Keychain + +On each macOS development machine, create the stable self-signed development +identity once: + +```bash +bun run --cwd=apps/native dev:signing:setup +``` + +The Cargo runner signs each development binary with that identity. This gives +Keychain entries a stable designated requirement across Rust rebuilds, so a +saved Studio session remains readable. The shipped app continues to use its +release signing identity; there is no filesystem token-store fallback. + +### Verification matrix + +The native E2E suite can run against the built `local-api` binary through +`LOCAL_API_E2E_CMD`. Provider-resume tests use deterministic Claude Code and +Codex fixtures to prove that the persisted provider session ID is reused and +only the newest user message is sent when a run resumes. + +Run `bun run fmt` after changes. + +## Boundaries + +- Keep product UI and user-facing behavior in `apps/web`; native code should + intercept transport and local capabilities rather than create a second UI. +- Keep hosted Hono routes and server persistence in `apps/api`. +- Keep browser-safe wire contracts in `@decocms/shared`; Rust mirrors those + contracts at the process boundary. +- Never expose upstream access or refresh tokens to webview JavaScript. +- Do not authorize sandbox preview traffic with the control cookie or rewrite + its application cookies. Preview requests are a transparent, dedicated + proxy surface. +- Sandboxes may persist identity and logs, but child processes are always + re-established after an application restart rather than orphaned. +- Subprocess logs are file-backed and bounded; memory holds only live fan-out. + +## Related documentation + +- [Studio Web](../web/README.md) +- [Studio API](../api/README.md) +- [Repository guidelines](../../AGENTS.md) +- [Testing strategy](../../TESTING.md) diff --git a/apps/native/crates/harness/Cargo.toml b/apps/native/crates/harness/Cargo.toml new file mode 100644 index 0000000000..8b9dcf2220 --- /dev/null +++ b/apps/native/crates/harness/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "harness" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +# Shared workspace-pinned deps (see apps/native/Cargo.toml's +# `[workspace.dependencies]` table) — consumed via `.workspace = true`, +# never edited here. +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +futures = { workspace = true } +futures-util = { workspace = true } +bytes = { workspace = true } + +# Crate-local dependency, NOT in the shared workspace table (see that +# table's trailing comment) — declared directly here since this crate is +# its only consumer. +# +# `portable-pty` — wezterm's PTY crate (see the native implementation contract +# section 4 for the full "why portable-pty over node-pty" writeup): spawns +# the child via `std::process::Command` from inside our own signed binary +# (no separate `spawn-helper` executable to lose its execute bit or fail +# Gatekeeper), used for the PTY-backed half of `spawn::spawn` — see that +# module's doc comment for why it is NOT the path harness dispatch +# actually takes (plain pipes are; PTY is implemented for completeness / +# any future TTY-requiring CLI, per the Phase 2 TODO's explicit ask). +# +# No `libc`/`nix` dependency: the workspace denies `unsafe_code` (see +# `apps/native/Cargo.toml`'s `[workspace.lints.rust]`), and every raw +# `kill(2)`/`setpgid(2)` FFI call requires `unsafe`. Process-GROUP +# signaling (`spawn.rs::kill_process_group`) instead shells out to the +# `kill(1)` binary (`kill -TERM -`) — POSIX-portable, zero `unsafe`, +# no extra dependency. Process-group CREATION uses +# `std::os::unix::process::CommandExt::process_group`, which is a safe, +# stable std API (since Rust 1.64) — no FFI needed for that half either. +portable-pty = "0.9" + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/apps/native/crates/harness/src/detect.rs b/apps/native/crates/harness/src/detect.rs new file mode 100644 index 0000000000..c855ae3fca --- /dev/null +++ b/apps/native/crates/harness/src/detect.rs @@ -0,0 +1,418 @@ +//! CLI availability detection — GROW-ONLY cache mirroring +//! `apps/api/src/link-daemon/capabilities.ts`'s semantics EXACTLY: +//! `mergeProbedCapabilities` there never un-advertises a capability once +//! seen ("a transient probe failure (CLI busy, SDK hiccup) must never +//! un-advertise a capability mid-run — dispatch routing would start +//! bouncing threads that are actively streaming"). This module ports that +//! same invariant to Rust: once a harness flips to `true`, no later probe +//! — however it fails — can flip it back. +//! +//! "Detected" means the CLI is installed AND has a confirmed logged-in +//! session — FAIL-CLOSED: an installed-but-not-logged-in CLI is not usable, +//! so it does NOT count as detected. `probe()` requires BOTH a successful +//! `--version` probe (installed) AND a passing auth probe (logged in): +//! - claude via ` auth status --json`'s `"loggedIn":true` (mirrors +//! `capabilities.ts::detectClaudeCode`'s account-email check, adapted +//! since this crate drives the CLI's own surface rather than the +//! `@anthropic-ai/claude-agent-sdk`), +//! - codex via ` login status`'s "Logged in" text — which the codex +//! CLI prints to STDERR with an empty stdout, so the auth probe merges +//! stdout+stderr before parsing (see `run_probe_capture`). +//! +//! A failed/unrecognized/unparseable auth check keeps `detected` at +//! `false` (logged as a diagnostic). The grow-only cache invariant above +//! still holds: once a confirmed-logged-in probe flips a harness to +//! `true`, no later probe flips it back — but a CLI never reaches `true` +//! in the first place until login is confirmed. +//! +//! Because detection is now auth-gated, the SHARED CONTRACT surface +//! `apps/native/e2e/fixtures/stub-harness.mjs` answers the auth probe as +//! logged-in too (claude: `{"loggedIn":true}` on stdout for `auth status +//! --json`; codex: "Logged in using ChatGPT" on stderr for `login status`) +//! in ADDITION to `--version`, so the differential/parity tests still see +//! it as detected. Both probes are independently swappable via +//! [`resolve::resolve_argv`] (the SHARED CONTRACT env overrides). + +use std::sync::OnceLock; +use std::time::Duration; + +use tokio::sync::RwLock; + +use crate::resolve::{self, HarnessId}; + +/// Wall-clock cap on a single probe subprocess (version OR auth check). +/// Generous for a binary that's actually installed and responsive, bounded +/// so a hung/misbehaving binary on someone's PATH can't wedge detection. +pub const PROBE_TIMEOUT: Duration = Duration::from_secs(5); +/// How often the background task re-probes while at least one harness is +/// still undetected. Matches the order of magnitude of +/// `capabilities.ts::startCapabilityReprobe`'s default (60s). +pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60); + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Detection { + pub claude_code: bool, + pub codex: bool, +} + +impl Detection { + pub fn get(self, harness: HarnessId) -> bool { + match harness { + HarnessId::ClaudeCode => self.claude_code, + HarnessId::Codex => self.codex, + } + } + + fn set(&mut self, harness: HarnessId, value: bool) { + match harness { + HarnessId::ClaudeCode => self.claude_code = value, + HarnessId::Codex => self.codex = value, + } + } + + fn all_detected(self) -> bool { + self.claude_code && self.codex + } + + /// Grow-only merge: `self ||= probed`, per-field. Never flips a `true` + /// back to `false`. Returns whether anything NEW became detected. + fn merge(&mut self, probed: Detection) -> bool { + let mut grew = false; + for harness in HarnessId::ALL { + if !self.get(harness) && probed.get(harness) { + self.set(harness, true); + grew = true; + } + } + grew + } +} + +static CACHE: OnceLock> = OnceLock::new(); +static REFRESH_STARTED: OnceLock<()> = OnceLock::new(); + +fn cache_lock() -> &'static RwLock { + CACHE.get_or_init(|| RwLock::new(Detection::default())) +} + +/// Read the cache without probing. `Detection::default()` (nothing +/// detected) if nothing has probed yet. +pub async fn cached() -> Detection { + *cache_lock().read().await +} + +/// Ensures the cache has been populated at least once, paying for a +/// bounded probe pass on the FIRST call (cold start) and starting the +/// background re-probe loop as a side effect. Every call after the first +/// reads the warm cache instantly — a request never blocks on a probe it +/// didn't cause. Concurrent cold-start callers all pay for their own probe +/// (idempotent — the grow-only merge makes redundant probing harmless, +/// just slightly wasteful), same tradeoff the Phase 1 `routes/models.rs` +/// bootstrap already made. +pub async fn ensure_detected() -> Detection { + let current = cached().await; + if current.all_detected() { + start_background_reprobe(); + return current; + } + let probed = probe_missing(current).await; + let merged = { + let mut guard = cache_lock().write().await; + guard.merge(probed); + *guard + }; + start_background_reprobe(); + merged +} + +/// Starts the periodic re-probe loop exactly once per process. A no-op +/// once every harness is detected (matches `capabilities.ts`'s "probing +/// stops being useful once every CLI capability is present" comment) — +/// each tick still fires, but `probe_missing` skips a harness already +/// `true`, so a fully-detected process settles into a near-free timer. +fn start_background_reprobe() { + if REFRESH_STARTED.set(()).is_err() { + return; + } + tokio::spawn(async move { + loop { + tokio::time::sleep(REFRESH_INTERVAL).await; + let current = cached().await; + if current.all_detected() { + continue; + } + let probed = probe_missing(current).await; + let mut guard = cache_lock().write().await; + guard.merge(probed); + } + }); +} + +/// Probes only the harnesses NOT already `true` in `current` — matches +/// `capabilities.ts::missingCliCapabilities` gating re-probes to what's +/// actually missing, so a confirmed-detected CLI never pays for another +/// probe subprocess. +async fn probe_missing(current: Detection) -> Detection { + let (claude_code, codex) = tokio::join!( + async { + if current.claude_code { + true + } else { + probe(HarnessId::ClaudeCode).await + } + }, + async { + if current.codex { + true + } else { + probe(HarnessId::Codex).await + } + }, + ); + Detection { claude_code, codex } +} + +async fn probe(harness: HarnessId) -> bool { + // Fail-CLOSED: detected == installed AND logged in. Short-circuit the + // auth probe when `--version` already failed (nothing to be logged in + // to). + if !version_ok(harness).await { + return false; + } + if !auth_ok(harness).await { + tracing::debug!( + harness = harness.wire_id(), + "CLI answered --version but its auth-status probe did not confirm a logged-in session \ + (not logged in, or the auth surface is unrecognized/unsupported) — NOT counted as \ + detected (fail-closed: an installed-but-not-logged-in CLI is not usable)" + ); + return false; + } + true +} + +async fn version_ok(harness: HarnessId) -> bool { + let Ok(argv) = resolve::resolve_argv(harness) else { + return false; + }; + run_probe_capture(&argv, &["--version"], PROBE_TIMEOUT) + .await + .is_some() +} + +async fn auth_ok(harness: HarnessId) -> bool { + let Ok(argv) = resolve::resolve_argv(harness) else { + return false; + }; + match harness { + HarnessId::ClaudeCode => { + let Some(captured) = + run_probe_capture(&argv, &["auth", "status", "--json"], PROBE_TIMEOUT).await + else { + return false; + }; + claude_auth_status_indicates_logged_in(&captured) + } + HarnessId::Codex => { + let Some(captured) = + run_probe_capture(&argv, &["login", "status"], PROBE_TIMEOUT).await + else { + return false; + }; + codex_login_status_indicates_logged_in(&captured) + } + } +} + +/// Parses `claude auth status --json`'s output. Pure (no subprocess) so it +/// is unit-tested directly against captured output rather than requiring +/// a real login/logout cycle in CI. `text` is the MERGED stdout+stderr the +/// probe captured, so rather than parsing the whole capture (which fails +/// the moment any stderr noise precedes/follows the JSON), extract the +/// JSON object — first `{` through last `}` — and parse `loggedIn` from +/// that substring. A clean stdout-only JSON still parses (the whole string +/// is the object). +fn claude_auth_status_indicates_logged_in(text: &str) -> bool { + let (Some(start), Some(end)) = (text.find('{'), text.rfind('}')) else { + return false; + }; + if end < start { + return false; + } + serde_json::from_str::(&text[start..=end]) + .ok() + .and_then(|v| v.get("loggedIn").and_then(serde_json::Value::as_bool)) + .unwrap_or(false) +} + +/// Parses `codex login status`'s plain-text output ("Logged in using +/// ChatGPT" / "Logged in using an API key - ..." vs "Not logged in" — the +/// codex CLI has no `--json` flag for this subcommand, and prints this line +/// to STDERR). Pure for the same reason as the claude parser above. `text` +/// is the MERGED stdout+stderr, so check whether ANY line/segment is +/// "Logged in" (capital L) — true for "Logged in using ChatGPT" even when +/// it arrives via stderr after (empty) stdout, false for "Not logged in". +fn codex_login_status_indicates_logged_in(text: &str) -> bool { + text.lines() + .any(|line| line.trim_start().starts_with("Logged in")) +} + +/// Spawn ` ` with stdin closed (never let a probe hang reading +/// stdin — see `run.rs`'s module doc for why every harness spawn does +/// this), capture stdout AND stderr merged (stdout first, then stderr), +/// bounded by `timeout`. The auth probe needs the merge because codex +/// `login status` prints "Logged in ..." to stderr with an empty stdout; +/// merging is harmless for the `--version` probe (which only checks +/// success). `None` on spawn failure, non-success exit, or timeout; +/// non-UTF8-safe-decode issues are tolerated via lossy conversion, and the +/// child is best-effort killed on timeout so it doesn't leak as a zombie. +async fn run_probe_capture(argv: &[String], args: &[&str], timeout: Duration) -> Option { + let (program, rest) = argv.split_first()?; + let mut cmd = tokio::process::Command::new(program); + cmd.args(rest); + cmd.args(args); + cmd.stdin(std::process::Stdio::null()); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + let mut child = cmd.spawn().ok()?; + let mut stdout = child.stdout.take()?; + let mut stderr = child.stderr.take()?; + + let read_and_wait = async { + use tokio::io::AsyncReadExt; + let mut out = Vec::new(); + let mut err = Vec::new(); + // Drain BOTH pipes CONCURRENTLY: a child that fills its stderr + // pipe buffer while we're blocked reading stdout to EOF (or the + // reverse) would deadlock if we drained them sequentially. + let _ = tokio::join!(stdout.read_to_end(&mut out), stderr.read_to_end(&mut err)); + let status = child.wait().await; + (out, err, status) + }; + + match tokio::time::timeout(timeout, read_and_wait).await { + Ok((mut out, err, Ok(status))) if status.success() => { + out.extend_from_slice(&err); + Some(String::from_utf8_lossy(&out).into_owned()) + } + Ok(_) => None, + Err(_) => { + // Timed out — best-effort reap so we don't leak a zombie. The + // owning `Command`/`Child` was moved into `read_and_wait`'s + // future, which was dropped by the timeout; nothing left to + // kill from here on most platforms (drop already sends + // SIGKILL for a tokio::process::Child with kill_on_drop, the + // default). Nothing further to do. + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detection_merge_is_grow_only() { + let mut current = Detection { + claude_code: true, + codex: false, + }; + let probed_a_failure = Detection { + claude_code: false, + codex: false, + }; + let grew = current.merge(probed_a_failure); + assert!(!grew); + assert!( + current.claude_code, + "a transient probe failure must never un-list a previously-detected CLI" + ); + } + + #[test] + fn detection_merge_reports_growth_when_something_new_is_detected() { + let mut current = Detection::default(); + let probed = Detection { + claude_code: true, + codex: false, + }; + let grew = current.merge(probed); + assert!(grew); + assert!(current.claude_code); + assert!(!current.codex); + } + + #[test] + fn detection_merge_of_fully_detected_is_a_no_op() { + let mut current = Detection { + claude_code: true, + codex: true, + }; + let grew = current.merge(Detection { + claude_code: true, + codex: true, + }); + assert!(!grew); + assert!(current.all_detected()); + } + + #[test] + fn claude_auth_status_parses_logged_in_true() { + let sample = r#"{"loggedIn":true,"authMethod":"claude.ai","email":"a@b.com"}"#; + assert!(claude_auth_status_indicates_logged_in(sample)); + } + + #[test] + fn claude_auth_status_parses_logged_in_false() { + let sample = r#"{"loggedIn":false}"#; + assert!(!claude_auth_status_indicates_logged_in(sample)); + } + + #[test] + fn claude_auth_status_treats_garbage_as_not_logged_in() { + assert!(!claude_auth_status_indicates_logged_in("not json at all")); + assert!(!claude_auth_status_indicates_logged_in("")); + } + + #[test] + fn claude_auth_status_parses_json_embedded_in_merged_stderr_noise() { + // Merged stdout+stderr: a stderr warning line before the JSON must + // not break parsing (the whole-capture `from_str` would have). + let merged = "warning: config deprecated\n{\"loggedIn\":true,\"email\":\"a@b.com\"}\n"; + assert!(claude_auth_status_indicates_logged_in(merged)); + let merged_false = "some noise\n{\"loggedIn\":false}\ntrailing noise\n"; + assert!(!claude_auth_status_indicates_logged_in(merged_false)); + } + + #[test] + fn codex_login_status_recognizes_logged_in_variants() { + assert!(codex_login_status_indicates_logged_in( + "Logged in using ChatGPT" + )); + assert!(codex_login_status_indicates_logged_in( + "Logged in using an API key - sk-...\n" + )); + assert!(codex_login_status_indicates_logged_in( + " Logged in using ChatGPT" + )); + } + + #[test] + fn codex_login_status_recognizes_logged_in_delivered_via_stderr() { + // codex prints "Logged in ..." to STDERR with an empty stdout; the + // probe merges stdout (empty) + stderr, so the parser sees the line + // after a leading empty-stdout segment (and possibly other stderr + // lines). This is the regression this whole change fixes. + let merged = "\nLogged in using ChatGPT\n"; + assert!(codex_login_status_indicates_logged_in(merged)); + let merged_with_noise = "codex-cli 1.2.3\nLogged in using ChatGPT\n"; + assert!(codex_login_status_indicates_logged_in(merged_with_noise)); + } + + #[test] + fn codex_login_status_recognizes_not_logged_in() { + assert!(!codex_login_status_indicates_logged_in("Not logged in")); + assert!(!codex_login_status_indicates_logged_in("")); + } +} diff --git a/apps/native/crates/harness/src/events/claude.rs b/apps/native/crates/harness/src/events/claude.rs new file mode 100644 index 0000000000..69d3e79a50 --- /dev/null +++ b/apps/native/crates/harness/src/events/claude.rs @@ -0,0 +1,1125 @@ +//! `claude -p --output-format stream-json --include-partial-messages --verbose` ndjson → +//! `UIMessageChunk` mapping. +//! +//! Claude emits each partial content block twice: incremental +//! `stream_event.event.content_block_*` frames, then a complete +//! `assistant.message.content[]` snapshot immediately before that block's +//! `content_block_stop`. The mapper streams the partial frames and uses +//! the snapshot only to append a missing suffix or finalize tool input. +//! It retains the complete-frame path as a fallback for older CLIs and +//! the deterministic stub, which emit no partial frames. +//! This ordering was re-verified live with Claude Code 2.1.218 on +//! 2026-07-23; `fixtures/claude_partial.jsonl` is a trimmed deterministic +//! representation of that capture. +//! +//! Event mapping: +//! - `system.init` → [`crate::events::MappedEvent::SessionId`] (captured, +//! no chunk) + one `{"type":"start"}` chunk. +//! - `stream_event.content_block_*` → incremental text, reasoning, and +//! tool-input chunks with stable per-block ids. +//! - `assistant.message.content[]` → suffix/finalization for a matching +//! partial block, or a complete one-shot block when no partial exists. +//! - `user.message.content[]` (`tool_result` items) → +//! `tool-output-available` / `tool-output-error`. +//! - `result`, `is_error:true` → `MappedEvent::FatalError` (message from +//! `result.result` if present, else the first `result.errors[]` entry). +//! - `result`, `is_error:false` → one `finish` chunk carrying +//! usage/cost/session metadata. +//! - unknown events and unparseable lines → ignored. + +use std::collections::BTreeMap; + +use serde_json::{json, Value}; + +use super::{chunk, EventMapper, FlushReason, MappedEvent}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PartialBlockKind { + Text, + Reasoning, + Tool, +} + +#[derive(Debug, Default)] +struct BlockStatus { + stopped: bool, + snapshot_seen: bool, + finalized: bool, +} + +#[derive(Debug)] +enum PartialBlock { + Text { + id: String, + emitted: String, + status: BlockStatus, + }, + Reasoning { + id: String, + emitted: String, + status: BlockStatus, + }, + Tool { + tool_call_id: String, + tool_name: String, + input_text: String, + final_input: Option, + status: BlockStatus, + }, +} + +impl PartialBlock { + fn kind(&self) -> PartialBlockKind { + match self { + Self::Text { .. } => PartialBlockKind::Text, + Self::Reasoning { .. } => PartialBlockKind::Reasoning, + Self::Tool { .. } => PartialBlockKind::Tool, + } + } + + fn status(&self) -> &BlockStatus { + match self { + Self::Text { status, .. } + | Self::Reasoning { status, .. } + | Self::Tool { status, .. } => status, + } + } + + fn status_mut(&mut self) -> &mut BlockStatus { + match self { + Self::Text { status, .. } + | Self::Reasoning { status, .. } + | Self::Tool { status, .. } => status, + } + } + + fn matches_snapshot(&self, block: &Value) -> bool { + match self { + Self::Text { emitted, .. } => { + let Some(text) = block.get("text").and_then(Value::as_str) else { + return false; + }; + block.get("type").and_then(Value::as_str) == Some("text") + && (text.starts_with(emitted) || emitted.starts_with(text)) + } + Self::Reasoning { emitted, .. } => { + let Some(thinking) = block.get("thinking").and_then(Value::as_str) else { + return false; + }; + block.get("type").and_then(Value::as_str) == Some("thinking") + && (thinking.starts_with(emitted) || emitted.starts_with(thinking)) + } + Self::Tool { + tool_call_id, + tool_name, + .. + } => { + if block.get("type").and_then(Value::as_str) != Some("tool_use") { + return false; + } + let snapshot_id = block.get("id").and_then(Value::as_str).unwrap_or(""); + let snapshot_name = block.get("name").and_then(Value::as_str).unwrap_or(""); + (!snapshot_id.is_empty() && snapshot_id == tool_call_id) + || (snapshot_id.is_empty() + && !snapshot_name.is_empty() + && snapshot_name == tool_name) + } + } + } + + fn apply_delta(&mut self, delta: &Value) -> Vec { + match self { + Self::Text { id, emitted, .. } + if delta.get("type").and_then(Value::as_str) == Some("text_delta") => + { + let text = delta.get("text").and_then(Value::as_str).unwrap_or(""); + emitted.push_str(text); + if text.is_empty() { + vec![] + } else { + vec![chunk( + json!({"type": "text-delta", "id": id, "delta": text}), + )] + } + } + Self::Reasoning { id, emitted, .. } + if delta.get("type").and_then(Value::as_str) == Some("thinking_delta") => + { + let thinking = delta.get("thinking").and_then(Value::as_str).unwrap_or(""); + emitted.push_str(thinking); + if thinking.is_empty() { + vec![] + } else { + vec![chunk( + json!({"type": "reasoning-delta", "id": id, "delta": thinking}), + )] + } + } + Self::Tool { + tool_call_id, + input_text, + .. + } if delta.get("type").and_then(Value::as_str) == Some("input_json_delta") => { + let partial = delta + .get("partial_json") + .and_then(Value::as_str) + .unwrap_or(""); + input_text.push_str(partial); + if partial.is_empty() { + vec![] + } else { + vec![chunk(json!({ + "type": "tool-input-delta", + "toolCallId": tool_call_id, + "inputTextDelta": partial, + }))] + } + } + _ => vec![], + } + } + + fn apply_snapshot(&mut self, block: &Value) -> Vec { + let mut out = Vec::new(); + match self { + Self::Text { + id, + emitted, + status, + } => { + let snapshot = block.get("text").and_then(Value::as_str).unwrap_or(""); + append_missing_suffix(&mut out, "text-delta", id, emitted, snapshot); + status.snapshot_seen = true; + } + Self::Reasoning { + id, + emitted, + status, + } => { + let snapshot = block.get("thinking").and_then(Value::as_str).unwrap_or(""); + append_missing_suffix(&mut out, "reasoning-delta", id, emitted, snapshot); + status.snapshot_seen = true; + } + Self::Tool { + tool_call_id, + input_text, + final_input, + status, + .. + } => { + let input = block.get("input").cloned().unwrap_or_else(|| json!({})); + let snapshot_text = serde_json::to_string(&input).unwrap_or_default(); + if let Some(suffix) = snapshot_text.strip_prefix(input_text.as_str()) { + if !suffix.is_empty() { + input_text.push_str(suffix); + out.push(chunk(json!({ + "type": "tool-input-delta", + "toolCallId": tool_call_id, + "inputTextDelta": suffix, + }))); + } + } + *final_input = Some(input); + status.snapshot_seen = true; + } + } + out + } + + fn mark_stopped(&mut self) { + self.status_mut().stopped = true; + } + + fn should_finalize(&self) -> bool { + let status = self.status(); + status.stopped && status.snapshot_seen + } + + fn finalize(&mut self) -> Vec { + if self.status().finalized { + return vec![]; + } + self.status_mut().finalized = true; + match self { + Self::Text { id, .. } => vec![chunk(json!({"type": "text-end", "id": id}))], + Self::Reasoning { id, .. } => { + vec![chunk(json!({"type": "reasoning-end", "id": id}))] + } + Self::Tool { + tool_call_id, + tool_name, + input_text, + final_input, + .. + } => { + if let Some(input) = final_input.clone() { + vec![chunk(json!({ + "type": "tool-input-available", + "toolCallId": tool_call_id, + "toolName": tool_name, + "input": input, + }))] + } else { + let input = serde_json::from_str(input_text) + .unwrap_or_else(|_| Value::String(input_text.clone())); + vec![chunk(json!({ + "type": "tool-input-error", + "toolCallId": tool_call_id, + "toolName": tool_name, + "input": input, + "errorText": "Claude's stream ended before the tool input was complete", + }))] + } + } + } + } +} + +fn append_missing_suffix( + out: &mut Vec, + chunk_type: &str, + id: &str, + emitted: &mut String, + snapshot: &str, +) { + let Some(suffix) = snapshot.strip_prefix(emitted.as_str()) else { + return; + }; + if suffix.is_empty() { + return; + } + emitted.push_str(suffix); + out.push(chunk(json!({ + "type": chunk_type, + "id": id, + "delta": suffix, + }))); +} + +#[derive(Debug, Default)] +pub struct ClaudeEventMapper { + session_id: Option, + started: bool, + next_id: u64, + partial_blocks: BTreeMap, + current_block_index: Option, +} + +impl ClaudeEventMapper { + pub fn new() -> Self { + Self::default() + } + + fn next_part_id(&mut self) -> String { + let id = self.next_id; + self.next_id += 1; + id.to_string() + } + + fn handle_top_level(&mut self, value: &Value) -> Vec { + match value.get("type").and_then(Value::as_str) { + Some("system") => self.handle_system(value), + Some("assistant") => self.handle_assistant(value), + Some("user") => self.handle_user(value), + Some("result") => self.handle_result(value), + Some("stream_event") => self.handle_stream_event(value), + _ => vec![], + } + } + + fn handle_system(&mut self, value: &Value) -> Vec { + if value.get("subtype").and_then(Value::as_str) != Some("init") { + return vec![]; + } + let mut out = Vec::new(); + if !self.started { + self.started = true; + out.push(chunk(json!({"type": "start"}))); + } + if let Some(sid) = value.get("session_id").and_then(Value::as_str) { + self.session_id = Some(sid.to_string()); + out.push(MappedEvent::SessionId(sid.to_string())); + } + out + } + + fn handle_assistant(&mut self, value: &Value) -> Vec { + let Some(content) = value.pointer("/message/content").and_then(Value::as_array) else { + return vec![]; + }; + let mut out = Vec::new(); + for block in content { + let Some(index) = self.matching_partial_index(block) else { + out.extend(self.emit_complete_block(block)); + continue; + }; + let Some(partial) = self.partial_blocks.get_mut(&index) else { + continue; + }; + out.extend(partial.apply_snapshot(block)); + if partial.should_finalize() { + out.extend(partial.finalize()); + } + } + out + } + + fn emit_complete_block(&mut self, block: &Value) -> Vec { + match block.get("type").and_then(Value::as_str) { + Some("text") => { + let text = block.get("text").and_then(Value::as_str).unwrap_or(""); + let id = self.next_part_id(); + vec![ + chunk(json!({"type": "text-start", "id": id})), + chunk(json!({"type": "text-delta", "id": id, "delta": text})), + chunk(json!({"type": "text-end", "id": id})), + ] + } + Some("thinking") => { + let text = block.get("thinking").and_then(Value::as_str).unwrap_or(""); + let id = self.next_part_id(); + vec![ + chunk(json!({"type": "reasoning-start", "id": id})), + chunk(json!({"type": "reasoning-delta", "id": id, "delta": text})), + chunk(json!({"type": "reasoning-end", "id": id})), + ] + } + Some("tool_use") => { + let tool_call_id = block + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let tool_name = block + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let input = block.get("input").cloned().unwrap_or_else(|| json!({})); + let input_text = serde_json::to_string(&input).unwrap_or_default(); + vec![ + chunk(json!({ + "type": "tool-input-start", + "toolCallId": tool_call_id, + "toolName": tool_name, + })), + chunk(json!({ + "type": "tool-input-delta", + "toolCallId": tool_call_id, + "inputTextDelta": input_text, + })), + chunk(json!({ + "type": "tool-input-available", + "toolCallId": tool_call_id, + "toolName": tool_name, + "input": input, + })), + ] + } + // `redacted_thinking`/any future block type: dropped rather + // than guessed at. + _ => vec![], + } + } + + fn matching_partial_index(&self, block: &Value) -> Option { + let kind = match block.get("type").and_then(Value::as_str) { + Some("text") => PartialBlockKind::Text, + Some("thinking") => PartialBlockKind::Reasoning, + Some("tool_use") => PartialBlockKind::Tool, + _ => return None, + }; + + if let Some(index) = self.current_block_index { + if self + .partial_blocks + .get(&index) + .is_some_and(|partial| partial.kind() == kind && partial.matches_snapshot(block)) + { + return Some(index); + } + } + + if let Some((index, _)) = self + .partial_blocks + .iter() + .rev() + .find(|(_, partial)| partial.kind() == kind && partial.matches_snapshot(block)) + { + return Some(*index); + } + + let mut unmatched = self + .partial_blocks + .iter() + .filter(|(_, partial)| partial.kind() == kind && !partial.status().snapshot_seen); + let (index, _) = unmatched.next()?; + if unmatched.next().is_none() { + Some(*index) + } else { + None + } + } + + fn handle_stream_event(&mut self, value: &Value) -> Vec { + let Some(event) = value.get("event") else { + return vec![]; + }; + match event.get("type").and_then(Value::as_str) { + Some("message_start") => self.finish_partial_message(), + Some("content_block_start") => self.handle_content_block_start(event), + Some("content_block_delta") => self.handle_content_block_delta(event), + Some("content_block_stop") => self.handle_content_block_stop(event), + Some("message_stop") => self.finish_partial_message(), + _ => vec![], + } + } + + fn handle_content_block_start(&mut self, event: &Value) -> Vec { + let Some(index) = event.get("index").and_then(Value::as_u64) else { + return vec![]; + }; + let Some(block) = event.get("content_block") else { + return vec![]; + }; + + let mut out = Vec::new(); + if let Some(mut prior) = self.partial_blocks.remove(&index) { + out.extend(prior.finalize()); + } + + let partial = match block.get("type").and_then(Value::as_str) { + Some("text") => { + let id = self.next_part_id(); + let initial = block + .get("text") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + out.push(chunk(json!({"type": "text-start", "id": id}))); + if !initial.is_empty() { + out.push(chunk( + json!({"type": "text-delta", "id": id, "delta": initial}), + )); + } + PartialBlock::Text { + id, + emitted: initial, + status: BlockStatus::default(), + } + } + Some("thinking") => { + let id = self.next_part_id(); + let initial = block + .get("thinking") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + out.push(chunk(json!({"type": "reasoning-start", "id": id}))); + if !initial.is_empty() { + out.push(chunk( + json!({"type": "reasoning-delta", "id": id, "delta": initial}), + )); + } + PartialBlock::Reasoning { + id, + emitted: initial, + status: BlockStatus::default(), + } + } + Some("tool_use") => { + let tool_call_id = block + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let tool_name = block + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + out.push(chunk(json!({ + "type": "tool-input-start", + "toolCallId": tool_call_id, + "toolName": tool_name, + }))); + PartialBlock::Tool { + tool_call_id, + tool_name, + input_text: String::new(), + final_input: None, + status: BlockStatus::default(), + } + } + _ => return out, + }; + + self.partial_blocks.insert(index, partial); + self.current_block_index = Some(index); + out + } + + fn handle_content_block_delta(&mut self, event: &Value) -> Vec { + let Some(index) = event.get("index").and_then(Value::as_u64) else { + return vec![]; + }; + let Some(delta) = event.get("delta") else { + return vec![]; + }; + self.partial_blocks + .get_mut(&index) + .map_or_else(Vec::new, |partial| partial.apply_delta(delta)) + } + + fn handle_content_block_stop(&mut self, event: &Value) -> Vec { + let Some(index) = event.get("index").and_then(Value::as_u64) else { + return vec![]; + }; + if self.current_block_index == Some(index) { + self.current_block_index = None; + } + let Some(partial) = self.partial_blocks.get_mut(&index) else { + return vec![]; + }; + partial.mark_stopped(); + if partial.should_finalize() { + partial.finalize() + } else { + vec![] + } + } + + fn finish_partial_message(&mut self) -> Vec { + let mut out = Vec::new(); + for partial in self.partial_blocks.values_mut() { + out.extend(partial.finalize()); + } + self.partial_blocks.clear(); + self.current_block_index = None; + out + } + + fn handle_user(&mut self, value: &Value) -> Vec { + let mut out = self.finish_partial_message(); + let Some(content) = value.pointer("/message/content").and_then(Value::as_array) else { + return out; + }; + out.extend( + content + .iter() + .filter_map(|item| { + if item.get("type").and_then(Value::as_str) != Some("tool_result") { + return None; + } + let tool_call_id = item.get("tool_use_id").and_then(Value::as_str)?.to_string(); + let is_error = item + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false); + let content_val = item.get("content").cloned().unwrap_or(Value::Null); + Some(if is_error { + chunk(json!({ + "type": "tool-output-error", + "toolCallId": tool_call_id, + "errorText": stringify_tool_content(&content_val), + })) + } else { + chunk(json!({ + "type": "tool-output-available", + "toolCallId": tool_call_id, + "output": content_val, + })) + }) + }) + .collect::>(), + ); + out + } + + fn handle_result(&mut self, value: &Value) -> Vec { + let mut out = self.finish_partial_message(); + // Error results can be the only frame carrying the session id (for + // example, an early resume failure with no preceding init). Capture it + // before branching on `is_error` so the durable assistant result keeps + // the resume anchor even though this turn itself failed. + if let Some(sid) = value + .get("session_id") + .and_then(Value::as_str) + .filter(|sid| !sid.trim().is_empty()) + { + self.session_id = Some(sid.to_string()); + out.push(MappedEvent::SessionId(sid.to_string())); + } + let is_error = value + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false); + if is_error { + let message = value + .get("result") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .or_else(|| { + // The stub's `fail` scenario (and, per the module doc, + // possibly the real CLI) carries `errors: [string]` + // instead of a `result` string on an error frame. + value + .get("errors") + .and_then(Value::as_array) + .and_then(|arr| arr.first()) + .and_then(Value::as_str) + .map(str::to_string) + }) + .or_else(|| { + value + .get("subtype") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| "claude CLI reported an error".to_string()); + out.push(MappedEvent::FatalError { message }); + return out; + } + + let usage = value.get("usage").cloned().unwrap_or(Value::Null); + let total_cost_usd = value.get("total_cost_usd").cloned().unwrap_or(Value::Null); + out.push(chunk(json!({ + "type": "finish", + "finishReason": "stop", + "messageMetadata": { + "usage": usage, + "totalCostUsd": total_cost_usd, + "codingAgentProvider": "claude-code", + "codingAgentSessionId": self.session_id, + }, + }))); + out + } +} + +/// Best-effort text extraction from a `tool_result` content value for the +/// `tool-output-error` chunk's `errorText` (a plain string per the AI +/// SDK's `UIMessageChunk` shape) — the CLI's `content` field itself may be +/// a string OR an array of content blocks. +fn stringify_tool_content(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Array(items) => items + .iter() + .filter_map(|item| item.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"), + other => other.to_string(), + } +} + +impl EventMapper for ClaudeEventMapper { + fn feed_line(&mut self, line: &str) -> Vec { + let trimmed = line.trim(); + if trimmed.is_empty() { + return vec![]; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + // A stray non-JSON line (banners, warnings) must not abort an + // otherwise-healthy run. + return vec![]; + }; + self.handle_top_level(&value) + } + + fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } + + fn flush(&mut self, _reason: FlushReason) -> Vec { + self.finish_partial_message() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SIMPLE_FIXTURE: &str = include_str!("fixtures/claude_simple.jsonl"); + const ERROR_FIXTURE: &str = include_str!("fixtures/claude_error.jsonl"); + const PARTIAL_FIXTURE: &str = include_str!("fixtures/claude_partial.jsonl"); + const TOOL_USE_FIXTURE: &str = include_str!("fixtures/claude_tool_use.jsonl"); + + fn feed_all(mapper: &mut ClaudeEventMapper, fixture: &str) -> Vec { + fixture + .lines() + .flat_map(|line| mapper.feed_line(line)) + .collect() + } + + #[test] + fn simple_fixture_yields_start_session_text_and_finish() { + let mut mapper = ClaudeEventMapper::new(); + let events = feed_all(&mut mapper, SIMPLE_FIXTURE); + + assert_eq!(events.first(), Some(&chunk(json!({"type": "start"})))); + assert!(events + .iter() + .any(|e| matches!(e, MappedEvent::SessionId(_)))); + assert!(mapper.session_id().is_some()); + + let text_deltas: Vec<&str> = events + .iter() + .filter_map(|e| match e { + MappedEvent::Chunk(v) if v["type"] == "text-delta" => v["delta"].as_str(), + _ => None, + }) + .collect(); + assert_eq!(text_deltas.join(""), "pong"); + + let finish = events + .iter() + .find(|e| matches!(e, MappedEvent::Chunk(v) if v["type"] == "finish")) + .expect("a finish chunk"); + let MappedEvent::Chunk(finish) = finish else { + unreachable!() + }; + assert_eq!(finish["finishReason"], "stop"); + assert_eq!( + finish["messageMetadata"]["codingAgentProvider"], + "claude-code" + ); + + assert!(!events + .iter() + .any(|e| matches!(e, MappedEvent::FatalError { .. }))); + } + + #[test] + fn start_chunk_is_emitted_exactly_once() { + let mut mapper = ClaudeEventMapper::new(); + let events = feed_all(&mut mapper, SIMPLE_FIXTURE); + let starts = events + .iter() + .filter(|e| matches!(e, MappedEvent::Chunk(v) if v["type"] == "start")) + .count(); + assert_eq!(starts, 1); + } + + #[test] + fn tool_use_fixture_yields_tool_input_and_output_chunks() { + let mut mapper = ClaudeEventMapper::new(); + let events = feed_all(&mut mapper, TOOL_USE_FIXTURE); + + let available = events + .iter() + .find(|e| matches!(e, MappedEvent::Chunk(v) if v["type"] == "tool-input-available")) + .expect("a tool-input-available chunk"); + let MappedEvent::Chunk(available) = available else { + unreachable!() + }; + assert_eq!(available["toolCallId"], "toolu_01ABC"); + assert_eq!(available["toolName"], "Bash"); + assert_eq!(available["input"]["command"], "echo hi"); + + let output = events + .iter() + .find(|e| matches!(e, MappedEvent::Chunk(v) if v["type"] == "tool-output-available")) + .expect("a tool-output-available chunk"); + let MappedEvent::Chunk(output) = output else { + unreachable!() + }; + assert_eq!(output["toolCallId"], "toolu_01ABC"); + } + + #[test] + fn result_is_error_true_with_result_field_yields_a_fatal_error() { + let mut mapper = ClaudeEventMapper::new(); + let events = feed_all(&mut mapper, ERROR_FIXTURE); + assert_eq!( + events.last(), + Some(&MappedEvent::FatalError { + message: "API Error: 529 overloaded".to_string() + }) + ); + } + + #[test] + fn result_is_error_true_with_errors_array_falls_back_to_the_first_entry() { + let mut mapper = ClaudeEventMapper::new(); + let events = mapper.feed_line( + r#"{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["stub-harness: induced failure"]}"#, + ); + assert_eq!( + events, + vec![MappedEvent::FatalError { + message: "stub-harness: induced failure".to_string() + }] + ); + } + + #[test] + fn error_result_captures_session_before_reporting_the_failure() { + let mut mapper = ClaudeEventMapper::new(); + let events = mapper.feed_line( + r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"failed","session_id":"error-session"}"#, + ); + assert_eq!( + events, + vec![ + MappedEvent::SessionId("error-session".to_string()), + MappedEvent::FatalError { + message: "failed".to_string() + } + ] + ); + assert_eq!(mapper.session_id(), Some("error-session")); + } + + #[test] + fn malformed_json_line_is_ignored_not_fatal() { + let mut mapper = ClaudeEventMapper::new(); + let events = mapper.feed_line("Reading additional input from stdin..."); + assert_eq!(events, vec![]); + } + + #[test] + fn blank_line_is_ignored() { + let mut mapper = ClaudeEventMapper::new(); + assert_eq!(mapper.feed_line(""), vec![]); + assert_eq!(mapper.feed_line(" "), vec![]); + } + + #[test] + fn unknown_top_level_type_is_ignored() { + let mut mapper = ClaudeEventMapper::new(); + let events = mapper.feed_line(r#"{"type":"rate_limit_event","rate_limit_info":{}}"#); + assert_eq!(events, vec![]); + } + + #[test] + fn partial_text_is_streamed_and_the_identical_snapshot_is_deduplicated() { + let mut mapper = ClaudeEventMapper::new(); + let fixture = [ + r#"{"type":"stream_event","event":{"type":"message_start","message":{"content":[]}}}"#, + r#"{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}"#, + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}}"#, + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"pong"}]}}"#, + r#"{"type":"stream_event","event":{"type":"content_block_stop","index":0}}"#, + r#"{"type":"stream_event","event":{"type":"message_stop"}}"#, + ] + .join("\n"); + let events = feed_all(&mut mapper, &fixture); + + let text_deltas: Vec<&str> = events + .iter() + .filter_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == "text-delta" => { + value["delta"].as_str() + } + _ => None, + }) + .collect(); + assert_eq!(text_deltas, vec!["pong"]); + assert_eq!( + events + .iter() + .filter(|event| matches!( + event, + MappedEvent::Chunk(value) if value["type"] == "text-start" + )) + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|event| matches!( + event, + MappedEvent::Chunk(value) if value["type"] == "text-end" + )) + .count(), + 1 + ); + } + + #[test] + fn eof_closes_a_partial_text_block_exactly_once() { + let mut mapper = ClaudeEventMapper::new(); + let events = mapper.feed_line( + r#"{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":"hel"}}}"#, + ); + assert!(events.iter().any( + |event| matches!(event, MappedEvent::Chunk(value) if value["type"] == "text-start") + )); + + let flushed = mapper.flush(FlushReason::EndOfStream); + assert_eq!(flushed, vec![chunk(json!({"type": "text-end", "id": "0"}))]); + assert!(mapper.flush(FlushReason::EndOfStream).is_empty()); + } + + #[test] + fn cancellation_closes_a_partial_reasoning_block() { + let mut mapper = ClaudeEventMapper::new(); + mapper.feed_line( + r#"{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}}"#, + ); + mapper.feed_line( + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"still thinking"}}}"#, + ); + + assert_eq!( + mapper.flush(FlushReason::Cancelled), + vec![chunk(json!({"type": "reasoning-end", "id": "0"}))] + ); + } + + #[test] + fn failed_stream_turns_partial_tool_input_into_tool_input_error() { + let mut mapper = ClaudeEventMapper::new(); + mapper.feed_line( + r#"{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_cut","name":"Bash","input":{}}}}"#, + ); + mapper.feed_line( + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"command\":"}}}"#, + ); + + let flushed = mapper.flush(FlushReason::Failed); + assert_eq!( + flushed, + vec![chunk(json!({ + "type": "tool-input-error", + "toolCallId": "toolu_cut", + "toolName": "Bash", + "input": "{\"command\":", + "errorText": "Claude's stream ended before the tool input was complete", + }))] + ); + assert!(!flushed.iter().any( + |event| matches!(event, MappedEvent::Chunk(value) if value["type"] == "tool-input-available") + )); + } + + #[test] + fn partial_fixture_appends_snapshot_suffixes_and_finalizes_reasoning_and_tools() { + let mut mapper = ClaudeEventMapper::new(); + let events = feed_all(&mut mapper, PARTIAL_FIXTURE); + + let text = events + .iter() + .filter_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == "text-delta" => { + value["delta"].as_str() + } + _ => None, + }) + .collect::(); + assert_eq!(text, "hello"); + + let reasoning = events + .iter() + .filter_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == "reasoning-delta" => { + value["delta"].as_str() + } + _ => None, + }) + .collect::(); + assert_eq!(reasoning, "Checking the task"); + + let tool_input_text = events + .iter() + .filter_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == "tool-input-delta" => { + value["inputTextDelta"].as_str() + } + _ => None, + }) + .collect::(); + assert_eq!(tool_input_text, r#"{"command":"echo hi"}"#); + + let tool_available: Vec<&Value> = events + .iter() + .filter_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == "tool-input-available" => Some(value), + _ => None, + }) + .collect(); + assert_eq!(tool_available.len(), 1); + assert_eq!(tool_available[0]["toolCallId"], "toolu_partial"); + assert_eq!(tool_available[0]["input"]["command"], "echo hi"); + assert!(events + .iter() + .filter_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == "tool-input-start" => { + value["toolCallId"].as_str() + } + _ => None, + }) + .all(|id| id == "toolu_partial")); + + for (start, end) in [ + ("text-start", "text-end"), + ("reasoning-start", "reasoning-end"), + ] { + assert_eq!( + events + .iter() + .filter(|event| matches!( + event, + MappedEvent::Chunk(value) if value["type"] == start + )) + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|event| matches!( + event, + MappedEvent::Chunk(value) if value["type"] == end + )) + .count(), + 1 + ); + let start_id = events.iter().find_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == start => value["id"].as_str(), + _ => None, + }); + let end_id = events.iter().find_map(|event| match event { + MappedEvent::Chunk(value) if value["type"] == end => value["id"].as_str(), + _ => None, + }); + assert_eq!(start_id, end_id); + } + } + + #[test] + fn multiple_assistant_frames_each_contribute_their_own_text_block() { + // Mirrors stub-harness.mjs's SCENARIO:simple: 3 separate + // `assistant` frames, each with ONE new text block — not an + // accumulating snapshot. + let mut mapper = ClaudeEventMapper::new(); + mapper.feed_line(r#"{"type":"system","subtype":"init","session_id":"s1"}"#); + let e1 = mapper.feed_line( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Hello"}]}}"#, + ); + let e2 = mapper.feed_line( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":" from"}]}}"#, + ); + let e3 = mapper.feed_line( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":" stub"}]}}"#, + ); + let all: Vec = [e1, e2, e3].concat(); + let deltas: Vec<&str> = all + .iter() + .filter_map(|e| match e { + MappedEvent::Chunk(v) if v["type"] == "text-delta" => v["delta"].as_str(), + _ => None, + }) + .collect(); + assert_eq!(deltas, vec!["Hello", " from", " stub"]); + // Each block gets its OWN part id (start/end pair), not shared. + let starts = all + .iter() + .filter(|e| matches!(e, MappedEvent::Chunk(v) if v["type"] == "text-start")) + .count(); + assert_eq!(starts, 3); + } +} diff --git a/apps/native/crates/harness/src/events/codex.rs b/apps/native/crates/harness/src/events/codex.rs new file mode 100644 index 0000000000..7c5f97c712 --- /dev/null +++ b/apps/native/crates/harness/src/events/codex.rs @@ -0,0 +1,425 @@ +//! `codex exec --json --skip-git-repo-check ` ndjson → +//! `UIMessageChunk` mapping. +//! +//! Wire shapes for `thread.started`/`turn.started`/`item.completed` +//! (`agent_message` item)/`turn.completed` were captured live on this +//! machine (codex-cli 0.144.5, 2026-07-18) — see +//! `events/fixtures/codex_exec.jsonl` (real capture, verbatim). Codex's +//! `--json` protocol also documents `item.started`/`item.updated` (for +//! items that stream incrementally) and richer item types +//! (`reasoning`, `command_execution`, `file_change`, `mcp_tool_call`, +//! `web_search`, `todo_list`) plus `turn.failed`/top-level `error` — none +//! of those were exercised by the trivial "reply with one word" smoke +//! prompt (a real capture would need to spend real API credits driving an +//! actual tool call, which this crate doesn't need to do twice to build a +//! reasonable mapping). `events/fixtures/codex_tool_use.jsonl` and +//! `events/fixtures/codex_error.jsonl` are HAND-CONSTRUCTED to the +//! documented item/turn shapes; unrecognized item types fall through a +//! generic best-effort mapping (see `handle_item` below) rather than being +//! silently dropped, so a future item type this mapper hasn't been taught +//! about yet still surfaces SOMETHING instead of vanishing. +//! +//! Unlike claude's Anthropic-Messages-API deltas, codex's `--json` items +//! arrive essentially as a WHOLE (`item.completed`) rather than as +//! incremental token deltas — so each `agent_message`/`reasoning` item +//! synthesizes a start+delta(full text)+end triplet in one line rather +//! than genuinely streaming word-by-word. `item.started`/`item.updated` +//! ARE handled (for forward-compat with a codex version that does stream +//! them): the mapper tracks each item's last-seen text and emits only the +//! GROWN suffix as the delta, so a future incrementally-updated item +//! degrades gracefully into real deltas instead of repeating the whole +//! text on every update. +//! +//! Event mapping: +//! - `thread.started` → [`crate::events::MappedEvent::SessionId`] +//! (`thread_id` is codex's resume token — `codex exec resume +//! `). +//! - `turn.started` → one `{"type":"start"}` chunk. +//! - `item.started` / `item.updated` / `item.completed` → see +//! [`handle_item`]. +//! - `turn.completed` → one `finish` chunk carrying usage metadata. +//! - `turn.failed` / top-level `error` → `MappedEvent::FatalError`. +//! - anything else / an unparseable line → `Ignored`. + +use std::collections::HashMap; + +use serde_json::{json, Value}; + +use super::{chunk, EventMapper, MappedEvent}; + +#[derive(Debug, Default)] +pub struct CodexEventMapper { + /// Per-item last-seen text, keyed by item id — lets `item.updated` + /// (if a future codex version streams it) emit only the grown suffix. + item_text: HashMap, + /// Item ids whose start chunk has already been emitted (so a repeated + /// `item.updated` for the same id doesn't re-open it). + item_started: std::collections::HashSet, + session_id: Option, + turn_started: bool, +} + +impl CodexEventMapper { + pub fn new() -> Self { + Self::default() + } + + fn handle_top_level(&mut self, value: &Value) -> Vec { + match value.get("type").and_then(Value::as_str) { + Some("thread.started") => self.handle_thread_started(value), + Some("turn.started") => { + if self.turn_started { + return vec![]; + } + self.turn_started = true; + vec![chunk(json!({"type": "start"}))] + } + Some("item.started") | Some("item.updated") => value + .get("item") + .map(|item| self.handle_item(item, false)) + .unwrap_or_default(), + Some("item.completed") => value + .get("item") + .map(|item| self.handle_item(item, true)) + .unwrap_or_default(), + Some("turn.completed") => Self::handle_turn_completed(value), + Some("turn.failed") => Self::handle_turn_failed(value), + Some("error") => Some(MappedEvent::FatalError { + message: value + .get("message") + .and_then(Value::as_str) + .unwrap_or("codex CLI reported an error") + .to_string(), + }) + .into_iter() + .collect(), + _ => vec![], + } + } + + fn handle_thread_started(&mut self, value: &Value) -> Vec { + let Some(id) = value.get("thread_id").and_then(Value::as_str) else { + return vec![]; + }; + self.session_id = Some(id.to_string()); + vec![MappedEvent::SessionId(id.to_string())] + } + + /// Maps ONE item (from `item.started`/`item.updated`/`item.completed`) + /// to zero or more chunks. `agent_message`/`reasoning` map to + /// text/reasoning start+delta(+end on completion); every other item + /// type (`command_execution`, `file_change`, `mcp_tool_call`, + /// `web_search`, `todo_list`, and anything not yet known) maps to a + /// generic tool-call shape: `toolName` is the item's own `type`, + /// `input` is the item object minus `id`/`type`/`status` (the + /// "control" fields), `output` populated from the item's own status + /// once it's `completed`. + fn handle_item(&mut self, item: &Value, is_completed_event: bool) -> Vec { + let Some(id) = item.get("id").and_then(Value::as_str) else { + return vec![]; + }; + let item_type = item.get("type").and_then(Value::as_str).unwrap_or(""); + // "Completed" if either the wrapping event says so (the + // `item.completed` variant) OR the item carries its own terminal + // `status` field — defensive coverage for whichever convention a + // given codex version actually uses; the real capture this + // mapper was built against carries no `status` field at all on + // its `item.completed` item, so `is_completed_event` alone must + // be sufficient. + let completed = + is_completed_event || item.get("status").and_then(Value::as_str) == Some("completed"); + + match item_type { + "agent_message" => self.handle_text_like_item(id, item, "text", completed), + "reasoning" => self.handle_text_like_item(id, item, "reasoning", completed), + // Generic (tool-shaped) items only surface once complete — an + // `item.started`/`item.updated` for e.g. a `command_execution` + // rarely carries a full `input`/`output` yet, and emitting the + // same object twice (once on started, again on completed) + // would duplicate the chunk. + other if completed => self.handle_generic_tool_item(id, other, item), + _ => vec![], + } + } + + /// `agent_message`/`reasoning` items: emit a `-start` the first time + /// this item id is seen, a `-delta` for whatever text GREW since the + /// last update (the whole text on first sight), and a `-end` only once + /// the item reaches a terminal status (or unconditionally for + /// `item.completed`, since that event has no further updates coming). + fn handle_text_like_item( + &mut self, + id: &str, + item: &Value, + kind: &str, + completed: bool, + ) -> Vec { + let text = item + .get("text") + .or_else(|| item.get("summary")) + .and_then(Value::as_str) + .unwrap_or_default(); + let mut out = Vec::new(); + + let first_sight = self.item_started.insert(id.to_string()); + if first_sight { + out.push(chunk(json!({"type": format!("{kind}-start"), "id": id}))); + } + + let previous = self.item_text.get(id).cloned().unwrap_or_default(); + if let Some(grown) = text.strip_prefix(&previous) { + if !grown.is_empty() { + out.push(chunk( + json!({"type": format!("{kind}-delta"), "id": id, "delta": grown}), + )); + } + } else if text != previous { + // Text didn't grow as a simple suffix (a rewrite, not an + // append) — emit the new text wholesale rather than a + // misleading diff. + out.push(chunk( + json!({"type": format!("{kind}-delta"), "id": id, "delta": text}), + )); + } + self.item_text.insert(id.to_string(), text.to_string()); + + if completed { + out.push(chunk(json!({"type": format!("{kind}-end"), "id": id}))); + } + out + } + + /// Any item type that isn't `agent_message`/`reasoning` — a generic, + /// best-effort tool-call mapping so a not-yet-specifically-handled + /// item (or a genuinely new item type a future codex version adds) + /// still surfaces as SOMETHING instead of silently vanishing (see + /// module doc). + fn handle_generic_tool_item( + &self, + id: &str, + tool_name: &str, + item: &Value, + ) -> Vec { + let mut input = item.clone(); + if let Value::Object(map) = &mut input { + map.remove("id"); + map.remove("type"); + } + vec![chunk(json!({ + "type": "tool-input-available", + "toolCallId": id, + "toolName": tool_name, + "input": input, + }))] + } + + fn handle_turn_completed(value: &Value) -> Vec { + let usage = value.get("usage").cloned().unwrap_or(Value::Null); + vec![chunk(json!({ + "type": "finish", + "finishReason": "stop", + "messageMetadata": { + "usage": usage, + "codingAgentProvider": "codex", + }, + }))] + } + + fn handle_turn_failed(value: &Value) -> Vec { + let message = value + .pointer("/error/message") + .and_then(Value::as_str) + .unwrap_or("codex CLI reported a turn failure") + .to_string(); + vec![MappedEvent::FatalError { message }] + } +} + +impl EventMapper for CodexEventMapper { + fn feed_line(&mut self, line: &str) -> Vec { + let trimmed = line.trim(); + if trimmed.is_empty() { + return vec![]; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + // Real captured behavior: codex prints a human + // "Reading additional input from stdin..." banner to stdout + // BEFORE its JSON stream when stdin isn't closed — see + // `run.rs`'s module doc for why every harness spawn closes + // stdin, and this fallback for defense in depth regardless. + return vec![]; + }; + self.handle_top_level(&value) + } + + fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const EXEC_FIXTURE: &str = include_str!("fixtures/codex_exec.jsonl"); + const TOOL_USE_FIXTURE: &str = include_str!("fixtures/codex_tool_use.jsonl"); + const ERROR_FIXTURE: &str = include_str!("fixtures/codex_error.jsonl"); + + fn feed_all(mapper: &mut CodexEventMapper, fixture: &str) -> Vec { + fixture + .lines() + .flat_map(|line| mapper.feed_line(line)) + .collect() + } + + #[test] + fn exec_fixture_yields_session_start_text_and_finish() { + let mut mapper = CodexEventMapper::new(); + let events = feed_all(&mut mapper, EXEC_FIXTURE); + + assert_eq!( + events.first(), + Some(&MappedEvent::SessionId( + "019f7325-3e34-7b30-863a-861396b02def".to_string() + )) + ); + assert_eq!( + mapper.session_id(), + Some("019f7325-3e34-7b30-863a-861396b02def") + ); + + assert!(events.contains(&chunk(json!({"type": "start"})))); + assert!(events.contains(&chunk(json!({"type": "text-start", "id": "item_0"})))); + assert!(events.contains(&chunk( + json!({"type": "text-delta", "id": "item_0", "delta": "pong"}) + ))); + assert!(events.contains(&chunk(json!({"type": "text-end", "id": "item_0"})))); + + let finish = events + .iter() + .find(|e| matches!(e, MappedEvent::Chunk(v) if v["type"] == "finish")) + .expect("a finish chunk"); + let MappedEvent::Chunk(finish) = finish else { + unreachable!() + }; + assert_eq!(finish["messageMetadata"]["codingAgentProvider"], "codex"); + assert_eq!(finish["messageMetadata"]["usage"]["output_tokens"], 106); + + assert!(!events + .iter() + .any(|e| matches!(e, MappedEvent::FatalError { .. }))); + } + + #[test] + fn agent_message_start_is_emitted_only_once() { + let mut mapper = CodexEventMapper::new(); + let events = feed_all(&mut mapper, EXEC_FIXTURE); + let starts = events + .iter() + .filter(|e| matches!(e, MappedEvent::Chunk(v) if v["type"] == "text-start")) + .count(); + assert_eq!(starts, 1); + } + + #[test] + fn tool_use_fixture_maps_reasoning_and_generic_command_execution() { + let mut mapper = CodexEventMapper::new(); + let events = feed_all(&mut mapper, TOOL_USE_FIXTURE); + + assert!(events.contains(&chunk(json!({"type": "reasoning-start", "id": "item_0"})))); + assert!(events.contains(&chunk( + json!({"type": "reasoning-delta", "id": "item_0", "delta": "Figuring out what to run."}) + ))); + assert!(events.contains(&chunk(json!({"type": "reasoning-end", "id": "item_0"})))); + + let tool_call = events + .iter() + .find(|e| matches!(e, MappedEvent::Chunk(v) if v["toolCallId"] == "item_1")) + .expect("a generic tool-input-available chunk for command_execution"); + let MappedEvent::Chunk(tool_call) = tool_call else { + unreachable!() + }; + assert_eq!(tool_call["type"], "tool-input-available"); + assert_eq!(tool_call["toolName"], "command_execution"); + assert_eq!(tool_call["input"]["command"], "echo hi"); + assert_eq!(tool_call["input"]["exit_code"], 0); + // Control fields id/type are stripped from `input`. + assert!(tool_call["input"].get("id").is_none()); + assert!(tool_call["input"].get("type").is_none()); + } + + #[test] + fn turn_failed_yields_a_fatal_error_with_the_reported_message() { + let mut mapper = CodexEventMapper::new(); + let events = feed_all(&mut mapper, ERROR_FIXTURE); + assert_eq!( + events.last(), + Some(&MappedEvent::FatalError { + message: "stream disconnected before completion".to_string() + }) + ); + } + + #[test] + fn top_level_error_type_is_also_fatal() { + let mut mapper = CodexEventMapper::new(); + let events = mapper.feed_line(r#"{"type":"error","message":"boom"}"#); + assert_eq!( + events, + vec![MappedEvent::FatalError { + message: "boom".to_string() + }] + ); + } + + #[test] + fn malformed_json_line_is_ignored_not_fatal() { + let mut mapper = CodexEventMapper::new(); + let events = mapper.feed_line("Reading additional input from stdin..."); + assert_eq!(events, vec![]); + } + + #[test] + fn incremental_item_updated_emits_only_the_grown_suffix() { + let mut mapper = CodexEventMapper::new(); + mapper.feed_line(r#"{"type":"thread.started","thread_id":"t1"}"#); + mapper.feed_line(r#"{"type":"turn.started"}"#); + let first = mapper.feed_line( + r#"{"type":"item.updated","item":{"id":"item_0","type":"agent_message","text":"pon","status":"in_progress"}}"#, + ); + assert!(first.contains(&chunk(json!({"type":"text-start","id":"item_0"})))); + assert!(first.contains(&chunk( + json!({"type":"text-delta","id":"item_0","delta":"pon"}) + ))); + + let second = mapper.feed_line( + r#"{"type":"item.updated","item":{"id":"item_0","type":"agent_message","text":"pong","status":"in_progress"}}"#, + ); + // Only the grown suffix "g", no repeated start. + assert_eq!( + second, + vec![chunk( + json!({"type":"text-delta","id":"item_0","delta":"g"}) + )] + ); + + let third = mapper.feed_line( + r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"pong"}}"#, + ); + assert_eq!(third, vec![chunk(json!({"type":"text-end","id":"item_0"}))]); + } + + #[test] + fn unknown_item_type_still_surfaces_generically() { + let mut mapper = CodexEventMapper::new(); + let events = mapper.feed_line( + r#"{"type":"item.completed","item":{"id":"item_9","type":"some_future_item","payload":42}}"#, + ); + let MappedEvent::Chunk(c) = &events[0] else { + panic!("expected a chunk") + }; + assert_eq!(c["type"], "tool-input-available"); + assert_eq!(c["toolName"], "some_future_item"); + assert_eq!(c["input"]["payload"], 42); + } +} diff --git a/apps/native/crates/harness/src/events/fixtures/claude_error.jsonl b/apps/native/crates/harness/src/events/fixtures/claude_error.jsonl new file mode 100644 index 0000000000..0f2fd588d3 --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/claude_error.jsonl @@ -0,0 +1,2 @@ +{"type":"system","subtype":"init","cwd":"/private/tmp","session_id":"error-session","tools":["Bash"],"model":"claude-fable-5"} +{"type":"result","subtype":"error_during_execution","is_error":true,"result":"API Error: 529 overloaded","session_id":"error-session"} diff --git a/apps/native/crates/harness/src/events/fixtures/claude_partial.jsonl b/apps/native/crates/harness/src/events/fixtures/claude_partial.jsonl new file mode 100644 index 0000000000..6ca1cc00cf --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/claude_partial.jsonl @@ -0,0 +1,17 @@ +{"type":"system","subtype":"init","session_id":"partial-session"} +{"type":"stream_event","event":{"type":"message_start","message":{"content":[]}},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Checking"}},"session_id":"partial-session"} +{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"Checking the task","signature":"fixture"}]},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_stop","index":0},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_partial","name":"Bash","input":{}}},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"command\":"}},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"echo hi\"}"}},"session_id":"partial-session"} +{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_partial","name":"Bash","input":{"command":"echo hi"}}]},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_stop","index":1},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_start","index":2,"content_block":{"type":"text","text":""}},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_delta","index":2,"delta":{"type":"text_delta","text":"hel"}},"session_id":"partial-session"} +{"type":"assistant","message":{"content":[{"type":"text","text":"hello"}]},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"content_block_stop","index":2},"session_id":"partial-session"} +{"type":"stream_event","event":{"type":"message_stop"},"session_id":"partial-session"} +{"type":"result","subtype":"success","is_error":false,"result":"hello","session_id":"partial-session","usage":{"input_tokens":1,"output_tokens":2}} diff --git a/apps/native/crates/harness/src/events/fixtures/claude_simple.jsonl b/apps/native/crates/harness/src/events/fixtures/claude_simple.jsonl new file mode 100644 index 0000000000..03aa323835 --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/claude_simple.jsonl @@ -0,0 +1,5 @@ +{"type":"system","subtype":"init","cwd":"/private/tmp","session_id":"770886ee-0c27-444d-b6b1-85fa370466e7","tools":["Bash","Read","Write"],"model":"claude-fable-5","permissionMode":"default","apiKeySource":"none","claude_code_version":"2.1.214"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1784359800,"rateLimitType":"five_hour"},"uuid":"013f1974-40e0-4fa7-9cfe-60b3a46043e4","session_id":"770886ee-0c27-444d-b6b1-85fa370466e7"} +{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011Cd8mPPW3FXy5S3H8FmUHF","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"","signature":"CAISiQIKiAEIDxgCKkD0"}],"stop_reason":null,"usage":{"input_tokens":2,"output_tokens":2}},"parent_tool_use_id":null,"session_id":"770886ee-0c27-444d-b6b1-85fa370466e7","uuid":"7c58a441-0e8c-48c4-b132-f7253cfe701a","timestamp":"2026-07-18T02:52:44.744Z"} +{"type":"assistant","message":{"model":"claude-fable-5","id":"msg_011Cd8mPPW3FXy5S3H8FmUHF","type":"message","role":"assistant","content":[{"type":"text","text":"pong"}],"stop_reason":null,"usage":{"input_tokens":2,"output_tokens":17}},"parent_tool_use_id":null,"session_id":"770886ee-0c27-444d-b6b1-85fa370466e7","uuid":"5bc95ffd-c667-4c74-90aa-7bdabae3c79c","timestamp":"2026-07-18T02:52:44.753Z"} +{"type":"result","subtype":"success","is_error":false,"duration_ms":2862,"num_turns":1,"result":"pong","stop_reason":"end_turn","session_id":"770886ee-0c27-444d-b6b1-85fa370466e7","total_cost_usd":0.170644,"usage":{"input_tokens":2,"output_tokens":17},"permission_denials":[],"terminal_reason":"completed"} diff --git a/apps/native/crates/harness/src/events/fixtures/claude_tool_use.jsonl b/apps/native/crates/harness/src/events/fixtures/claude_tool_use.jsonl new file mode 100644 index 0000000000..402074bd60 --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/claude_tool_use.jsonl @@ -0,0 +1,5 @@ +{"type":"system","subtype":"init","cwd":"/private/tmp","session_id":"tool-use-session","tools":["Bash"],"model":"claude-fable-5"} +{"type":"assistant","message":{"id":"msg_tool1","role":"assistant","content":[{"type":"tool_use","id":"toolu_01ABC","name":"Bash","input":{"command":"echo hi"}}],"usage":{"input_tokens":1,"output_tokens":1}},"session_id":"tool-use-session"} +{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01ABC","content":[{"type":"text","text":"hi\n"}],"is_error":false}]},"session_id":"tool-use-session"} +{"type":"assistant","message":{"id":"msg_tool1","role":"assistant","content":[{"type":"text","text":"Ran `echo hi`. Output: hi"}],"usage":{"input_tokens":1,"output_tokens":10}},"session_id":"tool-use-session"} +{"type":"result","subtype":"success","is_error":false,"result":"Ran `echo hi`. Output: hi","session_id":"tool-use-session","total_cost_usd":0.01,"usage":{"input_tokens":1,"output_tokens":10}} diff --git a/apps/native/crates/harness/src/events/fixtures/codex_error.jsonl b/apps/native/crates/harness/src/events/fixtures/codex_error.jsonl new file mode 100644 index 0000000000..df8732e1ba --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/codex_error.jsonl @@ -0,0 +1,3 @@ +{"type":"thread.started","thread_id":"error-thread"} +{"type":"turn.started"} +{"type":"turn.failed","error":{"message":"stream disconnected before completion"}} diff --git a/apps/native/crates/harness/src/events/fixtures/codex_exec.jsonl b/apps/native/crates/harness/src/events/fixtures/codex_exec.jsonl new file mode 100644 index 0000000000..705cdaae2f --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/codex_exec.jsonl @@ -0,0 +1,4 @@ +{"type":"thread.started","thread_id":"019f7325-3e34-7b30-863a-861396b02def"} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"pong"}} +{"type":"turn.completed","usage":{"input_tokens":10522,"cached_input_tokens":5120,"output_tokens":106,"reasoning_output_tokens":99}} diff --git a/apps/native/crates/harness/src/events/fixtures/codex_tool_and_error.jsonl b/apps/native/crates/harness/src/events/fixtures/codex_tool_and_error.jsonl new file mode 100644 index 0000000000..f12a40eacd --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/codex_tool_and_error.jsonl @@ -0,0 +1,9 @@ +{"type":"thread.started","thread_id":"tool-use-thread"} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Figuring out what to run."}} +{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"echo hi","aggregated_output":"hi\n","exit_code":0,"status":"completed"}} +{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"hi"}} +{"type":"turn.completed","usage":{"input_tokens":5,"output_tokens":5}} +{"type":"thread.started","thread_id":"error-thread"} +{"type":"turn.started"} +{"type":"turn.failed","error":{"message":"stream disconnected before completion"}} diff --git a/apps/native/crates/harness/src/events/fixtures/codex_tool_use.jsonl b/apps/native/crates/harness/src/events/fixtures/codex_tool_use.jsonl new file mode 100644 index 0000000000..998122ea86 --- /dev/null +++ b/apps/native/crates/harness/src/events/fixtures/codex_tool_use.jsonl @@ -0,0 +1,6 @@ +{"type":"thread.started","thread_id":"tool-use-thread"} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"Figuring out what to run."}} +{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"echo hi","aggregated_output":"hi\n","exit_code":0,"status":"completed"}} +{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"hi"}} +{"type":"turn.completed","usage":{"input_tokens":5,"output_tokens":5}} diff --git a/apps/native/crates/harness/src/events/mod.rs b/apps/native/crates/harness/src/events/mod.rs new file mode 100644 index 0000000000..fd6702566d --- /dev/null +++ b/apps/native/crates/harness/src/events/mod.rs @@ -0,0 +1,86 @@ +//! ndjson → `UIMessageChunk`-shaped-JSON mapping, one submodule per CLI's +//! wire format. See `crate` module doc's "Unknown-event policy" for what +//! happens to a line neither mapper recognizes. +//! +//! Both mappers are STATEFUL (`content_block_start`/`_delta`/`_stop` for +//! claude, `item.started`/`item.completed` for codex all need to remember +//! what's "open" across lines) but PURE otherwise — `feed_line` takes a +//! `&str`, returns `Vec`, does no I/O. That's what makes them +//! unit-testable by feeding captured fixture lines (`events/fixtures/*`) +//! without spawning any process; `run.rs` is the only place that wires a +//! mapper to a real child process's stdout. + +pub mod claude; +pub mod codex; + +use serde_json::Value; + +/// One decoded outcome from a single ndjson line of harness output. +#[derive(Debug, Clone, PartialEq)] +pub enum MappedEvent { + /// → dispatch's `{"type":"ui-message-chunk","chunk":}`. + Chunk(Value), + /// The harness itself reported a terminal fatal error (Claude's + /// `result.is_error`, Codex's `turn.failed`/top-level `error`) — NOT a + /// JSON-parse failure on our side. Maps to dispatch's + /// `{"type":"error",...}` immediately followed by `done`; the run + /// stops reading further lines once this is returned. + FatalError { message: String }, + /// A session/thread id became known — the resume token for the NEXT + /// turn (`input.harness.sessionId` on a future dispatch). Not itself a + /// chunk; `run.rs` threads it into the terminal `finish` chunk's + /// metadata and (in local-api) the persisted `Run` row. + SessionId(String), + /// Nothing worth surfacing — see the crate doc's unknown-event policy. + Ignored, +} + +/// Builds a `MappedEvent::Chunk` from an already-shaped `UIMessageChunk` +/// JSON value. Trivial, but centralizes the "one chunk in, one event out" +/// idiom both mappers use dozens of times. +pub(crate) fn chunk(value: Value) -> MappedEvent { + MappedEvent::Chunk(value) +} + +/// Why the subprocess-backed event stream is being closed. +/// +/// Mappers use this only to close protocol parts that were opened by an +/// incremental event but never received their normal terminator. The reason is +/// deliberately coarse: callers must not infer process policy from a mapper; +/// it exists so a malformed/truncated tool input can be surfaced as an error +/// instead of being promoted to an available tool call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlushReason { + EndOfStream, + Cancelled, + Failed, +} + +/// Feeds one line at a time (as a real spawned process's stdout would +/// arrive, split on `\n`) into a mapper. Implemented by both +/// [`claude::ClaudeEventMapper`] and [`codex::CodexEventMapper`]. +pub trait EventMapper { + /// `line` has its trailing newline already stripped. A line that + /// isn't valid JSON (has been observed in the wild — see + /// `codex_error.jsonl`'s sibling fixture's real capture printing a + /// human `"Reading additional input from stdin..."` banner before its + /// first JSON line when stdin isn't closed) is treated as + /// `MappedEvent::Ignored`, never a fatal error — a stray banner line + /// must not abort an otherwise-healthy run. + fn feed_line(&mut self, line: &str) -> Vec; + + /// The session/thread id captured so far, if any — convenience + /// accessor so `run.rs` can read it without re-deriving it from every + /// `MappedEvent::SessionId` it already forwarded. + fn session_id(&self) -> Option<&str>; + + /// Closes any incremental UI-message parts still open when the + /// subprocess stream ends. Called for clean EOF, cancellation, and every + /// fatal/nonzero path. Most mappers have nothing to flush; Claude's + /// partial-message protocol needs this to pair every `*-start` with an + /// `*-end`, and to turn a truncated tool input into + /// `tool-input-error`. + fn flush(&mut self, _reason: FlushReason) -> Vec { + Vec::new() + } +} diff --git a/apps/native/crates/harness/src/lib.rs b/apps/native/crates/harness/src/lib.rs new file mode 100644 index 0000000000..d8805af78b --- /dev/null +++ b/apps/native/crates/harness/src/lib.rs @@ -0,0 +1,57 @@ +//! `harness` — CLI detection, model tiers, process spawn, and ndjson→SSE +//! stream translation for the two harness CLIs desktop v1 supports +//! (`claude`, `codex` — see the desktop migration contract decision +//! 1: no Decopilot in the desktop app). Consumed by +//! `crates/local-api`'s `routes/dispatch.rs` (real streaming) and +//! `routes/models.rs` (`GET /models` detection + tiers). +//! +//! ## Module map +//! +//! - [`resolve`] — binary resolution. Implements the SHARED CONTRACT env +//! overrides (`LOCAL_API_CLAUDE_BIN` / `LOCAL_API_CODEX_BIN`, absolute +//! path or argv JSON array) with a PATH fallback to `claude`/`codex`. +//! - [`tiers`] — the model-tier tables ported verbatim (same ids/labels) +//! from `packages/harness/src/{claude-code,codex}/model/agent-tiers.ts`, +//! plus the composite-id → CLI `--model` flag resolvers ported from +//! `packages/harness/src/{claude-code,codex}/model/index.ts`. +//! - [`detect`] — CLI availability probing with GROW-ONLY caching, mirroring +//! `apps/api/src/link-daemon/capabilities.ts`'s semantics: a transient +//! probe failure never un-lists a CLI that was previously detected. +//! - [`spawn`] — the one process-spawn abstraction (PTY via `portable-pty` +//! OR plain pipes), 128+signal exit-code convention, process-group kill. +//! See that module's doc comment for why harness dispatch defaults to +//! plain pipes (investigated + documented, not just asserted). +//! - [`events`] — incremental ndjson→`UIMessageChunk`-shaped-JSON mapping +//! for each CLI's wire format (`events::claude`, `events::codex`). +//! - [`run`] — ties the above together: resolve → spawn → parse → yield, +//! with cancellation support. The type `local-api`'s dispatch route +//! actually drives. +//! - [`parts`] — reconstructs a UIMessage-shaped `parts` array from a +//! run's chunk stream, for persisting the assistant `Message` row (see +//! that module's doc for the resume-token convention it introduces). +//! +//! ## Unknown-event policy +//! +//! the native local-API contract's Dispatch Lifecycle section +//! pins exactly three SSE event shapes for `POST /_sandbox/dispatch`: +//! `ui-message-chunk`, `error`, and `done` — it defines no "escape hatch" +//! wire shape for an event this crate doesn't recognize. So: every ndjson +//! line this crate can't confidently map to one of those three lands as +//! [`events::MappedEvent::Ignored`] and is silently dropped, never +//! forwarded raw. Forwarding an unrecognized shape under the +//! `ui-message-chunk` envelope would violate its "opaque `UIMessageChunk`" +//! contract (the desktop frontend, Phase 3, expects genuine AI-SDK-shaped +//! chunks) — dropping is the conservative reading of "follow the contract +//! doc exactly" when the doc simply doesn't define that hatch. +#![forbid(unsafe_code)] + +pub mod detect; +pub mod events; +pub mod parts; +pub mod resolve; +pub mod run; +pub mod spawn; +pub mod tiers; + +pub use resolve::{resolve_argv, resolve_checked, HarnessId, ResolveError}; +pub use tiers::{ModelTier, CLAUDE_CODE_TIERS, CODEX_TIERS}; diff --git a/apps/native/crates/harness/src/parts.rs b/apps/native/crates/harness/src/parts.rs new file mode 100644 index 0000000000..ef3d7436c3 --- /dev/null +++ b/apps/native/crates/harness/src/parts.rs @@ -0,0 +1,453 @@ +//! Reconstructs a UIMessage-shaped `parts` array — the same storage shape +//! the native local-API contract's Threads-store section pins +//! for `Message.parts` (`unknown[]`, "opaque to local-api, ... the same +//! 'UIMessage parts' shape the mesh chat wire already uses") — from the +//! sequence of `UIMessageChunk`-shaped JSON values a [`crate::run::RunHandle`] +//! yields as [`crate::run::RunEvent::Chunk`]. Pure/stateful, no I/O: +//! `local-api`'s dispatch route feeds it every chunk it relays over SSE +//! and persists the final [`PartsAccumulator::finish`] array as the +//! assistant `Message` row once the run ends. +//! +//! Not itself part of the pinned contract (that doc only pins the STORAGE +//! shape and explicitly treats `parts`' contents as opaque) — the exact +//! part shapes below (`text`, `reasoning`, `tool-`) are this crate's +//! own reasonable-effort UIMessage-part reconstruction, introduced in +//! Phase 2 as the bridge between dispatch's ephemeral SSE stream and the +//! durable thread store. No test in either e2e suite pins this exact +//! shape (dispatch's real-harness streaming persistence has no e2e +//! coverage yet — see the Phase 2 report); this module's own unit tests +//! are the correctness gate for now. +//! +//! ## Resume token convention +//! +//! The contract's `Run` entity has no field for the harness CLI's own +//! session/thread id (needed to `--resume`/`resume ` on the NEXT +//! turn) — a Phase 0/1 schema gap; widening the pinned `Run`/`Message` +//! shape is a contract-doc change, out of this Phase 2 module's scope. +//! Instead, when a session id is known, [`PartsAccumulator::finish`] +//! appends ONE final pseudo-part: +//! `{"type":"data-harness-session","harnessId":...,"sessionId":...}` — +//! fits the EXISTING `parts: unknown[]` column with no schema change; a +//! future turn recovers it by scanning assistant messages newest-first for +//! the latest valid token matching the thread's pinned harness. Native chat +//! also checkpoints a newly observed token on its active SQLite queue claim, +//! so crash recovery can append this same part before retiring an interrupted +//! turn. + +use std::collections::HashMap; + +use serde_json::{json, Value}; + +#[derive(Debug, Clone)] +enum OpenPart { + Text(String), + Reasoning(String), + Tool { + tool_name: String, + input: Option, + raw_input: Option, + output: Option, + error_text: Option, + state: &'static str, + }, +} + +#[derive(Debug, Default)] +pub struct PartsAccumulator { + /// First-seen order of part/tool-call ids — preserves the sequence + /// the harness actually produced them in, independent of `HashMap`'s + /// unordered iteration. + order: Vec, + parts: HashMap, +} + +impl PartsAccumulator { + pub fn new() -> Self { + Self::default() + } + + /// Feed one `UIMessageChunk`-shaped chunk (a [`crate::run::RunEvent::Chunk`] + /// payload). Unrecognized `type`s (`start`, `finish`, `finish-step`, + /// any future chunk type this accumulator hasn't been taught about) + /// are ignored — they don't contribute a `parts` entry, matching how + /// AI SDK's own `UIMessage.parts` never includes lifecycle-only + /// chunks either. + pub fn feed(&mut self, chunk: &Value) { + match chunk.get("type").and_then(Value::as_str) { + Some("text-start") => self.open_text_like(chunk, false), + Some("text-delta") => self.append_text(chunk, false), + Some("reasoning-start") => self.open_text_like(chunk, true), + Some("reasoning-delta") => self.append_text(chunk, true), + Some("tool-input-available") => self.set_tool_input(chunk), + Some("tool-input-error") => self.set_tool_input_error(chunk), + Some("tool-output-available") => self.set_tool_output(chunk, false), + Some("tool-output-error") => self.set_tool_output(chunk, true), + _ => {} + } + } + + fn open_text_like(&mut self, chunk: &Value, reasoning: bool) { + let Some(id) = chunk.get("id").and_then(Value::as_str) else { + return; + }; + if !self.parts.contains_key(id) { + self.order.push(id.to_string()); + self.parts.insert( + id.to_string(), + if reasoning { + OpenPart::Reasoning(String::new()) + } else { + OpenPart::Text(String::new()) + }, + ); + } + } + + fn append_text(&mut self, chunk: &Value, reasoning: bool) { + let Some(id) = chunk.get("id").and_then(Value::as_str) else { + return; + }; + let delta = chunk.get("delta").and_then(Value::as_str).unwrap_or(""); + if !self.parts.contains_key(id) { + // A delta with no prior `-start` (shouldn't happen from + // either mapper, but never lose data) — synthesize the slot. + self.order.push(id.to_string()); + self.parts.insert( + id.to_string(), + if reasoning { + OpenPart::Reasoning(String::new()) + } else { + OpenPart::Text(String::new()) + }, + ); + } + match self.parts.get_mut(id) { + Some(OpenPart::Text(s)) if !reasoning => s.push_str(delta), + Some(OpenPart::Reasoning(s)) if reasoning => s.push_str(delta), + // A delta arrived for an id that's open as the OTHER kind + // (text vs reasoning) — shouldn't happen (ids are namespaced + // by content-block index per mapper), dropped defensively. + _ => {} + } + } + + fn set_tool_input(&mut self, chunk: &Value) { + let Some(id) = chunk.get("toolCallId").and_then(Value::as_str) else { + return; + }; + let tool_name = chunk + .get("toolName") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let input = chunk.get("input").cloned().unwrap_or(Value::Null); + if !self.parts.contains_key(id) { + self.order.push(id.to_string()); + } + self.parts.insert( + id.to_string(), + OpenPart::Tool { + tool_name, + input: Some(input), + raw_input: None, + output: None, + error_text: None, + state: "input-available", + }, + ); + } + + fn set_tool_input_error(&mut self, chunk: &Value) { + let Some(id) = chunk.get("toolCallId").and_then(Value::as_str) else { + return; + }; + let tool_name = chunk + .get("toolName") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let raw_input = chunk.get("input").cloned().unwrap_or(Value::Null); + let error_text = chunk + .get("errorText") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + if !self.parts.contains_key(id) { + self.order.push(id.to_string()); + } + self.parts.insert( + id.to_string(), + OpenPart::Tool { + tool_name, + // AI SDK v6 represents a static tool-input parse failure + // with no typed `input` and preserves the rejected value in + // `rawInput`. Keeping the same shape makes a reloaded message + // indistinguishable from its live streamed state. + input: None, + raw_input: Some(raw_input), + output: None, + error_text: Some(error_text), + state: "output-error", + }, + ); + } + + fn set_tool_output(&mut self, chunk: &Value, is_error: bool) { + let Some(id) = chunk.get("toolCallId").and_then(Value::as_str) else { + return; + }; + let Some(OpenPart::Tool { + output, + error_text, + state, + .. + }) = self.parts.get_mut(id) + else { + // An output for a tool call we never saw the input for — + // shouldn't happen (both mappers always emit input-available + // before any output), dropped defensively rather than + // panicking or fabricating a tool part with no `toolName`. + return; + }; + if is_error { + *error_text = chunk + .get("errorText") + .and_then(Value::as_str) + .map(str::to_string); + *state = "output-error"; + } else { + *output = chunk.get("output").cloned(); + *state = "output-available"; + } + } + + /// The final reconstructed parts array, in first-seen order. + /// `session`, when `Some((harness_id, session_id))`, appends the + /// resume-token pseudo-part described in the module doc. + pub fn finish(self, session: Option<(&str, &str)>) -> Vec { + let PartsAccumulator { order, parts } = self; + let mut out: Vec = order + .into_iter() + .filter_map(|id| parts.get(&id).map(|p| part_to_json(&id, p))) + .collect(); + if let Some((harness_id, session_id)) = session { + out.push(json!({ + "type": "data-harness-session", + "harnessId": harness_id, + "sessionId": session_id, + })); + } + out + } +} + +fn part_to_json(id: &str, part: &OpenPart) -> Value { + match part { + OpenPart::Text(s) => json!({"type": "text", "text": s}), + OpenPart::Reasoning(s) => json!({"type": "reasoning", "text": s}), + OpenPart::Tool { + tool_name, + input, + raw_input, + output, + error_text, + state, + } => { + let mut v = json!({ + "type": format!("tool-{tool_name}"), + "toolCallId": id, + "state": state, + }); + if let Some(input) = input { + v["input"] = input.clone(); + } + if let Some(raw_input) = raw_input { + v["rawInput"] = raw_input.clone(); + } + if let Some(o) = output { + v["output"] = o.clone(); + } + if let Some(e) = error_text { + v["errorText"] = json!(e); + } + v + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accumulates_a_text_part_across_deltas() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({"type": "text-start", "id": "0"})); + acc.feed(&json!({"type": "text-delta", "id": "0", "delta": "hel"})); + acc.feed(&json!({"type": "text-delta", "id": "0", "delta": "lo"})); + acc.feed(&json!({"type": "text-end", "id": "0"})); + let parts = acc.finish(None); + assert_eq!(parts, vec![json!({"type": "text", "text": "hello"})]); + } + + #[test] + fn accumulates_a_reasoning_part_separately_from_text() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({"type": "reasoning-start", "id": "0"})); + acc.feed(&json!({"type": "reasoning-delta", "id": "0", "delta": "thinking..."})); + acc.feed(&json!({"type": "text-start", "id": "1"})); + acc.feed(&json!({"type": "text-delta", "id": "1", "delta": "answer"})); + let parts = acc.finish(None); + assert_eq!( + parts, + vec![ + json!({"type": "reasoning", "text": "thinking..."}), + json!({"type": "text", "text": "answer"}), + ] + ); + } + + #[test] + fn tool_call_transitions_from_input_available_to_output_available() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({ + "type": "tool-input-available", + "toolCallId": "t1", + "toolName": "Bash", + "input": {"command": "echo hi"}, + })); + acc.feed(&json!({ + "type": "tool-output-available", + "toolCallId": "t1", + "output": "hi\n", + })); + let parts = acc.finish(None); + assert_eq!( + parts, + vec![json!({ + "type": "tool-Bash", + "toolCallId": "t1", + "state": "output-available", + "input": {"command": "echo hi"}, + "output": "hi\n", + })] + ); + } + + #[test] + fn tool_call_output_error_sets_error_state_and_text() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({ + "type": "tool-input-available", + "toolCallId": "t1", + "toolName": "Bash", + "input": {}, + })); + acc.feed(&json!({ + "type": "tool-output-error", + "toolCallId": "t1", + "errorText": "boom", + })); + let parts = acc.finish(None); + assert_eq!(parts[0]["state"], "output-error"); + assert_eq!(parts[0]["errorText"], "boom"); + } + + #[test] + fn tool_input_error_updates_an_existing_tool_to_a_terminal_error() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({ + "type": "tool-input-available", + "toolCallId": "t1", + "toolName": "Bash", + "input": {"command": "echo hi"}, + })); + acc.feed(&json!({ + "type": "tool-input-error", + "toolCallId": "t1", + "toolName": "Bash", + "input": "{\"command\":", + "errorText": "input was truncated", + })); + + assert_eq!( + acc.finish(None), + vec![json!({ + "type": "tool-Bash", + "toolCallId": "t1", + "state": "output-error", + "rawInput": "{\"command\":", + "errorText": "input was truncated", + })] + ); + } + + #[test] + fn direct_tool_input_error_creates_a_persistable_tool_part() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({ + "type": "tool-input-error", + "toolCallId": "t-direct", + "toolName": "Read", + "input": {"file_path": "/partial"}, + "errorText": "stream ended", + })); + + assert_eq!( + acc.finish(None), + vec![json!({ + "type": "tool-Read", + "toolCallId": "t-direct", + "state": "output-error", + "rawInput": {"file_path": "/partial"}, + "errorText": "stream ended", + })] + ); + } + + #[test] + fn output_for_an_unseen_tool_call_is_dropped_not_fabricated() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({"type": "tool-output-available", "toolCallId": "ghost", "output": 1})); + assert_eq!(acc.finish(None), Vec::::new()); + } + + #[test] + fn unknown_chunk_types_do_not_produce_a_part() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({"type": "start"})); + acc.feed(&json!({"type": "finish", "finishReason": "stop"})); + assert_eq!(acc.finish(None), Vec::::new()); + } + + #[test] + fn finish_appends_the_session_pseudo_part_when_present() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({"type": "text-start", "id": "0"})); + acc.feed(&json!({"type": "text-delta", "id": "0", "delta": "hi"})); + let parts = acc.finish(Some(("claude-code", "sess-1"))); + assert_eq!(parts.len(), 2); + assert_eq!( + parts[1], + json!({"type": "data-harness-session", "harnessId": "claude-code", "sessionId": "sess-1"}) + ); + } + + #[test] + fn finish_omits_the_session_part_when_none() { + let acc = PartsAccumulator::new(); + assert_eq!(acc.finish(None), Vec::::new()); + } + + #[test] + fn order_is_first_seen_not_hashmap_order() { + let mut acc = PartsAccumulator::new(); + acc.feed(&json!({"type": "text-start", "id": "z"})); + acc.feed(&json!({"type": "text-delta", "id": "z", "delta": "Z"})); + acc.feed(&json!({"type": "text-start", "id": "a"})); + acc.feed(&json!({"type": "text-delta", "id": "a", "delta": "A"})); + let parts = acc.finish(None); + assert_eq!(parts[0]["text"], "Z"); + assert_eq!(parts[1]["text"], "A"); + } +} diff --git a/apps/native/crates/harness/src/resolve.rs b/apps/native/crates/harness/src/resolve.rs new file mode 100644 index 0000000000..3b1a67d25c --- /dev/null +++ b/apps/native/crates/harness/src/resolve.rs @@ -0,0 +1,329 @@ +//! Binary resolution — the SHARED CONTRACT between the two Phase 2 agents +//! working this branch: `local-api` resolves harness binaries via env +//! overrides `LOCAL_API_CLAUDE_BIN` / `LOCAL_API_CODEX_BIN` (an absolute +//! path, a bare PATH-relative name, or a JSON array of argv strings — +//! `'["node", "/path/to/stub-harness.mjs"]'`), falling back to a PATH +//! lookup of `claude`/`codex` when unset. This module is that +//! implementation; the differential test suite's stub-harness binary is +//! the other side of the contract, wired in via these exact env vars. +//! +//! The JSON-array-or-plain-string parsing mirrors the convention already +//! used twice elsewhere in this migration — +//! `apps/native/e2e/helpers.ts::resolveLocalApiCmd` and +//! `packages/sandbox/daemon/daemon.e2e.helpers.ts::resolveDaemonCmd` — so a +//! test author who already knows one of those knows this one. + +use std::path::{Path, PathBuf}; + +/// The two harness CLIs desktop v1 supports (the desktop migration contract decision 1: no +/// `"decopilot"` in the desktop app). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum HarnessId { + ClaudeCode, + Codex, +} + +impl HarnessId { + /// Stable order pinned by the contract doc's `GET /models` example: + /// `claude-code` then `codex`. + pub const ALL: [HarnessId; 2] = [HarnessId::ClaudeCode, HarnessId::Codex]; + + /// The wire id used in `harnessId` (dispatch) and `harness` (models). + pub fn wire_id(self) -> &'static str { + match self { + HarnessId::ClaudeCode => "claude-code", + HarnessId::Codex => "codex", + } + } + + pub fn from_wire_id(id: &str) -> Option { + match id { + "claude-code" => Some(HarnessId::ClaudeCode), + "codex" => Some(HarnessId::Codex), + _ => None, + } + } + + /// SHARED CONTRACT env var name. + pub fn env_override_var(self) -> &'static str { + match self { + HarnessId::ClaudeCode => "LOCAL_API_CLAUDE_BIN", + HarnessId::Codex => "LOCAL_API_CODEX_BIN", + } + } + + /// PATH-lookup fallback name when no override is set. + pub fn default_binary_name(self) -> &'static str { + match self { + HarnessId::ClaudeCode => "claude", + HarnessId::Codex => "codex", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolveError { + /// The env override was set (non-empty) but couldn't be parsed as + /// either a JSON string array or used as a plain string. + InvalidOverride(String), + /// A caller supplied a resume token containing no non-whitespace + /// characters. Passing it to either CLI would look like a resume request + /// while actually selecting no durable conversation. + InvalidSessionId, + /// Resolved argv[0] is neither an executable path nor found on PATH. + NotFound(String), +} + +impl std::fmt::Display for ResolveError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ResolveError::InvalidOverride(msg) => write!(f, "{msg}"), + ResolveError::InvalidSessionId => { + write!(f, "harness session id must not be empty") + } + ResolveError::NotFound(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for ResolveError {} + +/// Resolve the argv PREFIX to spawn for `harness`, reading the SHARED +/// CONTRACT env override from the process environment. Never returns an +/// empty vec. +pub fn resolve_argv(harness: HarnessId) -> Result, ResolveError> { + let raw = std::env::var(harness.env_override_var()).ok(); + resolve_argv_from(harness, raw.as_deref()) +} + +/// Pure variant of [`resolve_argv`] taking the override value explicitly +/// instead of reading the environment — this is the one every unit test in +/// this module calls, so tests never race each other over a shared +/// process-global env var (`std::env::set_var` is famously not +/// parallel-test-safe). +pub fn resolve_argv_from( + harness: HarnessId, + override_raw: Option<&str>, +) -> Result, ResolveError> { + if let Some(raw) = override_raw { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + return parse_override(harness.env_override_var(), trimmed); + } + } + Ok(vec![harness.default_binary_name().to_string()]) +} + +fn parse_override(var: &str, trimmed: &str) -> Result, ResolveError> { + if trimmed.starts_with('[') { + let parsed: Vec = serde_json::from_str(trimmed).map_err(|e| { + ResolveError::InvalidOverride(format!("{var} is not a valid JSON string array: {e}")) + })?; + if parsed.is_empty() { + return Err(ResolveError::InvalidOverride(format!( + "{var} is an empty argv array" + ))); + } + return Ok(parsed); + } + Ok(vec![trimmed.to_string()]) +} + +/// Resolve AND verify argv[0] actually exists/is executable — a fast, +/// synchronous, no-subprocess check suitable for the dispatch route's +/// pre-stream "unknown_harness" gate (contract order #7: `lookupHarness` +/// throws → 400 before any process is spawned). A bare name (no path +/// separator) is searched against `PATH` by hand, mirroring POSIX +/// `execvp` search semantics, so a missing CLI surfaces as this crate's +/// own [`ResolveError::NotFound`] instead of an opaque spawn-time I/O +/// error the caller would otherwise have to sniff. +pub fn resolve_checked(harness: HarnessId) -> Result, ResolveError> { + let raw = std::env::var(harness.env_override_var()).ok(); + resolve_checked_from(harness, raw.as_deref()) +} + +/// Pure variant of [`resolve_checked`] — see [`resolve_argv_from`] for why. +pub fn resolve_checked_from( + harness: HarnessId, + override_raw: Option<&str>, +) -> Result, ResolveError> { + let argv = resolve_argv_from(harness, override_raw)?; + let program = Path::new(&argv[0]); + if is_executable_path(program) { + return Ok(argv); + } + if !argv[0].contains(std::path::MAIN_SEPARATOR) && which(&argv[0]).is_some() { + return Ok(argv); + } + Err(ResolveError::NotFound(format!( + "{} CLI not found: {:?} is neither an executable path nor on PATH", + harness.wire_id(), + argv[0], + ))) +} + +fn is_executable_path(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .map(|m| m.is_file() && (m.permissions().mode() & 0o111 != 0)) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + path.is_file() + } +} + +/// Manual `PATH` search — POSIX `execvp`-style: for each `PATH` entry, +/// check `/` is an executable file. Returns the first hit. +/// Module-public (not just crate-private) since `detect.rs` and tests both +/// use it directly. +pub fn which(name: &str) -> Option { + let path_var = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(name); + if is_executable_path(&candidate) { + return Some(candidate); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn make_executable(path: &Path) { + std::fs::File::create(path) + .unwrap() + .write_all(b"#!/bin/sh\nexit 0\n") + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + + #[test] + fn no_override_falls_back_to_default_binary_name() { + assert_eq!( + resolve_argv_from(HarnessId::ClaudeCode, None).unwrap(), + vec!["claude".to_string()] + ); + assert_eq!( + resolve_argv_from(HarnessId::Codex, None).unwrap(), + vec!["codex".to_string()] + ); + } + + #[test] + fn empty_override_falls_back_to_default_binary_name() { + assert_eq!( + resolve_argv_from(HarnessId::ClaudeCode, Some(" ")).unwrap(), + vec!["claude".to_string()] + ); + } + + #[test] + fn absolute_path_override_is_used_verbatim() { + assert_eq!( + resolve_argv_from(HarnessId::ClaudeCode, Some("/opt/claude/bin/claude")).unwrap(), + vec!["/opt/claude/bin/claude".to_string()] + ); + } + + #[test] + fn json_array_override_becomes_full_argv() { + assert_eq!( + resolve_argv_from( + HarnessId::Codex, + Some(r#"["node", "/path/to/stub-harness.mjs", "--codex"]"#) + ) + .unwrap(), + vec![ + "node".to_string(), + "/path/to/stub-harness.mjs".to_string(), + "--codex".to_string() + ] + ); + } + + #[test] + fn invalid_json_array_override_errors() { + let err = resolve_argv_from(HarnessId::ClaudeCode, Some("[not json")).unwrap_err(); + assert!(matches!(err, ResolveError::InvalidOverride(_))); + } + + #[test] + fn empty_json_array_override_errors() { + let err = resolve_argv_from(HarnessId::ClaudeCode, Some("[]")).unwrap_err(); + assert!(matches!(err, ResolveError::InvalidOverride(_))); + } + + #[test] + fn resolve_checked_accepts_an_existing_executable_override() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("fake-claude"); + make_executable(&bin); + let argv = + resolve_checked_from(HarnessId::ClaudeCode, Some(bin.to_str().unwrap())).unwrap(); + assert_eq!(argv, vec![bin.to_str().unwrap().to_string()]); + } + + #[test] + fn resolve_checked_rejects_a_nonexistent_override_path() { + let err = resolve_checked_from( + HarnessId::ClaudeCode, + Some("/definitely/not/a/real/path/claude"), + ) + .unwrap_err(); + assert!(matches!(err, ResolveError::NotFound(_))); + } + + #[test] + fn resolve_checked_rejects_a_non_executable_file() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("not-executable"); + std::fs::write(&bin, b"nope").unwrap(); + let err = + resolve_checked_from(HarnessId::ClaudeCode, Some(bin.to_str().unwrap())).unwrap_err(); + assert!(matches!(err, ResolveError::NotFound(_))); + } + + #[test] + fn which_finds_a_binary_placed_on_a_synthetic_path() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("totally-fake-cli-12345"); + make_executable(&bin); + let joined = std::env::join_paths([dir.path()]).unwrap(); + let found = which_with_path("totally-fake-cli-12345", &joined); + assert_eq!(found, Some(bin)); + } + + #[test] + fn which_returns_none_for_a_name_absent_from_path() { + let dir = tempfile::tempdir().unwrap(); + let joined = std::env::join_paths([dir.path()]).unwrap(); + assert_eq!( + which_with_path("nonexistent-cli-98765", &joined), + None:: + ); + } + + /// Test-only helper: `which()`'s search logic against an explicit + /// `PATH` value rather than the process environment (see the module + /// doc's parallel-test-safety rationale). + fn which_with_path(name: &str, path_var: &std::ffi::OsStr) -> Option { + for dir in std::env::split_paths(path_var) { + let candidate = dir.join(name); + if is_executable_path(&candidate) { + return Some(candidate); + } + } + None + } +} diff --git a/apps/native/crates/harness/src/run.rs b/apps/native/crates/harness/src/run.rs new file mode 100644 index 0000000000..f85c0066cf --- /dev/null +++ b/apps/native/crates/harness/src/run.rs @@ -0,0 +1,1923 @@ +//! Ties [`crate::resolve`] + [`crate::tiers`] + [`crate::spawn`] + +//! [`crate::events`] together into the one thing `local-api`'s dispatch +//! route actually drives: resolve → build argv → spawn (always plain +//! pipes — see `spawn.rs`'s module doc) → parse ndjson line-by-line → +//! yield [`RunEvent`]s, with cancellation support. +//! +//! ## Two-phase API (mirrors the dispatch contract's pre-stream gate) +//! +//! the native local-API contract's Dispatch Lifecycle pins +//! `lookupHarness(harnessId) throws` as a PRE-stream gate (#7): a 400 +//! `unknown_harness` response BEFORE the SSE response even starts, so it +//! must be resolved BEFORE any `200`/streaming headers are written. Once +//! that gate passes, the caller is committed to a `200` SSE stream — any +//! LATER failure (the resolved binary vanishes between the gate and the +//! actual spawn, a permission error, the CLI itself crashing) has to +//! surface as an in-stream `error` event, not a different HTTP status. +//! +//! [`build_argv`] is that gate: pure, synchronous (no process spawned), +//! calls [`resolve::resolve_checked`] and fails exactly the way an +//! `unknown_harness` gate should. [`start`] takes the now-resolved argv +//! and is effectively infallible from the CALLER's point of view — any +//! spawn-time failure after this point becomes the first (and only) +//! [`RunEvent::FatalError`] on the returned handle's stream instead of a +//! `Result::Err`. +//! +//! ## Stdin is always closed +//! +//! Every harness spawn closes stdin (`Stdio::null()`, set in +//! `spawn::plain::spawn_plain`). This is not incidental: `codex exec` +//! reads stdin as additional prompt content when it's a pipe and hangs +//! waiting for EOF if left open — observed empirically on this machine +//! (`codex exec --json ""` printed a `"Reading additional input +//! from stdin..."` banner and read after the prompt argument was already +//! provided, purely because stdin wasn't closed). Neither CLI needs stdin +//! for the single-turn, non-interactive invocations this crate makes. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Instant; + +use serde_json::Value; +use tokio::sync::{mpsc, Mutex}; + +use crate::events::claude::ClaudeEventMapper; +use crate::events::codex::CodexEventMapper; +use crate::events::{EventMapper, FlushReason, MappedEvent}; +use crate::resolve::{self, HarnessId, ResolveError}; +use crate::spawn::{self, ExitInfo, ProcessLease, SpawnRequest}; +use crate::tiers; + +/// Mirrors `packages/harness/src/types.ts::ToolApprovalLevel`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolApproval { + Auto, + Readonly, +} + +impl ToolApproval { + /// `"auto"` / `"readonly"` — the two values dispatch's pre-stream gate + /// (`validate_harness_input`, `routes/dispatch.rs`) already restricts + /// `input.toolApprovalLevel` to. + pub fn from_wire(level: &str) -> Self { + if level == "readonly" { + ToolApproval::Readonly + } else { + ToolApproval::Auto + } + } +} + +#[derive(Debug, Clone)] +pub struct RunSpec { + pub harness: HarnessId, + pub cwd: Option, + /// Extracted user-visible text (see [`extract_user_text`]) — the + /// CLI's positional prompt argument. + pub prompt: String, + /// `input.harness.sessionId` from a prior turn, if resuming. + pub resume_session_id: Option, + /// Composite model id (`input.models.thinking.id`, e.g. + /// `"claude-code:sonnet"`). + pub model_id: String, + pub tool_approval: ToolApproval, + /// `input.mode == "plan"` — same tool restrictions as `readonly` in + /// headless CLI mode, mirrors `isPlanMode` in + /// `packages/harness/src/claude-code/index.ts`. + pub plan_mode: bool, + /// Extra system-prompt text to APPEND to whatever the CLI already uses — + /// today the organization-filesystem block (see + /// `local-api::sandbox::org_prompt`). + /// + /// The two CLIs take this differently and the difference is load-bearing: + /// `claude` has a dedicated `--append-system-prompt` that adds to its own + /// default, while `codex` has no system-prompt flag at all and instead + /// takes `-c developer_instructions=…`, which SETS that layer rather than + /// appending. Nothing else populates codex's developer instructions today, + /// so this is additive in practice — but a second block would have to be + /// composed into one string rather than passed separately. + pub append_system_prompt: Option, + /// The agent's own MCP endpoint, so the CLI can call the agent's tools. + /// + /// Cluster runs get a per-run minted key; the desktop instead points the + /// CLI at THIS process, which proxies `/mcp/*` upstream with the + /// Keychain-backed token. That means the credential is local-api's own + /// control cookie, and local-api's embedded guard also demands the exact + /// `Origin` — hence both travel together. + pub mcp: Option, +} + +/// Where the agent's MCP lives and what it takes to be let in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpEndpoint { + pub url: String, + pub cookie: String, + pub origin: String, +} + +/// Env var names codex is pointed at, so no secret appears in its argv. +const MCP_URL_ENV: &str = "DECOCMS_MCP_URL"; +const MCP_COOKIE_ENV: &str = "DECOCMS_MCP_COOKIE"; +const MCP_ORIGIN_ENV: &str = "DECOCMS_MCP_ORIGIN"; + +/// The one MCP server name both CLIs see. Matches the cluster harness's +/// `cms` key, so a prompt that refers to it means the same thing either side. +const MCP_SERVER_NAME: &str = "cms"; + +/// claude's `--mcp-config` document. +/// +/// Every value is a `${VAR}` reference, so this string is safe to pass on the +/// command line: the environment supplies the url, cookie and origin. +pub fn claude_mcp_config() -> String { + serde_json::json!({ + "mcpServers": { + MCP_SERVER_NAME: { + "type": "http", + "url": format!("${{{MCP_URL_ENV}}}"), + "headers": { + "Cookie": format!("${{{MCP_COOKIE_ENV}}}"), + "Origin": format!("${{{MCP_ORIGIN_ENV}}}"), + }, + } + } + }) + .to_string() +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RunEvent { + /// Internal continuation checkpoint. Consumers must persist this before + /// acknowledging any later output from the same process; it is not an + /// AI-SDK wire chunk and must never be forwarded to the webview. + SessionId { session_id: String }, + /// → dispatch's `{"type":"ui-message-chunk","chunk":}`. + Chunk(Value), + /// → dispatch's `{"type":"error","code":"harness_crashed",...}`. At + /// most one per run, always the LAST event before the channel closes. + FatalError { message: String }, +} + +/// Extracts the visible text from `input.userMessage` — a UIMessage-shaped +/// `{ parts: [...] }` object opaque to the dispatch route (see the +/// contract doc's Threads-store section: "the same 'UIMessage parts' +/// shape the mesh chat wire already uses"). Joins every +/// `{"type":"text","text":...}` part with newlines; non-text parts +/// (images, tool calls) are ignored. Mirrors +/// `packages/harness/src/cli-message-prep.ts::extractUserText`'s +/// text-only extraction, simplified for dispatch's single-message (not +/// full-history) input shape. +pub fn extract_user_text(user_message: &Value) -> String { + let Some(parts) = user_message.get("parts").and_then(Value::as_array) else { + // Tolerate a bare string as the whole message too — defensive, + // since `userMessage` is opaque to the dispatch route's own + // schema (`chatMessageSchema = z.record(...)` on the TS side). + return user_message.as_str().unwrap_or_default().to_string(); + }; + parts + .iter() + .filter(|p| p.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|p| p.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n") +} + +/// The pre-stream gate: resolves `spec.harness`'s binary (SHARED CONTRACT +/// env overrides, PATH fallback) and builds the full argv, WITHOUT +/// spawning anything. `Err` here is dispatch's `unknown_harness` 400 gate +/// (contract order #7) — an unresolvable/missing CLI is treated the same +/// as an unrecognized `harnessId`, since from the caller's point of view +/// both mean "this run cannot start." +pub fn build_argv(spec: &RunSpec) -> Result, ResolveError> { + let resume_session_id = normalized_resume_session_id(spec)?; + let mut argv = resolve::resolve_checked(spec.harness)?; + match spec.harness { + HarnessId::ClaudeCode => append_claude_args(&mut argv, spec, resume_session_id), + HarnessId::Codex => append_codex_args(&mut argv, spec, resume_session_id), + } + Ok(argv) +} + +fn normalized_resume_session_id(spec: &RunSpec) -> Result, ResolveError> { + let Some(raw) = spec.resume_session_id.as_deref() else { + return Ok(None); + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(ResolveError::InvalidSessionId); + } + Ok(Some(trimmed)) +} + +fn append_claude_args(argv: &mut Vec, spec: &RunSpec, resume_session_id: Option<&str>) { + // `-p ` stays FIRST. The contract suite pins the prompt at + // `args[1]`, and every optional flag below is order-independent to the + // CLI — so anything added here goes after, never before. + argv.push("-p".to_string()); + argv.push(spec.prompt.clone()); + if let Some(extra) = spec.append_system_prompt.as_deref() { + // Appends to the CLI's own default prompt. `--system-prompt` would + // REPLACE it, discarding everything claude-code relies on. + argv.push("--append-system-prompt".to_string()); + argv.push(extra.to_string()); + } + if spec.mcp.is_some() { + // Inline JSON, but the SECRET is not in it: claude expands `${VAR}` + // from the environment (verified — a `${...}` cookie arrives at the + // server fully expanded), so argv carries only variable NAMES. `ps` + // shows argv to every local process, which is why this is not the + // literal cookie and why no file is needed either. + argv.push("--mcp-config".to_string()); + argv.push(claude_mcp_config()); + // Ignore the user's own `~/.claude` servers: an agent run must get the + // agent's tools, not whatever this machine happens to have configured. + argv.push("--strict-mcp-config".to_string()); + } + argv.push("--output-format".to_string()); + argv.push("stream-json".to_string()); + argv.push("--include-partial-messages".to_string()); + argv.push("--verbose".to_string()); + argv.push("--model".to_string()); + argv.push(tiers::resolve_claude_model_id(&spec.model_id)); + if let Some(sid) = resume_session_id { + argv.push("--resume".to_string()); + argv.push(sid.to_string()); + } + // Headless-safe permission handling, mirrors + // `packages/harness/src/claude-code/model/index.ts::createClaudeCodeModel`: + // always bypass the (impossible-in-headless) interactive permission + // prompt, and disallow tools that require a TTY / manage local + // session state regardless of approval level; ALSO disallow + // write-capable tools when the run is read-only or plan mode. + argv.push("--permission-mode".to_string()); + argv.push("bypassPermissions".to_string()); + let mut disallowed: Vec<&str> = vec![ + "AskUserQuestion", + "ExitPlanMode", + "EnterWorktree", + "ExitWorktree", + "Config", + ]; + if spec.plan_mode || matches!(spec.tool_approval, ToolApproval::Readonly) { + disallowed.extend(["Write", "Edit", "Bash", "NotebookEdit"]); + } + argv.push("--disallowedTools".to_string()); + argv.push(disallowed.join(",")); +} + +fn append_codex_args(argv: &mut Vec, spec: &RunSpec, resume_session_id: Option<&str>) { + argv.push("exec".to_string()); + if let Some(extra) = spec.append_system_prompt.as_deref() { + // codex has no system-prompt flag; `developer_instructions` is the + // config key that reaches the model (verified end to end — an + // instruction passed this way overrides the model's default answer). + // There is no file-based variant: `experimental_instructions_file`, + // `instructions_file` and `base_instructions` are all rejected by + // `--strict-config`. + argv.push("-c".to_string()); + argv.push(format!("developer_instructions={extra}")); + } + if let Some(mcp) = spec.mcp.as_ref() { + // `env_http_headers` maps a header NAME to the env var holding its + // value, so the cookie never appears in argv. Verified against the + // config `codex mcp add --url` writes. + argv.push("-c".to_string()); + argv.push(format!("mcp_servers.{MCP_SERVER_NAME}.url={}", mcp.url)); + argv.push("-c".to_string()); + argv.push(format!( + "mcp_servers.{MCP_SERVER_NAME}.env_http_headers.Cookie={MCP_COOKIE_ENV}" + )); + argv.push("-c".to_string()); + argv.push(format!( + "mcp_servers.{MCP_SERVER_NAME}.env_http_headers.Origin={MCP_ORIGIN_ENV}" + )); + } + argv.push("--json".to_string()); + argv.push("--skip-git-repo-check".to_string()); + argv.push("--model".to_string()); + argv.push(tiers::resolve_codex_model_id(&spec.model_id)); + let sandbox = if spec.plan_mode || matches!(spec.tool_approval, ToolApproval::Readonly) { + "read-only" + } else { + "workspace-write" + }; + argv.push("--sandbox".to_string()); + argv.push(sandbox.to_string()); + // `codex exec resume [PROMPT]` — a subcommand of `exec`, placed + // after the flags above and before the trailing positional prompt. + // NOTE: exact flag/subcommand interleaving is best-effort — verified + // manually via `codex exec --help`/`codex exec resume --help` on this + // machine (codex-cli 0.144.5) but NOT exercised end-to-end with a real + // resumed run (that needs a live multi-turn smoke test — see + // the native harness smoke procedure). If this ordering turns out + // to be wrong, the failure surfaces as a normal `harness_crashed` + // stream error (the CLI rejects its own argv), not a silent + // misbehavior. + if let Some(sid) = resume_session_id { + argv.push("resume".to_string()); + argv.push(sid.to_string()); + } + argv.push(spec.prompt.clone()); +} + +/// A running harness. `recv()` yields [`RunEvent`]s until the underlying +/// process's output is fully drained and the process has exited, at which +/// point the channel closes (`recv()` returns `None`) — mirroring the TS +/// dispatch route's `for await (const chunk of harness.stream())` +/// finishing naturally. A `FatalError`, if any, is always the LAST event +/// before closing. +pub struct RunHandle { + events_rx: mpsc::Receiver, + cancelled: Arc, + process_slot: Arc>>, + session_id: Arc>>, + drive_task: Option>, +} + +impl Drop for RunHandle { + fn drop(&mut self) { + // The drive task owns `SpawnedProcess::owner`. Aborting it drops that + // ownership edge, which wakes the detached child waiter and runs its + // bounded TERM→KILL + reap path. This is the panic/abort backstop for + // callers that cannot reach the async `cancel()` method. + self.cancelled.store(true, Ordering::SeqCst); + if let Some(task) = self.drive_task.take() { + task.abort(); + } + } +} + +impl RunHandle { + pub async fn recv(&mut self) -> Option { + self.events_rx.recv().await + } + + /// The harness's session/thread id, once known (populated as soon as + /// the underlying CLI reports it — claude's `system.init`/`result`, + /// codex's `thread.started`) — the resume token for the run's next + /// turn. + pub async fn session_id(&self) -> Option { + self.session_id.lock().await.clone() + } + + /// Cancels the run: marks it cancelled (suppressing the synthetic + /// "exited abnormally" `FatalError` a SIGTERM would otherwise + /// produce — a cancel is not a crash) and signals the process + /// group, escalating TERM→KILL when the CLI resists. Idempotent — safe + /// to call more than once, or after the run has already finished. The + /// process-group lease becomes inert when its waiter reaps the group, so a + /// stale handle never targets a later process that reused the numeric pid. + /// + /// RACE, CLOSED: a `cancel()` landing in the brief async gap between + /// `start()` beginning and the spawned process's pid becoming known + /// finds nothing to signal HERE (`process_slot` still `None`) — but it + /// still sets `cancelled`, and `drive()` re-checks that flag itself + /// the INSTANT it learns the pid (see `drive()`'s own comment at that + /// check), killing the just-spawned process immediately instead of + /// letting it run unsupervised. Found by + /// `apps/native/e2e/dispatch-stream.e2e.test.ts`'s `hang` scenario — + /// a client that cancels the moment it observes ANY output (before + /// the child process has even finished `fork`/`exec`) reliably raced + /// this window; a `cancel()` that fires before the pid exists is the + /// COMMON case for that scenario, not a rare corner. + pub async fn cancel(&self) { + self.cancelled.store(true, Ordering::SeqCst); + if let Some(process) = self.process_slot.lock().await.clone() { + process.terminate().await; + } + } + + /// A `Clone`-able, `Send + Sync` handle carrying JUST the cancel + /// capability, sharing the SAME underlying state as this `RunHandle` + /// (`Arc`-backed — cancelling through either one is observable + /// through the other). Exists because `recv()`/`&mut self` and a + /// registry of "runs any request can cancel by id" (`local-api`'s + /// `routes/dispatch.rs`) want different ownership shapes: the SSE + /// response body owns/drains `RunHandle` by value in one task, while + /// `DELETE /_sandbox/runs/:id` needs a `Clone`-able reference it can + /// stash in a shared map and call from a completely different + /// request's handler. + pub fn cancel_handle(&self) -> CancelHandle { + CancelHandle { + cancelled: self.cancelled.clone(), + process_slot: self.process_slot.clone(), + } + } +} + +/// See [`RunHandle::cancel_handle`]. Identical cancel semantics to +/// [`RunHandle::cancel`] — same fields, same method — just detached from +/// the event-receiving half. +#[derive(Clone)] +pub struct CancelHandle { + cancelled: Arc, + process_slot: Arc>>, +} + +impl CancelHandle { + pub async fn cancel(&self) { + self.cancelled.store(true, Ordering::SeqCst); + if let Some(process) = self.process_slot.lock().await.clone() { + process.terminate().await; + } + } +} + +/// Spawns `argv` (already resolved by [`build_argv`]) and starts driving +/// its output. Never fails from the caller's perspective (see module +/// doc's "two-phase API" section) — a spawn-time I/O error becomes the +/// handle's first and only [`RunEvent::FatalError`]. A caller that bypasses +/// [`build_argv`] still cannot bypass resume-token validation: invalid input +/// produces the same closed fatal stream without spawning `argv`. +/// Convenience entrypoint for harness-only tests/consumers that do not own a +/// local-api app-root recovery fence. Native local-api must use +/// [`start_with_child_lifetime_lock`] instead. +pub async fn start(argv: Vec, spec: &RunSpec) -> RunHandle { + start_inner(argv, spec, None).await +} + +pub async fn start_with_child_lifetime_lock( + argv: Vec, + spec: &RunSpec, + child_lifetime_lock_path: PathBuf, +) -> RunHandle { + start_inner(argv, spec, Some(child_lifetime_lock_path)).await +} + +async fn start_inner( + argv: Vec, + spec: &RunSpec, + child_lifetime_lock_path: Option, +) -> RunHandle { + let (tx, rx) = mpsc::channel::(64); + let cancelled = Arc::new(AtomicBool::new(false)); + let process_slot = Arc::new(Mutex::new(None)); + // A resumed turn already has a durable session identity before the child + // starts. Keep that identity even when the CLI is cancelled, crashes, or + // rejects its argv before emitting its normal init event; otherwise the + // failed assistant row can mask the only resume anchor and the next turn + // silently starts a fresh conversation. + let session_id = match normalized_resume_session_id(spec) { + Ok(session_id) => Arc::new(Mutex::new(session_id.map(str::to_string))), + Err(error) => { + let _ = tx + .send(RunEvent::FatalError { + message: error.to_string(), + }) + .await; + drop(tx); + return RunHandle { + events_rx: rx, + cancelled, + process_slot, + session_id: Arc::new(Mutex::new(None)), + drive_task: None, + }; + } + }; + + // Secrets reach the child by ENVIRONMENT (codex) or a 0600 file (claude), + // never argv — `ps` shows argv to every local process. + let mut env = Vec::new(); + if let Some(mcp) = spec.mcp.as_ref() { + env.push((MCP_URL_ENV.to_string(), mcp.url.clone())); + env.push((MCP_COOKIE_ENV.to_string(), mcp.cookie.clone())); + env.push((MCP_ORIGIN_ENV.to_string(), mcp.origin.clone())); + } + + let req = SpawnRequest { + argv, + cwd: spec.cwd.clone(), + env, + lifetime_lock_path: child_lifetime_lock_path, + // Always plain pipes — see `spawn.rs`'s "why not PTY" doc comment. + pty: false, + }; + + let harness = spec.harness; + + let drive_task = tokio::spawn(drive( + req, + harness, + tx, + cancelled.clone(), + process_slot.clone(), + session_id.clone(), + )); + + RunHandle { + events_rx: rx, + cancelled, + process_slot, + session_id, + drive_task: Some(drive_task), + } +} + +/// Bound on how much trailing stderr is retained for a nonzero-exit +/// error message — enough for a useful diagnostic line, not enough for a +/// misbehaving CLI's stderr firehose to grow this unbounded. +const STDERR_TAIL_CAP: usize = 4096; +const MISSING_SESSION_ID_ERROR: &str = + "local harness completed without a session id; the chat cannot be resumed safely"; +const CHANGED_SESSION_ID_ERROR: &str = + "local harness changed its session id while the thread was running; start a new chat instead of continuing with split history"; +const EMPTY_SESSION_ID_ERROR: &str = + "local harness emitted an empty session id; the chat cannot be resumed safely"; + +async fn drive( + req: SpawnRequest, + harness: HarnessId, + tx: mpsc::Sender, + cancelled: Arc, + process_slot: Arc>>, + session_id: Arc>>, +) { + let fallback_req = (harness == HarnessId::ClaudeCode) + .then(|| without_arg(&req, "--include-partial-messages")) + .flatten(); + let outcome = drive_attempt( + req, + harness, + &tx, + &cancelled, + &process_slot, + &session_id, + fallback_req.is_some(), + ) + .await; + + if outcome == AttemptOutcome::RetryWithoutPartialMessages && !cancelled.load(Ordering::SeqCst) { + let Some(fallback_req) = fallback_req else { + return; + }; + tracing::warn!( + target: "decocms.native.chat.latency", + harness = harness.wire_id(), + "Claude CLI does not support partial-message streaming; retrying after pre-inference argument rejection" + ); + let _ = drive_attempt( + fallback_req, + harness, + &tx, + &cancelled, + &process_slot, + &session_id, + false, + ) + .await; + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AttemptOutcome { + Done, + RetryWithoutPartialMessages, +} + +#[allow(clippy::too_many_arguments)] +async fn drive_attempt( + req: SpawnRequest, + harness: HarnessId, + tx: &mpsc::Sender, + cancelled: &AtomicBool, + process_slot: &Mutex>, + session_id: &Mutex>, + allow_partial_messages_fallback: bool, +) -> AttemptOutcome { + let spawned_at = Instant::now(); + let proc = match spawn::spawn(req).await { + Ok(p) => p, + Err(err) => { + let _ = tx + .send(RunEvent::FatalError { + message: format!("failed to start {}: {err}", harness.wire_id()), + }) + .await; + return AttemptOutcome::Done; + } + }; + *process_slot.lock().await = Some(proc.lease.clone()); + // Closes the race documented on `RunHandle::cancel`: a `cancel()` call + // that landed BEFORE this line (the child was still spawning, so + // `process_slot` was `None` and there was nothing to signal yet) sets + // `cancelled` but can't kill anything. Checking `cancelled` again + // HERE, now that the pid is known, catches exactly that window — + // this is what makes cancelling a request that returns near-instantly + // (e.g. a harness that emits one line then hangs, cancelled the + // moment that line is observed) reliable instead of racy. Every + // `cancel()`/`CancelHandle::cancel()` call kills unconditionally (not + // gated on "is anyone listening"), so re-signaling here even if the + // ORIGINAL call already found and killed the pid is a harmless + // no-op — `kill(1)` on an already-dead process just fails silently. + if cancelled.load(Ordering::SeqCst) { + proc.lease.terminate().await; + } + let spawn::SpawnedProcess { + pid: _, + mut stdout, + mut stderr, + exit, + lease: _lease, + owner: _owner, + } = proc; + + let stderr_tail = Arc::new(Mutex::new(String::new())); + let stderr_task = tokio::spawn({ + let stderr_tail = stderr_tail.clone(); + async move { + while let Some(chunk) = stderr.recv().await { + let mut tail = stderr_tail.lock().await; + tail.push_str(&String::from_utf8_lossy(&chunk)); + let len = tail.len(); + if len > STDERR_TAIL_CAP { + let excess = len - STDERR_TAIL_CAP; + tail.drain(0..excess); + } + } + } + }); + + let mut mapper: Box = match harness { + HarnessId::ClaudeCode => Box::new(ClaudeEventMapper::new()), + HarnessId::Codex => Box::new(CodexEventMapper::new()), + }; + + let mut linebuf: Vec = Vec::new(); + let mut stopped_early = false; + let mut mapper_reported_fatal = false; + let mut stdout_non_whitespace = false; + let mut first_meaningful_chunk_logged = false; + 'outer: while let Some(bytes) = stdout.recv().await { + stdout_non_whitespace |= bytes.iter().any(|byte| !byte.is_ascii_whitespace()); + linebuf.extend_from_slice(&bytes); + while let Some(pos) = linebuf.iter().position(|&b| b == b'\n') { + let line_bytes: Vec = linebuf.drain(..=pos).collect(); + let line = String::from_utf8_lossy(&line_bytes[..line_bytes.len().saturating_sub(1)]) + .into_owned(); + let mut events = mapper.feed_line(&line); + mapper_reported_fatal |= flush_before_mapped_fatal(&mut *mapper, &mut events); + trace_first_meaningful_chunk( + &events, + harness, + spawned_at, + &mut first_meaningful_chunk_logged, + ); + if dispatch_mapped(events, tx, session_id).await { + stopped_early = true; + break 'outer; + } + } + } + if !stopped_early && !linebuf.is_empty() { + let line = String::from_utf8_lossy(&linebuf).into_owned(); + let mut events = mapper.feed_line(&line); + mapper_reported_fatal |= flush_before_mapped_fatal(&mut *mapper, &mut events); + trace_first_meaningful_chunk( + &events, + harness, + spawned_at, + &mut first_meaningful_chunk_logged, + ); + if dispatch_mapped(events, tx, session_id).await { + stopped_early = true; + } + } + + let exit_info = exit.await.unwrap_or(ExitInfo { code: 1 }); + let _ = stderr_task.await; + + let tail = stderr_tail.lock().await.clone(); + if allow_partial_messages_fallback + && is_pre_inference_partial_flag_rejection(harness, exit_info, stdout_non_whitespace, &tail) + { + // The mapper is empty for a CLI argument-parser rejection, but invoke + // the lifecycle hook on this failed attempt just like every other + // process exit. Never retry after any stdout: that could duplicate a + // paid model call or a side effect. + let _ = mapper.flush(FlushReason::Failed); + return AttemptOutcome::RetryWithoutPartialMessages; + } + + if !mapper_reported_fatal && !stopped_early { + let flush_reason = if cancelled.load(Ordering::SeqCst) { + FlushReason::Cancelled + } else if exit_info.code == 0 { + FlushReason::EndOfStream + } else { + FlushReason::Failed + }; + let flushed = mapper.flush(flush_reason); + if dispatch_mapped(flushed, tx, session_id).await { + stopped_early = true; + } + } + + if !stopped_early + && !cancelled.load(Ordering::SeqCst) + && exit_info.code == 0 + && session_id + .lock() + .await + .as_deref() + .is_none_or(|sid| sid.trim().is_empty()) + { + let _ = tx + .send(RunEvent::FatalError { + message: MISSING_SESSION_ID_ERROR.to_string(), + }) + .await; + } + + if !stopped_early && !cancelled.load(Ordering::SeqCst) && exit_info.code != 0 { + let suffix = if tail.trim().is_empty() { + String::new() + } else { + format!(": {}", tail.trim()) + }; + let _ = tx + .send(RunEvent::FatalError { + message: format!( + "{} exited with code {}{suffix}", + harness.wire_id(), + exit_info.code + ), + }) + .await; + } + // The wrapper drops its owning `tx` after this attempt (or after the + // compatibility retry), closing the channel so `RunHandle::recv()` + // returns `None`. + AttemptOutcome::Done +} + +fn without_arg(req: &SpawnRequest, arg: &str) -> Option { + let index = req.argv.iter().position(|candidate| candidate == arg)?; + let mut fallback = req.clone(); + fallback.argv.remove(index); + Some(fallback) +} + +fn is_pre_inference_partial_flag_rejection( + harness: HarnessId, + exit_info: ExitInfo, + stdout_non_whitespace: bool, + stderr: &str, +) -> bool { + if harness != HarnessId::ClaudeCode || exit_info.code == 0 || stdout_non_whitespace { + return false; + } + let stderr = stderr.to_ascii_lowercase(); + stderr.contains("--include-partial-messages") + && [ + "unknown option", + "unknown argument", + "unrecognized option", + "unrecognized argument", + "unexpected argument", + "wasn't expected", + "was not expected", + ] + .iter() + .any(|marker| stderr.contains(marker)) +} + +/// A mapper-level fatal event must remain the final wire event. Insert the +/// mapper's flush output immediately before it, pairing any open incremental +/// parts without violating that ordering. +fn flush_before_mapped_fatal(mapper: &mut dyn EventMapper, events: &mut Vec) -> bool { + let Some(fatal_index) = events + .iter() + .position(|event| matches!(event, MappedEvent::FatalError { .. })) + else { + return false; + }; + let flushed = mapper.flush(FlushReason::Failed); + events.splice(fatal_index..fatal_index, flushed); + true +} + +fn trace_first_meaningful_chunk( + events: &[MappedEvent], + harness: HarnessId, + spawned_at: Instant, + already_logged: &mut bool, +) { + if *already_logged { + return; + } + let Some(chunk_type) = events.iter().find_map(|event| { + let MappedEvent::Chunk(value) = event else { + return None; + }; + let chunk_type = value.get("type").and_then(Value::as_str)?; + matches!( + chunk_type, + "text-start" | "reasoning-start" | "tool-input-start" + ) + .then_some(chunk_type) + }) else { + return; + }; + *already_logged = true; + let spawn_to_first_chunk_ms = + u64::try_from(spawned_at.elapsed().as_millis()).unwrap_or(u64::MAX); + tracing::info!( + target: "decocms.native.chat.latency", + harness = harness.wire_id(), + chunk_type, + spawn_to_first_chunk_ms, + "harness produced its first meaningful chunk" + ); +} + +/// Forwards every [`MappedEvent`] onto `tx`, updating `session_id` as a +/// side effect. Returns `true` when the caller should stop reading +/// further stdout (a `FatalError` was sent, OR the receiver was dropped). +async fn dispatch_mapped( + events: Vec, + tx: &mpsc::Sender, + session_id: &Mutex>, +) -> bool { + for ev in events { + match ev { + MappedEvent::Chunk(v) => { + if v.get("type").and_then(Value::as_str) == Some("finish") + && v.get("finishReason").and_then(Value::as_str) != Some("error") + && session_id + .lock() + .await + .as_deref() + .is_none_or(|sid| sid.trim().is_empty()) + { + let _ = tx + .send(RunEvent::FatalError { + message: MISSING_SESSION_ID_ERROR.to_string(), + }) + .await; + return true; + } + if tx.send(RunEvent::Chunk(v)).await.is_err() { + return true; + } + } + MappedEvent::SessionId(sid) => { + let sid = sid.trim(); + if sid.is_empty() { + let _ = tx + .send(RunEvent::FatalError { + message: EMPTY_SESSION_ID_ERROR.to_string(), + }) + .await; + return true; + } + let changed = { + let mut current = session_id.lock().await; + match current.as_deref() { + Some(existing) if existing != sid => true, + Some(_) => false, + None => { + *current = Some(sid.to_string()); + false + } + } + }; + if changed { + let _ = tx + .send(RunEvent::FatalError { + message: CHANGED_SESSION_ID_ERROR.to_string(), + }) + .await; + return true; + } + if tx + .send(RunEvent::SessionId { + session_id: sid.to_string(), + }) + .await + .is_err() + { + return true; + } + } + MappedEvent::FatalError { message } => { + let _ = tx.send(RunEvent::FatalError { message }).await; + return true; + } + MappedEvent::Ignored => {} + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// The harness-specific argv WITHOUT `build_argv`'s binary resolution: + /// CI has neither CLI installed, and the env override that would fake one + /// is documented as not parallel-test-safe (see `resolve.rs`). These tests + /// are about argv COMPOSITION, which is exactly what these two build. + fn argv_for(spec: &RunSpec) -> Vec { + let mut argv = Vec::new(); + match spec.harness { + HarnessId::ClaudeCode => append_claude_args(&mut argv, spec, None), + HarnessId::Codex => append_codex_args(&mut argv, spec, None), + } + argv + } + + fn mcp_fixture() -> McpEndpoint { + McpEndpoint { + url: "http://localhost:4420/mcp/virtual-mcp/vir_1".to_string(), + cookie: "decocms-local-session=SECRET".to_string(), + origin: "http://localhost:4420".to_string(), + } + } + + /// The credential must never be visible to `ps`. claude gets a file path, + /// codex gets ENV VAR NAMES — neither gets the secret itself. + #[test] + fn neither_cli_receives_the_mcp_secret_in_argv() { + for harness in [HarnessId::ClaudeCode, HarnessId::Codex] { + let mut spec = base_spec(harness); + spec.mcp = Some(mcp_fixture()); + let joined = argv_for(&spec).join(" "); + + assert!(!joined.contains("SECRET"), "{harness:?} leaked the cookie"); + assert!( + !joined.contains("decocms-local-session"), + "{harness:?} leaked the cookie name/value" + ); + } + } + + /// The contract suite pins the prompt at `args[1]`; an optional flag + /// inserted before it silently shifted every positional assertion. + #[test] + fn the_prompt_stays_first_whatever_else_is_added() { + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.mcp = Some(mcp_fixture()); + spec.append_system_prompt = Some("extra".to_string()); + let argv = argv_for(&spec); + + assert_eq!(argv[0], "-p"); + assert_eq!(argv[1], spec.prompt); + } + + #[test] + fn claude_gets_inline_config_and_ignores_other_mcp_sources() { + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.mcp = Some(mcp_fixture()); + let argv = argv_for(&spec); + + assert!(argv.iter().any(|a| a == "--mcp-config")); + // Without this the user's own ~/.claude servers would join the run. + assert!(argv.iter().any(|a| a == "--strict-mcp-config")); + } + + #[test] + fn codex_points_at_env_vars_rather_than_values() { + let mut spec = base_spec(HarnessId::Codex); + spec.mcp = Some(mcp_fixture()); + let joined = argv_for(&spec).join(" "); + + assert!(joined.contains("mcp_servers.cms.url=http://localhost:4420/mcp/virtual-mcp/vir_1")); + assert!(joined.contains("mcp_servers.cms.env_http_headers.Cookie=DECOCMS_MCP_COOKIE")); + assert!(joined.contains("mcp_servers.cms.env_http_headers.Origin=DECOCMS_MCP_ORIGIN")); + } + + #[test] + fn no_mcp_means_no_flags_at_all() { + for harness in [HarnessId::ClaudeCode, HarnessId::Codex] { + let joined = argv_for(&base_spec(harness)).join(" "); + assert!( + !joined.contains("mcp"), + "{harness:?} added MCP flags anyway" + ); + } + } + + /// Every value is a `${VAR}` reference, never a literal — that is what + /// makes passing this document on the command line safe. claude expands + /// them from the environment at startup (verified against the CLI). + #[test] + fn the_claude_config_document_references_env_vars_only() { + let value: serde_json::Value = + serde_json::from_str(&claude_mcp_config()).expect("valid json"); + let server = &value["mcpServers"]["cms"]; + assert_eq!(server["type"], "http"); + assert_eq!(server["url"], "${DECOCMS_MCP_URL}"); + assert_eq!(server["headers"]["Cookie"], "${DECOCMS_MCP_COOKIE}"); + assert_eq!(server["headers"]["Origin"], "${DECOCMS_MCP_ORIGIN}"); + } + + fn base_spec(harness: HarnessId) -> RunSpec { + RunSpec { + append_system_prompt: None, + mcp: None, + harness, + cwd: None, + prompt: "hello".to_string(), + resume_session_id: None, + model_id: match harness { + HarnessId::ClaudeCode => "claude-code:sonnet".to_string(), + HarnessId::Codex => "codex:gpt-5.6-terra".to_string(), + }, + tool_approval: ToolApproval::Auto, + plan_mode: false, + } + } + + #[cfg(unix)] + async fn wait_for_file(path: &std::path::Path) -> String { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if let Ok(value) = std::fs::read_to_string(path) { + if !value.trim().is_empty() { + return value; + } + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("child must publish its lifecycle oracle") + } + + #[cfg(unix)] + async fn wait_for_pid_exit(pid: u32) { + tokio::time::timeout(std::time::Duration::from_secs(5), async move { + loop { + let alive = tokio::process::Command::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .map(|status| status.success()) + .unwrap_or(false); + if !alive { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("process {pid} survived harness teardown")); + } + + #[test] + fn extract_user_text_joins_text_parts() { + let msg = json!({"parts": [ + {"type": "text", "text": "hello"}, + {"type": "image", "url": "http://x"}, + {"type": "text", "text": "world"}, + ]}); + assert_eq!(extract_user_text(&msg), "hello\nworld"); + } + + #[test] + fn extract_user_text_tolerates_a_bare_string() { + assert_eq!(extract_user_text(&json!("hi")), "hi"); + } + + #[test] + fn extract_user_text_empty_when_no_text_parts() { + let msg = json!({"parts": [{"type": "image", "url": "http://x"}]}); + assert_eq!(extract_user_text(&msg), ""); + } + + #[test] + fn tool_approval_from_wire() { + assert_eq!(ToolApproval::from_wire("readonly"), ToolApproval::Readonly); + assert_eq!(ToolApproval::from_wire("auto"), ToolApproval::Auto); + // Byte-parity gate already restricts this to auto|readonly; an + // unexpected value degrades to the more-permissive `Auto` rather + // than panicking. + assert_eq!(ToolApproval::from_wire("whatever"), ToolApproval::Auto); + } + + #[test] + fn resume_session_id_is_trimmed_and_empty_values_are_rejected() { + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.resume_session_id = Some(" session-123 ".to_string()); + assert_eq!( + normalized_resume_session_id(&spec).unwrap(), + Some("session-123") + ); + + spec.resume_session_id = Some(" \n\t ".to_string()); + assert_eq!( + normalized_resume_session_id(&spec), + Err(ResolveError::InvalidSessionId) + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn public_start_entrypoints_reject_invalid_resume_without_spawning() { + for use_lifetime_lock in [false, true] { + let temp = tempfile::tempdir().expect("tempdir"); + let marker = temp.path().join("spawned"); + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.resume_session_id = Some(" \n\t ".to_string()); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf spawned > \"$1\"".to_string(), + "harness".to_string(), + marker.display().to_string(), + ]; + + let mut handle = if use_lifetime_lock { + start_with_child_lifetime_lock(argv, &spec, temp.path().join("child.lock")).await + } else { + start(argv, &spec).await + }; + let mut events = Vec::new(); + while let Some(event) = handle.recv().await { + events.push(event); + } + + assert_eq!( + events, + vec![RunEvent::FatalError { + message: ResolveError::InvalidSessionId.to_string(), + }] + ); + assert!( + !marker.exists(), + "invalid resume input must be rejected before caller-supplied argv is spawned" + ); + assert_eq!(handle.session_id().await, None); + } + } + + #[test] + fn build_argv_fails_closed_for_an_unresolvable_claude_binary() { + // No LOCAL_API_CLAUDE_BIN override is set for this test process, + // and "claude" is very unlikely to be validated as present via + // this pure function in a bare CI runner — but to make the + // assertion deterministic regardless of the CI machine's PATH, + // point the override at a definitely-nonexistent path via the + // ENV directly is what `build_argv`/`resolve_checked` reads; since + // that's not parallel-test-safe (see `resolve.rs`'s doc comment), + // this test instead exercises the pure argv-shape assertions + // below and leaves the resolve-failure path to `resolve.rs`'s own + // (env-free) unit tests. + let spec = base_spec(HarnessId::ClaudeCode); + // Can't assert Ok/Err deterministically without an env override + // (which isn't parallel-test-safe here) — just confirm this + // doesn't panic either way. + let _ = build_argv(&spec); + } + + #[test] + fn claude_argv_shape_includes_expected_flags() { + let mut argv = vec!["claude".to_string()]; + let spec = base_spec(HarnessId::ClaudeCode); + append_claude_args(&mut argv, &spec, None); + assert!(argv.contains(&"-p".to_string())); + assert!(argv.contains(&"hello".to_string())); + assert!(argv.contains(&"stream-json".to_string())); + assert!(argv.contains(&"--include-partial-messages".to_string())); + assert!(argv.contains(&"claude-sonnet-5".to_string())); + assert!(!argv.contains(&"--resume".to_string())); + } + + #[test] + fn claude_argv_includes_resume_when_session_id_present() { + let mut argv = vec!["claude".to_string()]; + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.resume_session_id = Some("sess-123".to_string()); + append_claude_args(&mut argv, &spec, Some("sess-123")); + let idx = argv.iter().position(|a| a == "--resume").unwrap(); + assert_eq!(argv[idx + 1], "sess-123"); + } + + #[test] + fn claude_argv_disallows_write_tools_when_readonly() { + let mut argv = vec!["claude".to_string()]; + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.tool_approval = ToolApproval::Readonly; + append_claude_args(&mut argv, &spec, None); + let idx = argv.iter().position(|a| a == "--disallowedTools").unwrap(); + assert!(argv[idx + 1].contains("Write")); + assert!(argv[idx + 1].contains("Bash")); + } + + #[test] + fn claude_argv_allows_bash_when_auto_approval() { + let mut argv = vec!["claude".to_string()]; + let spec = base_spec(HarnessId::ClaudeCode); + append_claude_args(&mut argv, &spec, None); + let idx = argv.iter().position(|a| a == "--disallowedTools").unwrap(); + assert!(!argv[idx + 1].contains("Bash")); + } + + #[test] + fn codex_argv_shape_includes_expected_flags() { + let mut argv = vec!["codex".to_string()]; + let spec = base_spec(HarnessId::Codex); + append_codex_args(&mut argv, &spec, None); + assert_eq!(argv[1], "exec"); + assert!(argv.contains(&"--json".to_string())); + assert!(argv.contains(&"gpt-5.6-terra".to_string())); + assert_eq!(argv.last(), Some(&"hello".to_string())); + assert!(!argv.contains(&"resume".to_string())); + } + + #[test] + fn codex_argv_includes_resume_subcommand_when_session_id_present() { + let mut argv = vec!["codex".to_string()]; + let mut spec = base_spec(HarnessId::Codex); + spec.resume_session_id = Some("thread-abc".to_string()); + append_codex_args(&mut argv, &spec, Some("thread-abc")); + let idx = argv.iter().position(|a| a == "resume").unwrap(); + assert_eq!(argv[idx + 1], "thread-abc"); + // Prompt still trails everything. + assert_eq!(argv.last(), Some(&"hello".to_string())); + } + + #[test] + fn codex_argv_sandbox_is_read_only_for_plan_mode() { + let mut argv = vec!["codex".to_string()]; + let mut spec = base_spec(HarnessId::Codex); + spec.plan_mode = true; + append_codex_args(&mut argv, &spec, None); + let idx = argv.iter().position(|a| a == "--sandbox").unwrap(); + assert_eq!(argv[idx + 1], "read-only"); + } + + // --- End-to-end drive() tests using a fake "harness" script (/bin/sh) + // instead of the real CLIs — deterministic, free, and exercises the + // exact same drive()/spawn()/EventMapper wiring `local-api`'s dispatch + // route depends on, just against synthetic ndjson instead of a real + // model call. This is the differential/stub-harness style contract + // the Phase 2 TODO's "deterministic stub harness" item calls for, at + // the harness-crate level (the desktop-level black-box differential + // suite is a separate, higher-layer concern this crate's tests don't + // replace). + + fn claude_success_script() -> String { + // Mirrors the fallback shape from + // `apps/native/e2e/fixtures/stub-harness.mjs`: plain `assistant` + // frames only. The mapper retains this path for older CLIs and + // test doubles that accept but do not emit partial messages. + "printf '%s\\n' \ + '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s1\"}' \ + '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}}' \ + '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hi\",\"session_id\":\"s1\"}'" + .to_string() + } + + fn success_without_session_script(harness: HarnessId) -> String { + match harness { + HarnessId::ClaudeCode => { + "printf '%s\\n' \ + '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}}' \ + '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hi\"}'" + .to_string() + } + HarnessId::Codex => { + "printf '%s\\n' \ + '{\"type\":\"turn.started\"}' \ + '{\"type\":\"item.completed\",\"item\":{\"id\":\"item-1\",\"type\":\"agent_message\",\"text\":\"hi\"}}' \ + '{\"type\":\"turn.completed\",\"usage\":{}}'" + .to_string() + } + } + } + + fn changed_session_script(harness: HarnessId) -> String { + match harness { + HarnessId::ClaudeCode => "printf '%s\\n' \ + '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"changed-session\"}'" + .to_string(), + HarnessId::Codex => "printf '%s\\n' \ + '{\"type\":\"thread.started\",\"thread_id\":\"changed-session\"}'" + .to_string(), + } + } + + #[tokio::test] + async fn successful_first_run_without_session_is_fatal_for_both_harnesses() { + for harness in [HarnessId::ClaudeCode, HarnessId::Codex] { + let spec = base_spec(harness); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + success_without_session_script(harness), + ]; + let mut handle = start(argv, &spec).await; + let mut events = Vec::new(); + while let Some(event) = handle.recv().await { + events.push(event); + } + + assert!( + events.iter().any(|event| matches!( + event, + RunEvent::FatalError { message } if message == MISSING_SESSION_ID_ERROR + )), + "{harness:?} must fail instead of creating non-resumable history: {events:?}" + ); + assert!( + !events.iter().any( + |event| matches!(event, RunEvent::Chunk(value) if value["type"] == "finish") + ), + "{harness:?} must not publish a successful finish without a session" + ); + assert_eq!(handle.session_id().await, None); + } + } + + #[tokio::test] + async fn resumed_runs_retain_the_supplied_session_when_cli_does_not_repeat_it() { + for harness in [HarnessId::ClaudeCode, HarnessId::Codex] { + let mut spec = base_spec(harness); + spec.resume_session_id = Some(" durable-session ".to_string()); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + success_without_session_script(harness), + ]; + let mut handle = start(argv, &spec).await; + let mut events = Vec::new(); + while let Some(event) = handle.recv().await { + events.push(event); + } + + assert!( + events.iter().any( + |event| matches!(event, RunEvent::Chunk(value) if value["type"] == "finish") + ), + "{harness:?} resumed run should complete: {events:?}" + ); + assert!( + !events + .iter() + .any(|event| matches!(event, RunEvent::FatalError { .. })), + "{harness:?} resumed run should not require the CLI to repeat its id: {events:?}" + ); + assert_eq!( + handle.session_id().await.as_deref(), + Some("durable-session") + ); + } + } + + #[tokio::test] + async fn resumed_runs_fail_if_either_cli_changes_the_session_id() { + for harness in [HarnessId::ClaudeCode, HarnessId::Codex] { + let mut spec = base_spec(harness); + spec.resume_session_id = Some("durable-session".to_string()); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + changed_session_script(harness), + ]; + let mut handle = start(argv, &spec).await; + let mut events = Vec::new(); + while let Some(event) = handle.recv().await { + events.push(event); + } + + assert!( + events.iter().any(|event| matches!( + event, + RunEvent::FatalError { message } if message == CHANGED_SESSION_ID_ERROR + )), + "{harness:?} must reject a split session: {events:?}" + ); + assert_eq!( + handle.session_id().await.as_deref(), + Some("durable-session"), + "the durable resume id must not be replaced by the fork" + ); + } + } + + #[tokio::test] + async fn mapper_emitted_whitespace_session_id_is_fatal() { + let spec = base_spec(HarnessId::ClaudeCode); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf '%s\\n' \ + '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\" \"}'" + .to_string(), + ]; + let mut handle = start(argv, &spec).await; + let mut events = Vec::new(); + while let Some(event) = handle.recv().await { + events.push(event); + } + + assert!(events.iter().any(|event| matches!( + event, + RunEvent::FatalError { message } if message == EMPTY_SESSION_ID_ERROR + ))); + assert_eq!(handle.session_id().await, None); + } + + #[tokio::test] + async fn mapper_session_id_is_trimmed_before_resume_consistency_check() { + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.resume_session_id = Some(" durable-session ".to_string()); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf '%s\\n' \ + '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\" durable-session \"}' \ + '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"ok\",\"session_id\":\" durable-session \"}'" + .to_string(), + ]; + let mut handle = start(argv, &spec).await; + let mut events = Vec::new(); + while let Some(event) = handle.recv().await { + events.push(event); + } + + assert!(!events + .iter() + .any(|event| matches!(event, RunEvent::FatalError { .. }))); + assert!(events + .iter() + .any(|event| matches!(event, RunEvent::Chunk(value) if value["type"] == "finish"))); + assert_eq!( + handle.session_id().await.as_deref(), + Some("durable-session") + ); + } + + #[tokio::test] + async fn drive_end_to_end_yields_chunks_then_closes_with_no_fatal_error() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + claude_success_script(), + ]); + let (tx, mut rx) = mpsc::channel::(64); + let cancelled = Arc::new(AtomicBool::new(false)); + let process_slot = Arc::new(Mutex::new(None)); + let session_id = Arc::new(Mutex::new(None)); + drive( + req, + HarnessId::ClaudeCode, + tx, + cancelled, + process_slot, + session_id.clone(), + ) + .await; + + let mut events = Vec::new(); + while let Some(ev) = rx.recv().await { + events.push(ev); + } + assert!(events + .iter() + .any(|e| matches!(e, RunEvent::Chunk(v) if v["type"] == "text-delta"))); + assert!(events + .iter() + .any(|e| matches!(e, RunEvent::Chunk(v) if v["type"] == "finish"))); + assert!(!events + .iter() + .any(|e| matches!(e, RunEvent::FatalError { .. }))); + assert_eq!(session_id.lock().await.as_deref(), Some("s1")); + } + + #[tokio::test] + async fn drive_synthesizes_a_fatal_error_on_unexpected_nonzero_exit() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo 'not json' 1>&2; exit 3".to_string(), + ]); + let (tx, mut rx) = mpsc::channel::(64); + let cancelled = Arc::new(AtomicBool::new(false)); + let process_slot = Arc::new(Mutex::new(None)); + let session_id = Arc::new(Mutex::new(None)); + drive( + req, + HarnessId::Codex, + tx, + cancelled, + process_slot, + session_id, + ) + .await; + + let mut events = Vec::new(); + while let Some(ev) = rx.recv().await { + events.push(ev); + } + assert_eq!(events.len(), 1); + let RunEvent::FatalError { message } = &events[0] else { + panic!("expected a FatalError") + }; + assert!(message.contains("codex")); + assert!(message.contains('3')); + assert!(message.contains("not json")); + } + + #[tokio::test] + async fn drive_suppresses_the_synthetic_error_when_cancelled() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 0.2; exit 1".to_string(), + ]); + let (tx, mut rx) = mpsc::channel::(64); + let cancelled = Arc::new(AtomicBool::new(true)); // pre-cancelled + let process_slot = Arc::new(Mutex::new(None)); + let session_id = Arc::new(Mutex::new(None)); + drive( + req, + HarnessId::Codex, + tx, + cancelled, + process_slot, + session_id, + ) + .await; + + let mut events = Vec::new(); + while let Some(ev) = rx.recv().await { + events.push(ev); + } + assert!( + events.is_empty(), + "a cancelled run's nonzero exit must not synthesize a FatalError" + ); + } + + #[tokio::test] + async fn drive_flushes_partial_text_on_clean_eof() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf '%s\\n' \ + '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"partial-session\"}' \ + '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}}' \ + '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}}'" + .to_string(), + ]); + let (tx, mut rx) = mpsc::channel::(64); + drive( + req, + HarnessId::ClaudeCode, + tx, + Arc::new(AtomicBool::new(false)), + Arc::new(Mutex::new(None)), + Arc::new(Mutex::new(None)), + ) + .await; + + let mut chunk_types = Vec::new(); + while let Some(event) = rx.recv().await { + match event { + RunEvent::SessionId { .. } => {} + RunEvent::Chunk(value) => { + chunk_types.push(value["type"].as_str().unwrap_or("").to_string()) + } + RunEvent::FatalError { message } => panic!("unexpected fatal error: {message}"), + } + } + assert_eq!( + chunk_types, + vec!["start", "text-start", "text-delta", "text-end"] + ); + } + + #[tokio::test] + async fn drive_flushes_partial_tool_as_error_before_nonzero_exit_error() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf '%s\\n' \ + '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_cut\",\"name\":\"Bash\",\"input\":{}}}}' \ + '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"command\\\":\"}}}'; \ + printf 'process failed\\n' >&2; exit 3" + .to_string(), + ]); + let (tx, mut rx) = mpsc::channel::(64); + drive( + req, + HarnessId::ClaudeCode, + tx, + Arc::new(AtomicBool::new(false)), + Arc::new(Mutex::new(None)), + Arc::new(Mutex::new(None)), + ) + .await; + + let mut events = Vec::new(); + while let Some(event) = rx.recv().await { + events.push(event); + } + assert!(matches!( + events.get(events.len().saturating_sub(2)), + Some(RunEvent::Chunk(value)) if value["type"] == "tool-input-error" + && value["toolCallId"] == "toolu_cut" + )); + assert!(matches!( + events.last(), + Some(RunEvent::FatalError { message }) if message.contains("process failed") + )); + assert!(!events.iter().any( + |event| matches!(event, RunEvent::Chunk(value) if value["type"] == "tool-input-available") + )); + } + + #[tokio::test] + async fn claude_retries_without_partial_flag_only_after_argument_parser_rejection() { + let temp = tempfile::tempdir().expect("tempdir"); + let attempts = temp.path().join("attempts"); + let script = format!( + "count=$(cat '{}' 2>/dev/null || printf 0); count=$((count + 1)); printf '%s' \"$count\" > '{}'; \ + case \" $* \" in \ + *' --include-partial-messages '*) printf \"error: unknown option '--include-partial-messages'\\n\" >&2; exit 1 ;; \ + esac; \ + {}", + attempts.display(), + attempts.display(), + claude_success_script() + ); + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + script, + "stub-claude".to_string(), + "--include-partial-messages".to_string(), + ]); + let (tx, mut rx) = mpsc::channel::(64); + drive( + req, + HarnessId::ClaudeCode, + tx, + Arc::new(AtomicBool::new(false)), + Arc::new(Mutex::new(None)), + Arc::new(Mutex::new(None)), + ) + .await; + + let mut events = Vec::new(); + while let Some(event) = rx.recv().await { + events.push(event); + } + assert_eq!(std::fs::read_to_string(&attempts).unwrap(), "2"); + assert!(events + .iter() + .any(|event| matches!(event, RunEvent::Chunk(value) if value["type"] == "finish"))); + assert!(!events + .iter() + .any(|event| matches!(event, RunEvent::FatalError { .. }))); + } + + #[tokio::test] + async fn claude_never_retries_unknown_flag_text_after_any_stdout() { + let temp = tempfile::tempdir().expect("tempdir"); + let attempts = temp.path().join("attempts"); + let script = format!( + "count=$(cat '{}' 2>/dev/null || printf 0); count=$((count + 1)); printf '%s' \"$count\" > '{}'; \ + printf '%s\\n' '{{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"already-started\"}}'; \ + printf \"error: unknown option '--include-partial-messages'\\n\" >&2; exit 1", + attempts.display(), + attempts.display(), + ); + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + script, + "stub-claude".to_string(), + "--include-partial-messages".to_string(), + ]); + let (tx, mut rx) = mpsc::channel::(64); + drive( + req, + HarnessId::ClaudeCode, + tx, + Arc::new(AtomicBool::new(false)), + Arc::new(Mutex::new(None)), + Arc::new(Mutex::new(None)), + ) + .await; + + let mut events = Vec::new(); + while let Some(event) = rx.recv().await { + events.push(event); + } + assert_eq!(std::fs::read_to_string(&attempts).unwrap(), "1"); + assert!(events + .iter() + .any(|event| matches!(event, RunEvent::FatalError { .. }))); + } + + #[tokio::test] + async fn cancelling_a_partial_reasoning_stream_emits_reasoning_end() { + let spec = base_spec(HarnessId::ClaudeCode); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf '%s\\n' \ + '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}}' \ + '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"working\"}}}'; \ + sleep 30" + .to_string(), + ]; + let mut handle = start(argv, &spec).await; + let first = tokio::time::timeout(std::time::Duration::from_secs(5), handle.recv()) + .await + .expect("partial reasoning must start") + .expect("stream must remain open"); + assert!(matches!( + first, + RunEvent::Chunk(value) if value["type"] == "reasoning-start" + )); + + handle.cancel().await; + let mut events = Vec::new(); + while let Some(event) = handle.recv().await { + events.push(event); + } + assert!(events.iter().any( + |event| matches!(event, RunEvent::Chunk(value) if value["type"] == "reasoning-end") + )); + assert!(!events + .iter() + .any(|event| matches!(event, RunEvent::FatalError { .. }))); + } + + #[tokio::test] + async fn run_handle_cancel_terminates_the_process_and_closes_the_stream() { + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.prompt = "irrelevant".to_string(); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30".to_string(), + ]; + let mut handle = start(argv, &spec).await; + // Give the process a moment to actually spawn and register its pid. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + handle.cancel().await; + let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while let Some(_ev) = handle.recv().await {} + }) + .await; + assert!( + outcome.is_ok(), + "cancel() must terminate the process so the event stream closes promptly" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn run_handle_cancel_escalates_to_kill_for_a_term_resistant_group() { + let mut spec = base_spec(HarnessId::ClaudeCode); + spec.prompt = "irrelevant".to_string(); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + // Install the handler before announcing readiness so the test + // cannot accidentally signal the default disposition window. + "trap '' TERM; printf '%s\\n' '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"term-resistant\"}'; while :; do sleep 30; done".to_string(), + ]; + let mut handle = start(argv, &spec).await; + tokio::time::timeout(std::time::Duration::from_secs(5), handle.recv()) + .await + .expect("TERM-resistant child must announce readiness") + .expect("TERM-resistant child exited before cancellation"); + + handle.cancel().await; + let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while let Some(_event) = handle.recv().await {} + }) + .await; + assert!( + outcome.is_ok(), + "cancel() must hard-kill a process group that ignores SIGTERM" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn dropping_run_handle_reaps_term_resistant_child_and_descendant() { + let temp = tempfile::tempdir().expect("tempdir"); + let pids_path = temp.path().join("pids"); + let script = format!( + "trap '' TERM; /bin/sh -c 'trap \"\" TERM; while :; do sleep 30; done' & descendant=$!; printf '%s %s\\n' \"$$\" \"$descendant\" > '{}'; printf '%s\\n' '{{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"drop-owner\"}}'; while :; do sleep 30; done", + pids_path.display() + ); + let spec = base_spec(HarnessId::ClaudeCode); + let mut handle = start(vec!["/bin/sh".to_string(), "-c".to_string(), script], &spec).await; + tokio::time::timeout(std::time::Duration::from_secs(5), handle.recv()) + .await + .expect("child must announce readiness") + .expect("child exited before drop"); + let pids = wait_for_file(&pids_path).await; + let mut pids = pids.split_whitespace().map(|pid| { + pid.parse::() + .unwrap_or_else(|_| panic!("invalid child pid {pid:?}")) + }); + let child_pid = pids.next().expect("direct child pid"); + let descendant_pid = pids.next().expect("descendant pid"); + + drop(handle); + + wait_for_pid_exit(child_pid).await; + wait_for_pid_exit(descendant_pid).await; + } + + #[cfg(unix)] + #[tokio::test] + async fn aborting_cancel_future_does_not_abort_hard_kill_escalation() { + let temp = tempfile::tempdir().expect("tempdir"); + let term_path = temp.path().join("term-received"); + let script = format!( + "trap 'printf term > \"{}\"' TERM; printf '%s\\n' '{{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"cancel-abort\"}}'; while :; do sleep 30; done", + term_path.display() + ); + let spec = base_spec(HarnessId::ClaudeCode); + let mut handle = start(vec!["/bin/sh".to_string(), "-c".to_string(), script], &spec).await; + tokio::time::timeout(std::time::Duration::from_secs(5), handle.recv()) + .await + .expect("child must announce readiness") + .expect("child exited before cancellation"); + + let cancel = handle.cancel_handle(); + let cancel_task = tokio::spawn(async move { cancel.cancel().await }); + wait_for_file(&term_path).await; + cancel_task.abort(); + + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while handle.recv().await.is_some() {} + }) + .await + .expect("detached escalation must kill the TERM-resistant child"); + } + + #[tokio::test] + async fn cancel_handle_reaches_the_same_run_as_the_owning_run_handle() { + let spec = base_spec(HarnessId::ClaudeCode); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30".to_string(), + ]; + let mut handle = start(argv, &spec).await; + let cancel_handle = handle.cancel_handle(); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + // Cancel via the DETACHED handle, not `handle` itself — mirrors + // `local-api`'s dispatch route storing a `CancelHandle` in a + // shared registry while a separate task drains `handle.recv()`. + cancel_handle.cancel().await; + let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while let Some(_ev) = handle.recv().await {} + }) + .await; + assert!( + outcome.is_ok(), + "a cancel via the detached CancelHandle must still terminate the run" + ); + } + + /// Regression test for the exact bug + /// `apps/native/e2e/dispatch-stream.e2e.test.ts`'s `hang` scenario + /// caught: `cancel()` called with NO grace period at all — i.e. in + /// the window where `drive()`'s own `spawn::spawn(req).await` may not + /// have completed yet, so `process_slot` can still be `None` when + /// `cancel()` runs. Before the `drive()`-side re-check this test + /// exercises, this was flaky-to-reliably-failing (the process would + /// spawn AFTER `cancel()` already gave up, then run forever); it must + /// now deterministically terminate regardless of scheduling luck. + #[tokio::test] + async fn cancel_called_with_zero_grace_period_still_kills_the_process() { + let spec = base_spec(HarnessId::ClaudeCode); + let argv = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30".to_string(), + ]; + let mut handle = start(argv, &spec).await; + // NO sleep here — cancel as fast as this task can possibly run, + // maximizing the odds of landing before `drive()`'s spawn + // completes (exactly what a client cancelling the instant it + // observes the harness's very first output line does in + // practice). + handle.cancel().await; + let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), async { + while let Some(_ev) = handle.recv().await {} + }) + .await; + assert!( + outcome.is_ok(), + "a zero-grace-period cancel must still terminate the process once it spawns" + ); + } +} diff --git a/apps/native/crates/harness/src/spawn.rs b/apps/native/crates/harness/src/spawn.rs new file mode 100644 index 0000000000..adcf2b2aa8 --- /dev/null +++ b/apps/native/crates/harness/src/spawn.rs @@ -0,0 +1,1156 @@ +//! The one process-spawn abstraction — PTY (via `portable-pty`) OR plain +//! pipes behind a single [`spawn`] entry point, mirroring +//! `packages/sandbox/daemon/process/pty-spawn.ts`'s two-tier contract +//! (`spawnPty` + its `spawnFallback`) in intent: `TERM=xterm-256color`, +//! 128+signal shell-convention exit codes, 3× `openpty`/`forkpty` retry +//! with backoff before falling back to plain pipes on PTY-allocation +//! failure (keyed off the failure itself, not an OS-version-specific error +//! string — see the native implementation contract section 4's "Fallback design +//! that must be preserved"). +//! +//! ## Why harness dispatch does NOT use the PTY path (investigated + decided) +//! +//! The Phase 2 TODO explicitly asks this to be investigated and documented, +//! not just asserted. Evidence, gathered by actually running both CLIs on +//! this machine (claude 2.1.214, codex-cli 0.144.5, 2026-07-18): +//! +//! - `claude -p --output-format stream-json --verbose` and +//! `codex exec --json` were both run with stdout/stderr redirected to a +//! plain file (i.e. `isatty(stdout) == false`, no PTY at all) and BOTH +//! produced clean, complete, well-formed ndjson with no missing frames, +//! no ANSI escape codes, and no behavioral difference from a TTY-backed +//! run. `-p`/`--print` (claude) and `exec` (codex) are each CLI's +//! explicit non-interactive/headless mode — by design, not by accident, +//! these do not require a controlling terminal. +//! - A PTY, if used, would actively HURT this use case: `events::claude` +//! and `events::codex` parse each line of stdout as one JSON object. +//! PTY line-discipline (canonical mode line editing, `\r\n` translation, +//! OS-level 4096-byte-line buffering under some ptys) and any +//! TTY-triggered color/progress-bar ANSI output the CLI might emit ARE +//! TTY-mode side effects specifically — no evidence either CLI emits +//! them in `-p`/`exec` mode, but a PTY would risk introducing exactly +//! the corruption this crate's ndjson parser has no reason to defend +//! against if plain pipes are used. +//! - A concrete failure mode observed empirically: `codex exec` reads +//! stdin as an "append to the prompt" channel when stdin is a pipe +//! (`Reading additional input from stdin...`) and hangs waiting for EOF +//! if it never arrives — this crate's plain-pipe path closes stdin +//! (`Stdio::null()`) specifically to prevent that hang. A PTY's stdin +//! is inherently a terminal-like fd (never naturally EOF-closed by the +//! spawning side the way a piped `Stdio::null()` is), which would make +//! this specific, already-observed hazard WORSE, not better. +//! +//! **Decision**: `run.rs` always spawns with `pty: false` (plain pipes). +//! The PTY half of this module exists, is exercised by this module's own +//! tests, and remains available for any FUTURE harness/CLI that genuinely +//! requires a controlling terminal (the Phase 2 TODO's explicit ask: "one +//! spawn abstraction" covering both, not a PTY-only or plain-only module). + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use tokio::sync::{mpsc, oneshot, Mutex, Notify}; + +/// One request to spawn a child process. `Clone` so a caller can retain a +/// copy across a PTY→plain-pipe fallback without re-building it. +#[derive(Debug, Clone)] +pub struct SpawnRequest { + /// `argv[0]` is the program; the rest are its arguments. + pub argv: Vec, + pub cwd: Option, + /// Extra/override environment variables, merged over the inherited + /// process environment (and `TERM=xterm-256color`, always set). + pub env: Vec<(String, String)>, + /// Path to local-api's crash/relaunch ownership fence. The production + /// plain-pipe path opens this file with a shared advisory lock and passes + /// that locked file into the independent watchdog as an exec-inherited + /// descriptor before the CLI is spawned. A replacement local-api takes an + /// exclusive lock before recovering durable queue state, so it cannot + /// promote a successor while an old crash watchdog still owns descendants. + /// + /// `None` is reserved for harness-crate unit tests and non-local-api users; + /// both native dispatch call sites always provide the app-root path. + pub lifetime_lock_path: Option, + /// `true` requests the PTY path (with plain-pipe fallback on + /// allocation failure); `false` goes straight to plain pipes. See this + /// module's doc comment for why harness dispatch always passes + /// `false`. + pub pty: bool, +} + +impl SpawnRequest { + pub fn new(argv: Vec) -> Self { + Self { + argv, + cwd: None, + env: Vec::new(), + lifetime_lock_path: None, + pty: false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Signal { + Term, + Kill, +} + +/// Shell-convention exit info: `code` is the raw exit code for a normal +/// exit, or `128 + signal` for a signal-terminated process (POSIX shell +/// convention — byte-parity in SPIRIT with `pty-spawn.ts::shellExitCode`, +/// though Rust's `std::os::unix::process::ExitStatusExt::signal()` makes +/// the actual mapping far simpler than the TS side's signal-NAME-to-number +/// table: `ExitStatus::signal()` already returns the numeric signal, no +/// name/number lookup table needed). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExitInfo { + pub code: i32, +} + +#[derive(Debug)] +pub enum SpawnError { + Io(std::io::Error), + /// PTY allocation/spawn failed (after retries, if the PTY path was + /// requested) — carries the last error's message. + Pty(String), + /// The spawned child never reported a pid (should not happen in + /// practice; guarded defensively rather than unwrapped). + NoPid, +} + +impl std::fmt::Display for SpawnError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SpawnError::Io(e) => write!(f, "spawn failed: {e}"), + SpawnError::Pty(msg) => write!(f, "PTY spawn failed: {msg}"), + SpawnError::NoPid => write!(f, "spawned child reported no pid"), + } + } +} + +impl std::error::Error for SpawnError {} + +/// A running (or just-exited) child process. `stdout`/`stderr` are +/// SEPARATE channels always (even in PTY mode, where `stderr` simply never +/// produces anything — the OS merges both into the one PTY stream, routed +/// here as `stdout`) — unlike `pty-spawn.ts`'s merged single stream, this +/// crate's consumer (`run.rs`) needs stdout to carry ONLY the harness's +/// ndjson, uncontaminated by stderr diagnostics. +pub struct SpawnedProcess { + pub pid: u32, + pub stdout: mpsc::Receiver, + pub stderr: mpsc::Receiver, + /// Resolves exactly once, when the child exits. + pub exit: oneshot::Receiver, + /// Live ownership of the process group. On the plain-pipe path used by + /// harness dispatch, the independent group anchor stays alive until this + /// becomes inert, so stale cancellation cannot target a reused pgid. + pub(crate) lease: ProcessLease, + /// Ownership signal for the detached plain-pipe waiter that holds the + /// actual child. Dropping the consumer closes this sender; the waiter then + /// performs the same bounded process-group teardown as explicit cancel. + pub(crate) owner: ProcessOwner, +} + +impl SpawnedProcess { + /// Signals the child's WHOLE PROCESS GROUP (not just the direct + /// child) — see [`kill_pid`]'s doc comment for the mechanism and why. + pub async fn kill(&self, signal: Signal) { + self.lease.signal(signal).await; + } +} + +/// A process-group id paired with its in-process ownership lifetime. +/// +/// Unix's `kill(2)` API only accepts a numeric pid/pgid, so retaining that +/// number after the child has been reaped risks signaling an unrelated process +/// after pid reuse. Runtime cancellation therefore carries this lease instead. +/// The production plain-pipe path couples it to an independent group anchor; +/// PTY is a completeness-only utility and does not claim that stronger anchor +/// guarantee (harness dispatch always sets `pty: false`). +#[derive(Clone)] +pub(crate) struct ProcessLease { + inner: Arc, +} + +struct ProcessLeaseInner { + group_id: u32, + /// The independent watchdog is the group leader on the plain path. It + /// must never receive SIGKILL with the rest of the group: it owns the + /// inherited shared lifetime fence and releases it only after proving + /// every non-anchor group member is gone. PTY has no such anchor. + anchor_id: Option, + alive: AtomicBool, + exited: Notify, + /// Serializes every numeric-pgid signal with the final group-anchor reap. + /// Without this gate, a signal task could observe `alive`, get descheduled, + /// then resume after the watchdog was reaped and target a reused pgid. + signal_gate: Mutex<()>, + termination_started: AtomicBool, + termination_finished: AtomicBool, + termination_done: Notify, +} + +impl ProcessLease { + fn new(group_id: u32, anchor_id: Option) -> Self { + Self { + inner: Arc::new(ProcessLeaseInner { + group_id, + anchor_id, + alive: AtomicBool::new(true), + exited: Notify::new(), + signal_gate: Mutex::new(()), + termination_started: AtomicBool::new(false), + termination_finished: AtomicBool::new(false), + termination_done: Notify::new(), + }), + } + } + + pub(crate) fn is_alive(&self) -> bool { + self.inner.alive.load(Ordering::SeqCst) + } + + fn mark_exited(&self) { + if self.inner.alive.swap(false, Ordering::SeqCst) { + self.inner.exited.notify_waiters(); + } + } + + /// PTY's waiter is a blocking OS thread. It uses the same signal gate so a + /// signal already in flight finishes before the lease becomes inert. + fn mark_exited_blocking(&self) { + let _signal_guard = self.inner.signal_gate.blocking_lock(); + self.mark_exited(); + } + + pub(crate) async fn signal(&self, signal: Signal) { + let _signal_guard = self.inner.signal_gate.lock().await; + if self.is_alive() { + if let Some(anchor_id) = self.inner.anchor_id { + signal_anchored_group_members(self.inner.group_id, anchor_id, signal).await; + } else { + kill_pid(self.inner.group_id, signal).await; + } + } + } + + /// Cooperative cancellation with a bounded hard-kill fallback. The + /// watchdog remains the process-group anchor while the CLI is alive, so + /// the pgid cannot disappear/rebind during this lease. A stale clone is + /// inert after `mark_exited`. Escalation runs in its own once-only task: + /// dropping the caller's future after TERM cannot strand a resistant CLI. + pub(crate) async fn terminate(&self) { + if !self.is_alive() { + return; + } + + let done = self.inner.termination_done.notified(); + tokio::pin!(done); + done.as_mut().enable(); + + if !self.inner.termination_started.swap(true, Ordering::SeqCst) { + let lease = self.clone(); + tokio::spawn(async move { + lease.signal(Signal::Term).await; + if lease.is_alive() { + let exited = lease.inner.exited.notified(); + tokio::pin!(exited); + exited.as_mut().enable(); + if lease.is_alive() { + tokio::select! { + _ = &mut exited => {}, + _ = tokio::time::sleep(Duration::from_secs(1)) => { + lease.signal(Signal::Kill).await; + } + } + } + } + lease + .inner + .termination_finished + .store(true, Ordering::SeqCst); + lease.inner.termination_done.notify_waiters(); + }); + } + + if !self.inner.termination_finished.load(Ordering::SeqCst) { + done.await; + } + } + + /// Final cleanup for the plain-pipe path. Closing the liveness writer asks + /// the watchdog to TERM→KILL every NON-anchor member and keep its shared + /// lifetime fence until `pgrep` proves the group empty. Only then does the + /// watchdog exit and become reapable. Killing the anchor ourselves would + /// release the restart fence before descendants were known gone. + #[cfg(unix)] + async fn finish_plain_group( + &self, + watchdog: &mut tokio::process::Child, + parent_liveness: tokio::process::ChildStdin, + ) { + let _signal_guard = self.inner.signal_gate.lock().await; + drop(parent_liveness); + match watchdog.wait().await { + Ok(_) => self.mark_exited(), + Err(error) => { + // Fail closed. The watchdog may still own the shared lifetime + // fence; pretending cleanup finished would let queue recovery + // race an indeterminate old process tree. + tracing::error!(%error, "could not prove harness watchdog exit"); + std::future::pending::<()>().await; + } + } + } +} + +/// The waiter owns the OS child; the stream driver owns this sender. That +/// explicit edge makes dropping/aborting a driver a teardown event instead of +/// silently detaching the waiter and its child process. +pub(crate) struct ProcessOwner { + teardown: Option>, +} + +impl ProcessOwner { + fn new(teardown: oneshot::Sender<()>) -> Self { + Self { + teardown: Some(teardown), + } + } + + fn inert() -> Self { + Self { teardown: None } + } +} + +impl Drop for ProcessOwner { + fn drop(&mut self) { + if let Some(teardown) = self.teardown.take() { + let _ = teardown.send(()); + } + } +} + +/// Signals process GROUP `pid` (not just the single process) — see +/// `Cargo.toml`'s doc comment on why this shells out to `kill(1)` instead +/// of an `unsafe` `libc::kill` FFI call. The caller passes an owned process +/// GROUP id: plain harnesses use the parent-liveness watchdog's pid, while +/// PTY children use `portable-pty`'s session/group-leading pid. `-` +/// reaches the harness CLI's own child processes too (e.g. a Bash tool call), +/// not just the direct CLI. Plain-pipe runtime callers retain a +/// watchdog-anchored [`ProcessLease`], never this bare number, so pid reuse +/// cannot turn a stale cancel into a signal for an unrelated process. +#[cfg(unix)] +pub async fn kill_pid(pid: u32, signal: Signal) { + let flag = match signal { + Signal::Term => "-TERM", + Signal::Kill => "-KILL", + }; + let pgid = format!("-{pid}"); + let _ = tokio::process::Command::new("kill") + .arg(flag) + .arg(pgid) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await; +} + +/// Signals each current member of an anchored process group except the +/// watchdog itself. `kill -KILL -` is deliberately forbidden here: it +/// would kill the watchdog (the group leader), close its inherited shared +/// lifetime-lock descriptor, and let a replacement server recover SQLite while +/// a resistant descendant was still exiting. +/// +/// `pgrep -g .` is portable across the macOS/BSD and Linux variants in +/// scope. The explicit `.` pattern matters on macOS, where `pgrep` requires a +/// pattern; unlike a `pgrep` launched by the watchdog itself, this caller is +/// not an ancestor of the group and therefore sees the anchor too, which is why +/// it is filtered explicitly. An enumeration error fails closed by sending +/// nothing: the watchdog's independent EOF path remains responsible for the +/// repeated, proven teardown. +#[cfg(unix)] +async fn signal_anchored_group_members(group_id: u32, anchor_id: u32, signal: Signal) { + let output = match tokio::process::Command::new("pgrep") + .arg("-g") + .arg(group_id.to_string()) + .arg(".") + .stdin(std::process::Stdio::null()) + .output() + .await + { + Ok(output) => output, + Err(error) => { + tracing::error!(%error, group_id, "could not enumerate anchored harness group"); + return; + } + }; + if !output.status.success() { + // pgrep exit 1 means no match. The anchor should remain present while + // this lease is alive, but either way there is nothing safe to signal. + if output.status.code() != Some(1) { + tracing::error!( + status = ?output.status.code(), + group_id, + "indeterminate anchored harness group enumeration" + ); + } + return; + } + + let flag = match signal { + Signal::Term => "-TERM", + Signal::Kill => "-KILL", + }; + for pid in String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .filter_map(|raw| raw.parse::().ok()) + .filter(|pid| *pid != anchor_id) + { + // These pids are intentionally ephemeral (enumerate then signal), + // never persisted. The still-live anchor keeps the group id owned, so + // a stale RunHandle cannot later target a recycled process group. + let _ = tokio::process::Command::new("kill") + .arg(flag) + .arg(pid.to_string()) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await; + } +} + +#[cfg(not(unix))] +pub async fn kill_pid(_pid: u32, _signal: Signal) { + // Process-group signaling is a POSIX concept; Windows job objects are + // a different mechanism entirely. Studio ships as a macOS desktop app + // (the desktop migration contract) — out of scope for v1, not silently pretended to work. + tracing::warn!("process-group kill is not implemented on this platform"); +} + +#[cfg(not(unix))] +async fn signal_anchored_group_members(_group_id: u32, _anchor_id: u32, _signal: Signal) { + // The anchored watchdog exists only on Unix. Kept as a compile-time + // counterpart because `ProcessLease::signal` is platform-neutral. +} + +/// Spawn per `req`. Tries PTY first (with retry+backoff) when +/// `req.pty == true`, falling back to plain pipes on allocation failure; +/// otherwise goes straight to plain pipes. +pub async fn spawn(req: SpawnRequest) -> Result { + if req.pty { + let pty_req = req.clone(); + match tokio::task::spawn_blocking(move || pty::spawn_pty_with_retry(&pty_req)).await { + Ok(Ok(built)) => return Ok(pty::finish_async(built)), + Ok(Err(err)) => { + tracing::warn!( + error = %err, + "PTY spawn failed after retries, falling back to plain pipes" + ); + } + Err(join_err) => { + tracing::warn!( + error = %join_err, + "PTY spawn task panicked, falling back to plain pipes" + ); + } + } + } + plain::spawn_plain(req).await +} + +// --------------------------------------------------------------------------- +// Plain-pipe path (the one harness dispatch actually uses) +// --------------------------------------------------------------------------- + +mod plain { + use super::*; + use std::fs::{File, OpenOptions}; + use std::process::Stdio; + + /// Unix-only process-group anchor. Its stdin is a liveness pipe whose + /// writer exists only inside Studio. If Studio is uncatchably terminated + /// (`SIGKILL`, crash), EOF wakes this independent process and it gives the + /// CLI group one second to honor TERM before KILLing every survivor. + /// + /// The watchdog ignores TERM itself so it remains alive to perform the + /// escalation. Crucially, it never group-KILLs itself: on macOS `pgrep` + /// excludes its own ancestors, so `pgrep -g $$ .` enumerates only the + /// sibling CLI and its descendants. The watchdog exits (releasing the + /// inherited shared lifetime fence) only after that enumeration proves no + /// non-anchor process remains. An enumeration error sleeps forever and + /// therefore fails closed instead of allowing unsafe queue recovery. + #[cfg(unix)] + const PARENT_LIVENESS_WATCHDOG: &str = r#" +trap '' TERM +while IFS= read -r _; do :; done + +term_round=0 +while [ "$term_round" -lt 20 ]; do + members="$(pgrep -g "$$" . 2>/dev/null)" + status=$? + if [ "$status" -eq 1 ]; then + exit 0 + fi + if [ "$status" -ne 0 ]; then + while :; do sleep 60; done + fi + found=0 + for pid in $members; do + if [ "$pid" -eq "$$" ]; then continue; fi + found=1 + kill -TERM "$pid" 2>/dev/null || true + done + if [ "$found" -eq 0 ]; then exit 0; fi + term_round=$((term_round + 1)) + sleep 0.05 +done + +while :; do + members="$(pgrep -g "$$" . 2>/dev/null)" + status=$? + if [ "$status" -eq 1 ]; then + exit 0 + fi + if [ "$status" -ne 0 ]; then + while :; do sleep 60; done + fi + found=0 + for pid in $members; do + if [ "$pid" -eq "$$" ]; then continue; fi + found=1 + kill -KILL "$pid" 2>/dev/null || true + done + if [ "$found" -eq 0 ]; then exit 0; fi + sleep 0.05 +done +"#; + + #[cfg(unix)] + fn open_shared_lifetime_lock( + path: Option<&std::path::Path>, + ) -> Result, SpawnError> { + let Some(path) = path else { + return Ok(None); + }; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).mode(0o600); + let file = options.open(path).map_err(SpawnError::Io)?; + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(SpawnError::Io)?; + file.try_lock_shared().map_err(|error| { + SpawnError::Io(match error { + std::fs::TryLockError::Error(error) => error, + std::fs::TryLockError::WouldBlock => std::io::ErrorKind::WouldBlock.into(), + }) + })?; + Ok(Some(file)) + } + + pub async fn spawn_plain(req: SpawnRequest) -> Result { + let SpawnRequest { + argv, + cwd, + env, + lifetime_lock_path, + pty: _, + } = req; + let (program, args) = argv.split_first().ok_or(SpawnError::NoPid)?; + + #[cfg(unix)] + let (mut watchdog, parent_liveness, process_group_id) = { + use std::os::unix::process::CommandExt; + + // Acquire before the watchdog exists and before the CLI spawn can + // happen. Moving this locked File into stdout makes it an + // exec-inherited descriptor owned by the watchdog; Studio keeps + // no duplicate whose close could accidentally release the fence. + let lifetime_lock = open_shared_lifetime_lock(lifetime_lock_path.as_deref())?; + + let mut watchdog_cmd = std::process::Command::new("/bin/sh"); + watchdog_cmd + .arg("-c") + .arg(PARENT_LIVENESS_WATCHDOG) + .arg("decocms-harness-watchdog") + .stdin(Stdio::piped()) + .stdout(lifetime_lock.map(Stdio::from).unwrap_or_else(Stdio::null)) + .stderr(Stdio::null()) + .process_group(0); + let mut watchdog_cmd = tokio::process::Command::from(watchdog_cmd); + // Intentionally FALSE: if the Tokio task/runtime disappears, the + // liveness writer drops and this process must survive long enough + // to TERM→KILL the CLI group. Normal completion explicitly + // KILLs and reaps it below. + watchdog_cmd.kill_on_drop(false); + let mut watchdog = watchdog_cmd.spawn().map_err(SpawnError::Io)?; + let process_group_id = watchdog.id().ok_or(SpawnError::NoPid)?; + let parent_liveness = watchdog.stdin.take().ok_or_else(|| { + SpawnError::Io(std::io::Error::other( + "parent-liveness watchdog stdin was not piped", + )) + })?; + (watchdog, parent_liveness, process_group_id) + }; + + let mut std_cmd = std::process::Command::new(program); + std_cmd.args(args); + if let Some(cwd) = &cwd { + std_cmd.current_dir(cwd); + } + std_cmd.env("TERM", "xterm-256color"); + for (k, v) in &env { + std_cmd.env(k, v); + } + // This is the REAL CLI's stdin, and remains /dev/null even on Unix. + // The separate watchdog owns the parent-liveness pipe; codex can never + // mistake that pipe for additional prompt input. + std_cmd.stdin(Stdio::null()); + std_cmd.stdout(Stdio::piped()); + std_cmd.stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // Join the watchdog's already-live process group. The watchdog is + // the group anchor, so the pgid remains owned and cannot be reused + // until the waiter below reaps it. + let process_group = i32::try_from(process_group_id).map_err(|_| { + SpawnError::Io(std::io::Error::other( + "watchdog pid does not fit a Unix process-group id", + )) + })?; + std_cmd.process_group(process_group); + } + + let mut tokio_cmd = tokio::process::Command::from(std_cmd); + // Last-resort teardown for runtime/app shutdown: if the waiter task + // owning this `Child` is dropped before it can reap the process, + // dropping the child must not leave the harness CLI running detached + // from Studio. Normal cancellation still signals the whole process + // group through `kill_pid`; this guard covers abrupt Tokio-runtime + // teardown of the direct child. + tokio_cmd.kill_on_drop(true); + let mut child = match tokio_cmd.spawn() { + Ok(child) => child, + Err(error) => { + #[cfg(unix)] + { + // The CLI never started. Close the liveness pipe and let + // the watchdog prove its group has no non-anchor members + // before it releases the shared lifetime fence. + drop(parent_liveness); + let _ = watchdog.wait().await; + } + return Err(SpawnError::Io(error)); + } + }; + let pid = child.id().ok_or(SpawnError::NoPid)?; + let stdout = child.stdout.take().expect("stdout was piped"); + let stderr = child.stderr.take().expect("stderr was piped"); + + let (stdout_tx, stdout_rx) = mpsc::channel(256); + let (stderr_tx, stderr_rx) = mpsc::channel(256); + let (exit_tx, exit_rx) = oneshot::channel(); + let (teardown_tx, mut teardown_rx) = oneshot::channel(); + + tokio::spawn(pump(stdout, stdout_tx)); + tokio::spawn(pump(stderr, stderr_tx)); + #[cfg(unix)] + let lease = ProcessLease::new(process_group_id, Some(process_group_id)); + #[cfg(not(unix))] + let lease = ProcessLease::new(pid, None); + let waiter_lease = lease.clone(); + + tokio::spawn(async move { + let status = tokio::select! { + status = child.wait() => status, + _ = &mut teardown_rx => { + #[cfg(unix)] + waiter_lease.terminate().await; + #[cfg(not(unix))] + let _ = child.start_kill(); + child.wait().await + } + }; + #[cfg(unix)] + { + // The direct CLI is reaped, but tools/grandchildren could + // still occupy its group. The watchdog anchor proves the pgid + // is still ours, so a final group KILL is safe and leaves no + // detached descendants. Keep the liveness writer open until + // AFTER the watchdog is gone so it cannot race this cleanup. + waiter_lease + .finish_plain_group(&mut watchdog, parent_liveness) + .await; + } + #[cfg(not(unix))] + waiter_lease.mark_exited(); + let _ = exit_tx.send(exit_info_from(status)); + }); + + Ok(SpawnedProcess { + pid, + stdout: stdout_rx, + stderr: stderr_rx, + exit: exit_rx, + lease, + owner: ProcessOwner::new(teardown_tx), + }) + } + + async fn pump(mut reader: R, tx: mpsc::Sender) { + use tokio::io::AsyncReadExt; + let mut buf = vec![0u8; 8192]; + loop { + match reader.read(&mut buf).await { + Ok(0) => return, + Ok(n) => { + if tx.send(Bytes::copy_from_slice(&buf[..n])).await.is_err() { + return; + } + } + Err(_) => return, + } + } + } + + fn exit_info_from(status: std::io::Result) -> ExitInfo { + match status { + Ok(s) => shell_exit_code(s), + Err(_) => ExitInfo { code: 1 }, + } + } + + #[cfg(unix)] + fn shell_exit_code(status: std::process::ExitStatus) -> ExitInfo { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = status.signal() { + return ExitInfo { code: 128 + sig }; + } + ExitInfo { + code: status.code().unwrap_or(1), + } + } + + #[cfg(not(unix))] + fn shell_exit_code(status: std::process::ExitStatus) -> ExitInfo { + ExitInfo { + code: status.code().unwrap_or(1), + } + } +} + +// --------------------------------------------------------------------------- +// PTY path (implemented for completeness per the Phase 2 TODO's "one spawn +// abstraction" ask; NOT the path harness dispatch takes — see module doc) +// --------------------------------------------------------------------------- + +mod pty { + use super::*; + use portable_pty::{native_pty_system, Child, CommandBuilder, ExitStatus, PtySize}; + use std::io::Read; + + /// 3 attempts, 50ms × attempt-number backoff between them — same + /// numbers as `pty-spawn.ts::spawnPty`'s retry loop. + const MAX_ATTEMPTS: u32 = 3; + + pub struct Built { + pub pid: u32, + pub child: Box, + pub reader: Box, + /// Kept alive for the process's lifetime — dropping the PTY + /// pair's master half can tear down the slave side prematurely on + /// some platforms. + pub _master: Box, + } + + /// BLOCKING — must be called from `tokio::task::spawn_blocking` (both + /// `openpty`/`spawn_command` and the retry loop's backoff sleep are + /// synchronous). + pub fn spawn_pty_with_retry(req: &SpawnRequest) -> Result { + let mut last_err = None; + for attempt in 0..MAX_ATTEMPTS { + match spawn_pty_once(req) { + Ok(built) => return Ok(built), + Err(e) => { + last_err = Some(e); + std::thread::sleep(Duration::from_millis(50 * u64::from(attempt + 1))); + } + } + } + Err(last_err.unwrap_or_else(|| SpawnError::Pty("unknown PTY failure".to_string()))) + } + + fn spawn_pty_once(req: &SpawnRequest) -> Result { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 30, + cols: 120, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| SpawnError::Pty(e.to_string()))?; + + let (program, args) = req + .argv + .split_first() + .ok_or_else(|| SpawnError::Pty("empty argv".to_string()))?; + let mut cmd = CommandBuilder::new(program); + for a in args { + cmd.arg(a); + } + if let Some(cwd) = &req.cwd { + cmd.cwd(cwd); + } + cmd.env("TERM", "xterm-256color"); + for (k, v) in &req.env { + cmd.env(k, v); + } + + let child = pair + .slave + .spawn_command(cmd) + .map_err(|e| SpawnError::Pty(e.to_string()))?; + let pid = child.process_id().unwrap_or(0); + // The slave fd isn't needed once the child has it open. + drop(pair.slave); + let reader = pair + .master + .try_clone_reader() + .map_err(|e| SpawnError::Pty(e.to_string()))?; + + Ok(Built { + pid, + child, + reader, + _master: pair.master, + }) + } + + /// Bridges the blocking `portable_pty::Child`/`Read` API to the same + /// async `SpawnedProcess` shape the plain path returns — a dedicated + /// OS thread per stream (portable-pty's reader/wait calls are + /// blocking; there is no async portable-pty API to await instead). + pub fn finish_async(built: Built) -> SpawnedProcess { + let Built { + pid, + mut child, + mut reader, + _master, + } = built; + + let (stdout_tx, stdout_rx) = mpsc::channel::(256); + std::thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) => return, + Ok(n) => { + if stdout_tx + .blocking_send(Bytes::copy_from_slice(&buf[..n])) + .is_err() + { + return; + } + } + Err(_) => return, + } + } + }); + + // stderr is never populated in PTY mode — the OS already merged + // it into the master fd read above. An immediately-closed channel + // gives `run.rs` a uniform `SpawnedProcess` shape regardless of + // which path was taken. + let (_stderr_tx, stderr_rx) = mpsc::channel::(1); + + let lease = ProcessLease::new(pid, None); + let waiter_lease = lease.clone(); + let (exit_tx, exit_rx) = oneshot::channel(); + std::thread::spawn(move || { + let status = child.wait(); + waiter_lease.mark_exited_blocking(); + let _ = exit_tx.send(exit_info_from_pty(status)); + }); + + SpawnedProcess { + pid, + stdout: stdout_rx, + stderr: stderr_rx, + exit: exit_rx, + lease, + // Harness dispatch always uses plain pipes. PTY remains a fallback + // utility and its blocking portable-pty waiter has no async owner + // channel; explicit `kill` remains available through the lease. + owner: ProcessOwner::inert(), + } + } + + /// Maps `portable_pty::ExitStatus` to the shell-convention + /// `128 + signal` for a signal-terminated child. UNLIKE the plain-pipe + /// path (`std::os::unix::process::ExitStatusExt::signal()`, a clean + /// numeric accessor), `portable_pty::ExitStatus::exit_code()` on a + /// signal-killed child is always just `1` (see 0.9.0's + /// `From` — it discards the real code once + /// a signal is present) — the crate only exposes the signal via + /// `signal() -> Option<&str>`, a human `strsignal(3)`-derived + /// description, NOT a bare number. Verified empirically on THIS + /// platform (macOS 26 / Darwin 25, the v1 ship target per the desktop migration contract): + /// `strsignal` here always suffixes the description with `: ` + /// (`"Terminated: 15"`, `"Killed: 9"`, `"Interrupt: 2"`) — see this + /// module's `pty_spawn_captures_output_and_signal_exit_code` test, + /// which exercises this exact parse against a real spawned+signaled + /// child rather than a canned string. Falls back to `exit_code()` + /// (never panics/errors) if a future platform's `strsignal` output + /// doesn't end in a parseable number — the PTY path isn't + /// harness-dispatch's default anyway (see module doc), so a + /// less-precise code on a hypothetical odd platform is an acceptable + /// degradation, not a correctness blocker. + fn exit_info_from_pty(status: std::io::Result) -> ExitInfo { + match status { + Ok(s) => ExitInfo { + code: signal_number_from_description(s.signal()) + .map(|sig| 128 + sig) + .unwrap_or(s.exit_code() as i32), + }, + Err(_) => ExitInfo { code: 1 }, + } + } + + /// Parses the trailing `: ` off a `strsignal`-derived description + /// (see `exit_info_from_pty`'s doc comment). `None` for `None` input + /// or a description that doesn't end in `: `. + fn signal_number_from_description(description: Option<&str>) -> Option { + let (_, tail) = description?.rsplit_once(':')?; + tail.trim().parse::().ok() + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn parses_macos_strsignal_style_descriptions() { + assert_eq!( + signal_number_from_description(Some("Terminated: 15")), + Some(15) + ); + assert_eq!(signal_number_from_description(Some("Killed: 9")), Some(9)); + assert_eq!( + signal_number_from_description(Some("Interrupt: 2")), + Some(2) + ); + } + + #[test] + fn none_and_unparseable_descriptions_yield_none() { + assert_eq!(signal_number_from_description(None), None); + assert_eq!(signal_number_from_description(Some("Terminated")), None); + assert_eq!(signal_number_from_description(Some("")), None); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn collect_stdout(mut rx: mpsc::Receiver) -> String { + let mut out = Vec::new(); + while let Some(chunk) = rx.recv().await { + out.extend_from_slice(&chunk); + } + String::from_utf8_lossy(&out).into_owned() + } + + #[tokio::test] + async fn plain_spawn_captures_stdout_and_clean_exit() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo hello-plain".to_string(), + ]); + let mut proc = spawn(req).await.expect("spawn should succeed"); + let out = collect_stdout(std::mem::replace(&mut proc.stdout, mpsc::channel(1).1)).await; + assert_eq!(out.trim(), "hello-plain"); + let exit = proc.exit.await.expect("exit info"); + assert_eq!(exit.code, 0); + } + + #[tokio::test] + async fn plain_spawn_keeps_stdout_and_stderr_separate() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo out-line; echo err-line 1>&2".to_string(), + ]); + let mut proc = spawn(req).await.expect("spawn should succeed"); + let stdout = collect_stdout(std::mem::replace(&mut proc.stdout, mpsc::channel(1).1)).await; + let stderr = collect_stdout(std::mem::replace(&mut proc.stderr, mpsc::channel(1).1)).await; + assert_eq!(stdout.trim(), "out-line"); + assert_eq!(stderr.trim(), "err-line"); + } + + #[tokio::test] + async fn plain_spawn_maps_nonzero_exit_code() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "exit 7".to_string(), + ]); + let proc = spawn(req).await.expect("spawn should succeed"); + let exit = proc.exit.await.expect("exit info"); + assert_eq!(exit.code, 7); + } + + #[tokio::test] + async fn plain_spawn_maps_signal_termination_to_128_plus_signal() { + let req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "kill -TERM $$; sleep 5".to_string(), + ]); + let proc = spawn(req).await.expect("spawn should succeed"); + let exit = proc.exit.await.expect("exit info"); + assert_eq!(exit.code, 128 + 15 /* SIGTERM */); + } + + #[tokio::test] + async fn plain_spawn_stdin_is_closed_not_inherited() { + // A `cat` with no args reads stdin until EOF; if stdin were + // inherited (a live pipe/tty) this would hang forever instead of + // exiting immediately — the very hazard this module's doc comment + // documents for codex's `exec` subcommand. + let req = SpawnRequest::new(vec!["/bin/cat".to_string()]); + let proc = spawn(req).await.expect("spawn should succeed"); + let exit = tokio::time::timeout(Duration::from_secs(5), proc.exit) + .await + .expect("must not hang waiting on stdin") + .expect("exit info"); + assert_eq!(exit.code, 0); + } + + #[tokio::test] + async fn kill_reaches_a_process_group_grandchild() { + // The direct child backgrounds a `sleep`, then blocks forever + // itself — killing only the PGID (not just the direct pid) is + // what reaps the grandchild too. We assert on the direct child's + // own exit here (simpler to observe from this process); the + // process-group targeting itself is exercised structurally by + // `process_group(0)` in `plain::spawn_plain` plus this kill call + // both operating on the same value. + let dir = tempfile::tempdir().unwrap(); + let lifetime_lock_path = dir.path().join("child-lifetime.lock"); + let mut req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30 & wait".to_string(), + ]); + req.lifetime_lock_path = Some(lifetime_lock_path.clone()); + let proc = spawn(req).await.expect("spawn should succeed"); + // Give the shell a moment to background the sleep. + tokio::time::sleep(Duration::from_millis(100)).await; + let contender = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&lifetime_lock_path) + .expect("open child lifetime fence"); + assert!(matches!( + contender.try_lock(), + Err(std::fs::TryLockError::WouldBlock) + )); + proc.kill(Signal::Kill).await; + let exit = tokio::time::timeout(Duration::from_secs(5), proc.exit) + .await + .expect("kill must terminate the group promptly") + .expect("exit info"); + assert_eq!(exit.code, 128 + 9 /* SIGKILL */); + let successor = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&lifetime_lock_path) + .expect("open post-reap child lifetime fence"); + successor + .try_lock() + .expect("watchdog releases shared fence only after group reap"); + } + + #[tokio::test] + async fn pty_spawn_captures_output_and_signal_exit_code() { + let mut req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo hello-pty; kill -TERM $$; sleep 5".to_string(), + ]); + req.pty = true; + let mut proc = spawn(req).await.expect("spawn should succeed"); + let out = collect_stdout(std::mem::replace(&mut proc.stdout, mpsc::channel(1).1)).await; + assert!( + out.contains("hello-pty"), + "expected PTY output to contain the echoed line, got {out:?}" + ); + let exit = proc.exit.await.expect("exit info"); + assert_eq!( + exit.code, + 128 + 15, + "portable-pty's exit_code() should already encode 128+signal" + ); + } + + #[tokio::test] + async fn pty_spawn_falls_back_to_plain_pipes_on_an_unresolvable_program() { + // `native_pty_system().openpty()` itself will succeed (opening the + // pty pair doesn't depend on the child program); it's + // `spawn_command` that fails for a program that can't be found — + // exercising the SAME fallback-on-spawn-failure path a genuine + // `openpty` exhaustion would take, without needing to actually + // exhaust the system's PTY table in a unit test. + let mut req = SpawnRequest::new(vec!["/definitely/not/a/real/binary/xyz".to_string()]); + req.pty = true; + // Either path (PTY retried-then-failed-then-plain-fallback, or + // plain directly) surfaces the SAME observable outcome: a spawn + // error, because the plain fallback also can't find the program. + // This asserts the fallback doesn't panic/hang and DOES surface + // an error rather than silently succeeding. + let result = spawn(req).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn env_vars_are_honored() { + let mut req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo \"$MY_VAR\"".to_string(), + ]); + req.env + .push(("MY_VAR".to_string(), "hello-env".to_string())); + let mut proc = spawn(req).await.expect("spawn should succeed"); + let out = collect_stdout(std::mem::replace(&mut proc.stdout, mpsc::channel(1).1)).await; + assert_eq!(out.trim(), "hello-env"); + } + + #[tokio::test] + async fn cwd_is_honored() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("marker.txt"), b"x").unwrap(); + let mut req = SpawnRequest::new(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "ls".to_string(), + ]); + req.cwd = Some(dir.path().to_path_buf()); + let mut proc = spawn(req).await.expect("spawn should succeed"); + let out = collect_stdout(std::mem::replace(&mut proc.stdout, mpsc::channel(1).1)).await; + assert_eq!(out.trim(), "marker.txt"); + } +} diff --git a/apps/native/crates/harness/src/tiers.rs b/apps/native/crates/harness/src/tiers.rs new file mode 100644 index 0000000000..abcd953fa7 --- /dev/null +++ b/apps/native/crates/harness/src/tiers.rs @@ -0,0 +1,209 @@ +//! Model-tier tables — ported VERBATIM (same ids/labels) from +//! `packages/harness/src/{claude-code,codex}/model/agent-tiers.ts` +//! (`CLAUDE_CODE_TIERS` / `CODEX_TIERS`, keyed by `ChatTier = +//! "fast"|"smart"|"thinking"`), plus the composite-id → CLI `--model` flag +//! resolvers ported from +//! `packages/harness/src/{claude-code,codex}/model/index.ts` +//! (`resolveClaudeCodeModelId` / `resolveCodexModelId`). +//! +//! This is the canonical Rust copy — `crates/local-api/src/routes/models.rs` +//! (`GET /models`) and `crates/harness/src/run.rs` (building the CLI's +//! `--model` argument) both read through here so the two can never drift +//! independently the way two hand-duplicated copies eventually would. +//! +//! Cross-language duplication point: this table is hand-transcribed from +//! the TS source above, not generated. If a build-time codegen step +//! (TS → Rust literal) is ever wanted, that's a `Cargo.toml`/build-script +//! change — an interface request against the workspace owner, not +//! something this crate's implementer should add unilaterally (see +//! the native module-ownership contract's "Models / Tiers" section). + +use serde::Serialize; + +use crate::resolve::HarnessId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct ModelTier { + pub id: &'static str, + pub label: &'static str, +} + +/// Order matches `ChatTier` fast → smart → thinking, which is also the +/// order the contract doc's `GET /models` example JSON uses. +pub const CLAUDE_CODE_TIERS: [ModelTier; 3] = [ + ModelTier { + id: "claude-code:haiku", + label: "Haiku 4.5", + }, + ModelTier { + id: "claude-code:sonnet", + label: "Sonnet 5", + }, + ModelTier { + id: "claude-code:opus-1m", + label: "Opus 4.8 1M", + }, +]; + +pub const CODEX_TIERS: [ModelTier; 3] = [ + ModelTier { + id: "codex:gpt-5.6-luna", + label: "GPT-5.6 Luna", + }, + ModelTier { + id: "codex:gpt-5.6-terra", + label: "GPT-5.6 Terra", + }, + ModelTier { + id: "codex:gpt-5.6-sol", + label: "GPT-5.6 Sol", + }, +]; + +/// The static tier catalog for `harness` (fast, smart, thinking order). +pub fn tiers_for(harness: HarnessId) -> &'static [ModelTier; 3] { + match harness { + HarnessId::ClaudeCode => &CLAUDE_CODE_TIERS, + HarnessId::Codex => &CODEX_TIERS, + } +} + +/// Composite id (`"claude-code:sonnet"`) → the claude CLI's `--model` flag +/// value (`"claude-sonnet-5"`). Mirrors `resolveClaudeCodeModelId` +/// (`packages/harness/src/claude-code/model/index.ts`) EXACTLY, including +/// its passthrough-on-unknown fallback: the ai-sdk-provider-claude-code +/// SDK only recognizes the short aliases "opus"/"sonnet"/"haiku"; models +/// without a short alias use the CLI's full model id. +pub fn resolve_claude_model_id(model_id: &str) -> String { + match model_id { + "claude-code:opus" => "opus", + "claude-code:opus-1m" => "opus[1m]", + "claude-code:sonnet" => "claude-sonnet-5", + "claude-code:haiku" => "haiku", + "claude-code:fable" => "claude-fable-5", + other => return other.to_string(), + } + .to_string() +} + +/// Composite id → the codex CLI's `--model` flag value. Mirrors +/// `resolveCodexModelId` (`packages/harness/src/codex/model/index.ts`) +/// EXCEPT that function throws on an unrecognized id; this returns a +/// best-effort fallback (strip the `codex:` prefix) instead. Dispatch's +/// pre-stream gates already validated `models.thinking.id` is *a* string, +/// not that it's a KNOWN one — an unresolvable id here should reach the +/// CLI and let ITS `--model` validation reject it (surfacing as a +/// `harness_crashed` stream error with the CLI's own message), not crash +/// this resolver into a local-api 500. +pub fn resolve_codex_model_id(model_id: &str) -> String { + match model_id { + "codex:gpt-5.6-sol" => "gpt-5.6-sol", + "codex:gpt-5.6-terra" => "gpt-5.6-terra", + "codex:gpt-5.6-luna" => "gpt-5.6-luna", + "codex:gpt-5.5" => "gpt-5.5", + "codex:gpt-5.4" => "gpt-5.4", + "codex:gpt-5.4-mini" => "gpt-5.4-mini", + "codex:gpt-5.3-codex-spark" => "gpt-5.3-codex-spark", + other => return other.strip_prefix("codex:").unwrap_or(other).to_string(), + } + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn harness_order_is_claude_code_then_codex() { + assert_eq!(HarnessId::ALL[0].wire_id(), "claude-code"); + assert_eq!(HarnessId::ALL[1].wire_id(), "codex"); + } + + #[test] + fn every_tier_id_is_prefixed_with_its_harness() { + for harness in HarnessId::ALL { + for tier in tiers_for(harness) { + assert!( + tier.id.starts_with(&format!("{}:", harness.wire_id())), + "{} does not start with {}:", + tier.id, + harness.wire_id() + ); + assert!(!tier.label.is_empty()); + } + } + } + + #[test] + fn claude_model_ids_match_agent_tiers_ts_verbatim() { + assert_eq!(CLAUDE_CODE_TIERS[0].id, "claude-code:haiku"); + assert_eq!(CLAUDE_CODE_TIERS[0].label, "Haiku 4.5"); + assert_eq!(CLAUDE_CODE_TIERS[1].id, "claude-code:sonnet"); + assert_eq!(CLAUDE_CODE_TIERS[1].label, "Sonnet 5"); + assert_eq!(CLAUDE_CODE_TIERS[2].id, "claude-code:opus-1m"); + assert_eq!(CLAUDE_CODE_TIERS[2].label, "Opus 4.8 1M"); + } + + #[test] + fn codex_model_ids_match_agent_tiers_ts_verbatim() { + assert_eq!(CODEX_TIERS[0].id, "codex:gpt-5.6-luna"); + assert_eq!(CODEX_TIERS[0].label, "GPT-5.6 Luna"); + assert_eq!(CODEX_TIERS[1].id, "codex:gpt-5.6-terra"); + assert_eq!(CODEX_TIERS[1].label, "GPT-5.6 Terra"); + assert_eq!(CODEX_TIERS[2].id, "codex:gpt-5.6-sol"); + assert_eq!(CODEX_TIERS[2].label, "GPT-5.6 Sol"); + } + + #[test] + fn resolve_claude_model_id_maps_known_composites() { + assert_eq!(resolve_claude_model_id("claude-code:opus"), "opus"); + assert_eq!(resolve_claude_model_id("claude-code:opus-1m"), "opus[1m]"); + assert_eq!( + resolve_claude_model_id("claude-code:sonnet"), + "claude-sonnet-5" + ); + assert_eq!(resolve_claude_model_id("claude-code:haiku"), "haiku"); + assert_eq!( + resolve_claude_model_id("claude-code:fable"), + "claude-fable-5" + ); + } + + #[test] + fn resolve_claude_model_id_passes_through_unknown_ids() { + assert_eq!( + resolve_claude_model_id("claude-code:some-future-model"), + "claude-code:some-future-model" + ); + } + + #[test] + fn resolve_codex_model_id_maps_known_composites() { + assert_eq!(resolve_codex_model_id("codex:gpt-5.6-sol"), "gpt-5.6-sol"); + assert_eq!( + resolve_codex_model_id("codex:gpt-5.6-terra"), + "gpt-5.6-terra" + ); + assert_eq!(resolve_codex_model_id("codex:gpt-5.6-luna"), "gpt-5.6-luna"); + } + + #[test] + fn resolve_codex_model_id_strips_prefix_for_unknown_ids() { + assert_eq!( + resolve_codex_model_id("codex:some-future-model"), + "some-future-model" + ); + // No prefix at all — passes through verbatim. + assert_eq!(resolve_codex_model_id("bare-model-name"), "bare-model-name"); + } + + #[test] + fn every_tier_composite_id_resolves_to_a_non_empty_cli_model() { + for tier in CLAUDE_CODE_TIERS { + assert!(!resolve_claude_model_id(tier.id).is_empty()); + } + for tier in CODEX_TIERS { + assert!(!resolve_codex_model_id(tier.id).is_empty()); + } + } +} diff --git a/apps/native/crates/local-api/Cargo.toml b/apps/native/crates/local-api/Cargo.toml new file mode 100644 index 0000000000..f1d66d272c --- /dev/null +++ b/apps/native/crates/local-api/Cargo.toml @@ -0,0 +1,73 @@ +[package] +name = "local-api" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lib] +# Phase 3 addition: a thin `start()`/`boot_from_env()` lib surface so the +# Tauri shell (`apps/native/src-tauri/`) can embed this server in-process +# (per the desktop migration contract's in-process decision) instead +# of spawning it as a subprocess. Mechanical extraction from `main.rs` — see +# `src/lib.rs`'s module doc. The `local-api` BINARY (below) is unchanged in +# behavior; it now just calls into this lib for its boot sequence. +name = "local_api" +path = "src/lib.rs" + +[[bin]] +name = "local-api" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +# Phase 2: harness detection/tiers/spawn/stream-translation — see +# `crates/harness`'s own Cargo.toml doc comment for why its PTY dependency +# lives there instead of the shared `[workspace.dependencies]` table. This +# is a path dep (in-workspace crate), not a version to keep in sync. +harness = { path = "../harness" } +upstream = { path = "../upstream" } +axum = { workspace = true } +tokio = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +hyper = { workspace = true } +hyper-util = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +rusqlite = { workspace = true } +reqwest = { workspace = true } +globset = { workspace = true } +walkdir = { workspace = true } +ignore = { workspace = true } +notify = { workspace = true } +regex = { workspace = true } +subtle = { workspace = true } +rand = { workspace = true } +futures = { workspace = true } +futures-util = { workspace = true } +bytes = { workspace = true } +urlencoding = { workspace = true } +uuid = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +async-trait = { workspace = true } +tokio-tungstenite = { workspace = true } +# Crate-local, NOT in the shared workspace table — same "single-consumer dep +# stays local" precedent `crates/upstream/Cargo.toml`'s own `sha2` entry +# documents. Used by `sandbox/manager.rs` to derive the per-handle +# `-` sandbox directory name (prod-parity shape). +sha2 = "0.10" + +[dev-dependencies] +tempfile = { workspace = true } +# Same path dep as the `[dependencies]` entry above, with its `test-support` +# feature enabled — Cargo unifies these into one compiled `upstream` +# instance for test builds, giving `routes/upstream.rs`'s tests access to +# `upstream::tokens::test_support::MemoryTokenStore` (a `#[cfg(test)]`-only +# module in `upstream` otherwise invisible to a downstream crate's own +# tests — see that crate's Cargo.toml `[features] test-support` doc). +upstream = { path = "../upstream", features = ["test-support"] } diff --git a/apps/native/crates/local-api/build.rs b/apps/native/crates/local-api/build.rs new file mode 100644 index 0000000000..52e71f4854 --- /dev/null +++ b/apps/native/crates/local-api/build.rs @@ -0,0 +1,48 @@ +//! Bakes the web bundle's version into the binary as `STUDIO_WEB_VERSION`. +//! +//! The webview compares `GET /api/config`'s `config.version` against its own +//! build-time `__STUDIO_VERSION__` and nags "A new version is ready" when they +//! drift (`apps/web/src/components/version-check-dialog.tsx`). Vite injects +//! that constant from `apps/api/package.json` (see `apps/web/vite.config.ts`'s +//! `define`), and the desktop bundles that same Vite output — so reading the +//! very same file here is what makes the two agree, and lets +//! `routes/upstream.rs` answer the poll with the version actually running +//! inside this app instead of whatever the upstream deployment happens to be +//! serving. +//! +//! Parsed by hand rather than with `serde_json` to keep this crate free of +//! build-dependencies; the field is a plain string in a generated file. + +use std::path::Path; + +fn main() { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); + // apps/native/crates/local-api -> apps/api/package.json + let package_json = Path::new(&manifest_dir).join("../../../api/package.json"); + + println!("cargo:rerun-if-changed={}", package_json.display()); + + let version = std::fs::read_to_string(&package_json) + .ok() + .and_then(|contents| parse_version(&contents)); + + // Fail open: an unreadable/renamed manifest must not break the build. The + // proxy treats an empty value as "don't rewrite" and passes upstream's + // version through unchanged (today's behavior). + println!( + "cargo:rustc-env=STUDIO_WEB_VERSION={}", + version.unwrap_or_default() + ); +} + +/// Pulls the top-level `"version": "…"` string out of a package.json. +fn parse_version(contents: &str) -> Option { + let start = contents.find("\"version\"")?; + let rest = &contents[start + "\"version\"".len()..]; + let colon = rest.find(':')?; + let after_colon = &rest[colon + 1..]; + let open = after_colon.find('"')?; + let value = &after_colon[open + 1..]; + let close = value.find('"')?; + Some(value[..close].to_string()) +} diff --git a/apps/native/crates/local-api/src/auth.rs b/apps/native/crates/local-api/src/auth.rs new file mode 100644 index 0000000000..1bc2dc17e2 --- /dev/null +++ b/apps/native/crates/local-api/src/auth.rs @@ -0,0 +1,101 @@ +//! Bearer-token auth — byte-parity with +//! `packages/sandbox/daemon/auth.ts::requireToken` / +//! `constantTimeEqual` (see `auth.test.ts` for the exact matrix pinned +//! here: matching token accepts, wrong token / no header / non-`Bearer` +//! scheme / empty configured token all reject). +//! +//! Route exemption: unlike the daemon (which leaves `/_sandbox/idle`, +//! `/_sandbox/scripts`, `/_sandbox/events` GETs, and OPTIONS preflight +//! unauthenticated), local-api's contract +//! (the native local-API contract) tightens this to +//! "every route requires the bearer except `GET /health`". `router.rs` +//! enforces that exemption structurally (by never applying this module's +//! middleware to the `/health` route) rather than this module special- +//! casing paths — keeps the auth primitive itself path-agnostic and +//! testable in isolation. + +use axum::http::HeaderMap; +use subtle::ConstantTimeEq; + +use crate::error::ApiError; + +const BEARER_PREFIX: &str = "Bearer "; + +/// Validate `Authorization: Bearer ` against `expected` in constant +/// time. `Ok(())` on match; `Err(ApiError::unauthorized())` otherwise. An +/// empty `expected` NEVER matches — fail closed, never treat an unset +/// server-side token as "auth disabled". +pub fn require_bearer(headers: &HeaderMap, expected: &str) -> Result<(), ApiError> { + let header = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let Some(provided) = header.strip_prefix(BEARER_PREFIX) else { + return Err(ApiError::unauthorized()); + }; + if expected.is_empty() || !constant_time_eq(provided.as_bytes(), expected.as_bytes()) { + return Err(ApiError::unauthorized()); + } + Ok(()) +} + +pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + // `subtle::ConstantTimeEq` already short-circuits safely on length + // mismatch (it compares up to the shorter length and folds the length + // check itself into the constant-time result), but being explicit here + // documents the invariant without depending on that implementation + // detail staying true across a `subtle` upgrade. + if a.len() != b.len() { + return false; + } + a.ct_eq(b).into() +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::{HeaderMap, HeaderValue}; + + const TOKEN: &str = "test-token-32-chars-min-aaaaaaaa"; + + fn headers_with_auth(value: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert( + axum::http::header::AUTHORIZATION, + HeaderValue::from_str(value).unwrap(), + ); + h + } + + #[test] + fn accepts_matching_bearer() { + let headers = headers_with_auth(&format!("Bearer {TOKEN}")); + assert!(require_bearer(&headers, TOKEN).is_ok()); + } + + #[test] + fn rejects_wrong_token() { + let headers = headers_with_auth("Bearer wrong-token"); + let err = require_bearer(&headers, TOKEN).unwrap_err(); + assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); + assert_eq!(err.body["error"], "unauthorized"); + } + + #[test] + fn rejects_missing_header() { + let headers = HeaderMap::new(); + assert!(require_bearer(&headers, TOKEN).is_err()); + } + + #[test] + fn rejects_non_bearer_scheme() { + let headers = headers_with_auth(TOKEN); + assert!(require_bearer(&headers, TOKEN).is_err()); + } + + #[test] + fn empty_expected_token_rejects_everything() { + let headers = headers_with_auth("Bearer "); + assert!(require_bearer(&headers, "").is_err()); + } +} diff --git a/apps/native/crates/local-api/src/client_auth.rs b/apps/native/crates/local-api/src/client_auth.rs new file mode 100644 index 0000000000..9932903158 --- /dev/null +++ b/apps/native/crates/local-api/src/client_auth.rs @@ -0,0 +1,400 @@ +use std::sync::{Arc, Mutex}; + +use axum::http::{header, HeaderMap, Method}; +use rand::RngCore; + +use crate::auth::{constant_time_eq, require_bearer}; +use crate::error::ApiError; + +pub(crate) const LOCAL_SESSION_COOKIE_NAME: &str = "decocms-local-session"; + +#[derive(Clone)] +pub(crate) enum ClientAuth { + Bearer { token: Arc }, + Embedded { session: Arc }, +} + +/// Removes only local-api's control credential, preserving every unrelated +/// application cookie. Call this at the Studio upstream-proxy boundary, never +/// in the shared guard: sandbox routes and the preview proxy carry application +/// Cookie/Authorization end-to-end. +pub(crate) fn remove_local_session_cookie(headers: &mut HeaderMap) { + let retained = headers + .get_all(header::COOKIE) + .iter() + .filter_map(|header| header.to_str().ok()) + .flat_map(|header| header.split(';')) + .map(str::trim) + .filter(|pair| { + pair.split_once('=') + .is_none_or(|(name, _)| name != LOCAL_SESSION_COOKIE_NAME) + }) + .filter(|pair| !pair.is_empty()) + .map(str::to_string) + .collect::>(); + headers.remove(header::COOKIE); + if !retained.is_empty() { + if let Ok(value) = retained.join("; ").parse() { + headers.insert(header::COOKIE, value); + } + } +} + +pub(crate) struct EmbeddedSession { + expected_host: Arc, + control_origin: Arc, + session_token: Arc, + bootstrap_secrets: Mutex>>, +} + +impl ClientAuth { + pub(crate) fn bearer(token: impl Into>) -> Self { + Self::Bearer { + token: token.into(), + } + } + + pub(crate) fn embedded( + expected_host: String, + control_origin: String, + bootstrap_secrets: Vec, + ) -> Result { + validate_control_identity(&expected_host, &control_origin)?; + let mut unique_secrets = Vec::::new(); + for secret in bootstrap_secrets + .into_iter() + .filter(|secret| !secret.is_empty()) + { + if !unique_secrets.iter().any(|existing| existing == &secret) { + unique_secrets.push(secret); + } + } + let bootstrap_secrets = unique_secrets + .into_iter() + .map(String::into_bytes) + .collect::>(); + if bootstrap_secrets.is_empty() { + return Err("embedded auth requires at least one non-empty bootstrap secret".into()); + } + + Ok(Self::Embedded { + session: Arc::new(EmbeddedSession { + expected_host: expected_host.into(), + control_origin: control_origin.into(), + session_token: generate_secret().into(), + bootstrap_secrets: Mutex::new(bootstrap_secrets), + }), + }) + } + + pub(crate) fn is_embedded(&self) -> bool { + matches!(self, Self::Embedded { .. }) + } + + /// The per-launch control credential, for the ONE caller that is not a + /// browser: the `rclone` child that mounts the org filesystem must satisfy + /// the same guard as the webview (see [`crate::sandbox::org_mount`]), and + /// it has no cookie jar to be handed one through. + /// + /// `None` in bearer mode, where callers authenticate with the bearer + /// instead. + pub(crate) fn session_token(&self) -> Option<&str> { + match self { + Self::Embedded { session } => Some(&session.session_token), + _ => None, + } + } + + /// Host is a DNS-rebinding boundary in embedded mode. It deliberately + /// includes the port: debug requests can arrive through Vite while the + /// Rust listener itself is bound to a different loopback port. + pub(crate) fn require_expected_host(&self, headers: &HeaderMap) -> Result<(), ApiError> { + let Self::Embedded { session } = self else { + return Ok(()); + }; + let host = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if constant_time_eq(host.as_bytes(), session.expected_host.as_bytes()) { + Ok(()) + } else { + Err(ApiError::forbidden_origin()) + } + } + + /// SameSite is site-based, not origin-based. Unsafe embedded requests + /// therefore require the exact browser origin in addition to the session + /// cookie. Safe requests may omit Origin, but a present Origin is never + /// allowed to disagree (notably, WebSocket upgrades are GET requests and + /// are not protected by browser CORS). + pub(crate) fn require_unsafe_origin( + &self, + method: &Method, + headers: &HeaderMap, + ) -> Result<(), ApiError> { + let Self::Embedded { session } = self else { + return Ok(()); + }; + let safe = matches!( + *method, + Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE + ); + let origin = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()); + if (safe && origin.is_none()) + || origin.is_some_and(|origin| { + constant_time_eq(origin.as_bytes(), session.control_origin.as_bytes()) + }) + { + Ok(()) + } else { + Err(ApiError::forbidden_origin()) + } + } + + pub(crate) fn require_exact_origin(&self, headers: &HeaderMap) -> Result<(), ApiError> { + let Self::Embedded { session } = self else { + return Ok(()); + }; + let origin = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if constant_time_eq(origin.as_bytes(), session.control_origin.as_bytes()) { + Ok(()) + } else { + Err(ApiError::forbidden_origin()) + } + } + + pub(crate) fn require_private(&self, headers: &HeaderMap) -> Result<(), ApiError> { + match self { + Self::Bearer { token } => require_bearer(headers, token), + Self::Embedded { session } => { + let matches = cookie_values(headers, LOCAL_SESSION_COOKIE_NAME).any(|value| { + constant_time_eq(value.as_bytes(), session.session_token.as_bytes()) + }); + if matches { + Ok(()) + } else { + Err(ApiError::unauthorized()) + } + } + } + } + + /// Atomically consumes one matching bootstrap bearer. A failed attempt + /// never burns another window's capability. + pub(crate) fn consume_bootstrap(&self, headers: &HeaderMap) -> Result { + let Self::Embedded { session } = self else { + return Err(ApiError::not_found("Not found")); + }; + let provided = bearer_value(headers).ok_or_else(ApiError::unauthorized)?; + let mut secrets = session + .bootstrap_secrets + .lock() + .map_err(|_| ApiError::unauthorized())?; + let matching_index = secrets + .iter() + .position(|expected| constant_time_eq(provided.as_bytes(), expected)); + let Some(index) = matching_index else { + return Err(ApiError::unauthorized()); + }; + secrets.swap_remove(index); + + Ok(format!( + "{LOCAL_SESSION_COOKIE_NAME}={}; Path=/; HttpOnly; SameSite=Strict", + session.session_token + )) + } +} + +fn validate_control_identity(expected_host: &str, control_origin: &str) -> Result<(), String> { + if expected_host.is_empty() { + return Err("embedded auth expected_host cannot be empty".into()); + } + let uri = control_origin + .parse::() + .map_err(|error| format!("invalid embedded control_origin: {error}"))?; + if !matches!(uri.scheme_str(), Some("http" | "https")) + || uri.authority().is_none() + || uri + .path_and_query() + .is_some_and(|path| path.as_str() != "/") + { + return Err("embedded control_origin must be an http(s) origin without a path".into()); + } + if uri.authority().map(|authority| authority.as_str()) != Some(expected_host) { + return Err("embedded control_origin authority must equal expected_host".into()); + } + Ok(()) +} + +fn bearer_value(headers: &HeaderMap) -> Option<&str> { + headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .filter(|value| !value.is_empty()) +} + +fn cookie_values<'a>( + headers: &'a HeaderMap, + expected_name: &'a str, +) -> impl Iterator { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|header| header.to_str().ok()) + .flat_map(|header| header.split(';')) + .filter_map(move |pair| { + let (name, value) = pair.trim().split_once('=')?; + (name == expected_name).then_some(value) + }) +} + +fn generate_secret() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use axum::http::HeaderValue; + + use super::*; + + fn embedded() -> ClientAuth { + ClientAuth::embedded( + "studio.localhost:43120".into(), + "http://studio.localhost:43120".into(), + vec!["bootstrap-a".into(), "bootstrap-b".into()], + ) + .unwrap() + } + + fn bootstrap_headers(secret: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {secret}")).unwrap(), + ); + headers + } + + fn cookie_from_set_cookie(set_cookie: &str) -> String { + set_cookie.split(';').next().unwrap().to_string() + } + + #[test] + fn embedded_identity_requires_matching_host_and_origin_authority() { + assert!(ClientAuth::embedded( + "studio.localhost:43120".into(), + "http://studio.localhost:43120".into(), + vec!["secret".into()], + ) + .is_ok()); + assert!(ClientAuth::embedded( + "studio.localhost:43121".into(), + "http://studio.localhost:4420".into(), + vec!["secret".into()], + ) + .is_err()); + assert!(ClientAuth::embedded( + "studio.localhost:43120".into(), + "http://studio.localhost:43120/path".into(), + vec!["secret".into()], + ) + .is_err()); + } + + #[test] + fn bootstrap_secret_is_one_time_and_other_secrets_survive_failed_attempts() { + let auth = embedded(); + assert!(auth.consume_bootstrap(&bootstrap_headers("wrong")).is_err()); + let first = auth + .consume_bootstrap(&bootstrap_headers("bootstrap-a")) + .unwrap(); + assert!(first.contains("HttpOnly")); + assert!(first.contains("SameSite=Strict")); + assert!(!first.contains("Domain=")); + assert!(auth + .consume_bootstrap(&bootstrap_headers("bootstrap-a")) + .is_err()); + assert!(auth + .consume_bootstrap(&bootstrap_headers("bootstrap-b")) + .is_ok()); + } + + #[test] + fn session_cookie_auth_accepts_only_the_server_generated_value() { + let auth = embedded(); + let set_cookie = auth + .consume_bootstrap(&bootstrap_headers("bootstrap-a")) + .unwrap(); + let cookie = cookie_from_set_cookie(&set_cookie); + + let mut good = HeaderMap::new(); + good.insert(header::COOKIE, HeaderValue::from_str(&cookie).unwrap()); + assert!(auth.require_private(&good).is_ok()); + + let mut wrong = HeaderMap::new(); + wrong.insert( + header::COOKIE, + HeaderValue::from_static("decocms-local-session=attacker-selected"), + ); + assert!(auth.require_private(&wrong).is_err()); + } + + #[test] + fn embedded_host_and_unsafe_origin_are_exact() { + let auth = embedded(); + let mut headers = HeaderMap::new(); + headers.insert( + header::HOST, + HeaderValue::from_static("studio.localhost:43120"), + ); + headers.insert( + header::ORIGIN, + HeaderValue::from_static("http://studio.localhost:43120"), + ); + assert!(auth.require_expected_host(&headers).is_ok()); + assert!(auth.require_unsafe_origin(&Method::POST, &headers).is_ok()); + + headers.insert( + header::HOST, + HeaderValue::from_static("studio.localhost:43121"), + ); + assert!(auth.require_expected_host(&headers).is_err()); + + headers.insert( + header::ORIGIN, + HeaderValue::from_static("http://attacker.localhost:43120"), + ); + assert!(auth.require_unsafe_origin(&Method::GET, &headers).is_err()); + assert!(auth.require_unsafe_origin(&Method::POST, &headers).is_err()); + + headers.remove(header::ORIGIN); + assert!(auth.require_unsafe_origin(&Method::GET, &headers).is_ok()); + assert!(auth.require_unsafe_origin(&Method::POST, &headers).is_err()); + } + + #[test] + fn removing_control_cookie_preserves_unrelated_application_cookies() { + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_static( + "sandbox-session=abc; decocms-local-session=secret; theme=dark", + ), + ); + remove_local_session_cookie(&mut headers); + assert_eq!( + headers.get(header::COOKIE).unwrap(), + "sandbox-session=abc; theme=dark" + ); + } +} diff --git a/apps/native/crates/local-api/src/config/classify.rs b/apps/native/crates/local-api/src/config/classify.rs new file mode 100644 index 0000000000..abc04396ca --- /dev/null +++ b/apps/native/crates/local-api/src/config/classify.rs @@ -0,0 +1,367 @@ +//! Pure: derive the single highest-impact transition between two configs. +//! Byte-parity port of `packages/sandbox/daemon/config-store/classify.ts`. +//! +//! Precedence (highest first): identity-conflict, then bootstrap, +//! branch-change, runtime-change, pm-change, port-change, env-change, +//! git-credential-refresh, and finally no-op. +//! +//! Callers MUST run [`crate::config::validate::validate_tenant_config`] on +//! `after` before calling this (same ordering as the TS store's `runOne`) — +//! classify assumes `after`'s fields are already well-typed (e.g. `branch` +//! is a string when present) and degrades a malformed field to "absent" +//! rather than panicking, which is a deliberate, harmless divergence from +//! the TS source (which would throw on some malformed inputs — see the +//! module doc on `validate.rs`). + +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Transition { + Bootstrap, + BranchChange { + from: Option, + to: String, + }, + PmChange { + from: Option, + to: Value, + }, + RuntimeChange { + from: Option, + to: String, + }, + PortChange { + from: Option, + to: Option, + }, + EnvChange { + set: Vec, + deleted: Vec, + }, + GitCredentialRefresh { + clone_url: String, + }, + IdentityConflict { + field: &'static str, + }, + NoOp, +} + +impl Transition { + /// The wire-shape `transition` string (`result.transition.kind` on the + /// TS side) — byte-parity with the daemon's `Transition["kind"]` union. + pub fn kind(&self) -> &'static str { + match self { + Transition::Bootstrap => "bootstrap", + Transition::BranchChange { .. } => "branch-change", + Transition::PmChange { .. } => "pm-change", + Transition::RuntimeChange { .. } => "runtime-change", + Transition::PortChange { .. } => "port-change", + Transition::EnvChange { .. } => "env-change", + Transition::GitCredentialRefresh { .. } => "git-credential-refresh", + Transition::IdentityConflict { .. } => "identity-conflict", + Transition::NoOp => "no-op", + } + } +} + +pub fn classify(before: Option<&Value>, after: &Value) -> Transition { + let before_url = before.and_then(|b| get_str_path(b, &["git", "repository", "cloneUrl"])); + let after_url = get_str_path(after, &["git", "repository", "cloneUrl"]); + + if let (Some(bu), Some(au)) = (&before_url, &after_url) { + if strip_credentials(bu) != strip_credentials(au) { + return Transition::IdentityConflict { field: "cloneUrl" }; + } + } + + // Byte-parity with the TS `after.application !== undefined` check: key + // PRESENCE, not truthiness — an explicit `application: null` patch (an + // edge case no schema forbids pre-merge) still counts as "meaningful". + let is_meaningful = after_url.is_some() || after.get("application").is_some(); + + let Some(before) = before else { + if is_meaningful { + return Transition::Bootstrap; + } + return match diff_env(None, after.get("env")) { + Some((set, deleted)) => Transition::EnvChange { set, deleted }, + None => Transition::NoOp, + }; + }; + + let before_branch = get_str_path(before, &["git", "repository", "branch"]); + if let Some(after_branch) = get_present_str(after, &["git", "repository", "branch"]) { + if Some(&after_branch) != before_branch.as_ref() { + return Transition::BranchChange { + from: before_branch, + to: after_branch, + }; + } + } + + let before_runtime = get_str_path(before, &["application", "runtime"]); + if let Some(after_runtime) = get_present_str(after, &["application", "runtime"]) { + if Some(&after_runtime) != before_runtime.as_ref() { + return Transition::RuntimeChange { + from: before_runtime, + to: after_runtime, + }; + } + } + + let before_pm = get_path(before, &["application", "packageManager"]); + if let Some(after_pm) = get_path(after, &["application", "packageManager"]) { + if !after_pm.is_null() { + let before_name = before_pm + .and_then(|p| p.get("name")) + .and_then(Value::as_str); + let after_name = after_pm.get("name").and_then(Value::as_str); + let before_path = before_pm + .and_then(|p| p.get("path")) + .and_then(Value::as_str); + let after_path = after_pm.get("path").and_then(Value::as_str); + if before_name != after_name || before_path != after_path { + return Transition::PmChange { + from: before_pm.cloned(), + to: after_pm.clone(), + }; + } + } + } + + let before_port = get_path(before, &["application", "port"]).and_then(Value::as_i64); + let after_port = get_path(after, &["application", "port"]).and_then(Value::as_i64); + if before_port != after_port { + return Transition::PortChange { + from: before_port, + to: after_port, + }; + } + + if let Some((set, deleted)) = diff_env(before.get("env"), after.get("env")) { + return Transition::EnvChange { set, deleted }; + } + + if let (Some(bu), Some(au)) = (&before_url, &after_url) { + if bu != au && strip_credentials(bu) == strip_credentials(au) { + return Transition::GitCredentialRefresh { + clone_url: au.clone(), + }; + } + } + + Transition::NoOp +} + +fn diff_env(before: Option<&Value>, after: Option<&Value>) -> Option<(Vec, Vec)> { + let b = before.and_then(Value::as_object); + let a = after.and_then(Value::as_object); + + let mut set = Vec::new(); + if let Some(a_obj) = a { + for (k, v) in a_obj { + let differs = match b.and_then(|bo| bo.get(k)) { + Some(bv) => bv != v, + None => true, + }; + if differs { + set.push(k.clone()); + } + } + } + let mut deleted = Vec::new(); + if let Some(b_obj) = b { + for k in b_obj.keys() { + if a.is_none_or(|ao| !ao.contains_key(k)) { + deleted.push(k.clone()); + } + } + } + if set.is_empty() && deleted.is_empty() { + None + } else { + Some((set, deleted)) + } +} + +/// The `cloneUrl` embeds an OAuth token (e.g. +/// `x-access-token:TOKEN@github.com/…`) that gets refreshed independently of +/// the repo identity — strip username/password before comparing so only the +/// actual repo path is guarded (byte-parity with `classify.ts::stripCredentials`). +fn strip_credentials(raw_url: &str) -> String { + match reqwest::Url::parse(raw_url) { + Ok(mut u) => { + let _ = u.set_username(""); + let _ = u.set_password(None); + u.to_string() + } + Err(_) => raw_url.to_string(), + } +} + +fn get_path<'a>(v: &'a Value, path: &[&str]) -> Option<&'a Value> { + let mut cur = v; + for k in path { + cur = cur.as_object()?.get(*k)?; + } + Some(cur) +} + +fn get_str_path(v: &Value, path: &[&str]) -> Option { + get_path(v, path) + .and_then(Value::as_str) + .map(str::to_string) +} + +/// A field the TS side reads as `after.foo?.bar !== undefined` — present +/// (any non-null JSON value) AND actually a string. A present-but-wrong-type +/// value (which `validate_tenant_config` should already have rejected) is +/// treated as absent rather than causing a spurious transition. +fn get_present_str(v: &Value, path: &[&str]) -> Option { + let field = get_path(v, path)?; + if field.is_null() { + return None; + } + field.as_str().map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn null_to_null_is_no_op() { + assert_eq!(classify(None, &json!({})).kind(), "no-op"); + } + + #[test] + fn null_to_meaningful_clone_url_is_bootstrap() { + let after = json!({"git": {"repository": {"cloneUrl": "https://x.git"}}}); + assert_eq!(classify(None, &after).kind(), "bootstrap"); + } + + #[test] + fn null_to_meaningful_application_only_is_bootstrap() { + let after = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "node"}}); + assert_eq!(classify(None, &after).kind(), "bootstrap"); + } + + #[test] + fn clone_url_mismatch_is_identity_conflict() { + let before = + json!({"git": {"repository": {"cloneUrl": "https://github.com/org/repo-a.git"}}}); + let after = + json!({"git": {"repository": {"cloneUrl": "https://github.com/org/repo-b.git"}}}); + assert_eq!(classify(Some(&before), &after).kind(), "identity-conflict"); + } + + #[test] + fn clone_url_credential_only_change_is_not_identity_conflict() { + let before = json!({"git": {"repository": {"cloneUrl": "https://x-access-token:OLD@github.com/org/repo.git"}}}); + let after = json!({"git": {"repository": {"cloneUrl": "https://x-access-token:NEW@github.com/org/repo.git"}}}); + assert_ne!(classify(Some(&before), &after).kind(), "identity-conflict"); + } + + #[test] + fn clone_url_credential_only_change_is_git_credential_refresh() { + let before = json!({"git": {"repository": {"cloneUrl": "https://x-access-token:OLD@github.com/org/repo.git"}}}); + let after = json!({"git": {"repository": {"cloneUrl": "https://x-access-token:NEW@github.com/org/repo.git"}}}); + let t = classify(Some(&before), &after); + assert_eq!(t.kind(), "git-credential-refresh"); + if let Transition::GitCredentialRefresh { clone_url } = t { + assert_eq!( + clone_url, + "https://x-access-token:NEW@github.com/org/repo.git" + ); + } else { + panic!("expected git-credential-refresh"); + } + } + + #[test] + fn branch_change() { + let before = json!({"git": {"repository": {"cloneUrl": "x", "branch": "main"}}}); + let after = json!({"git": {"repository": {"cloneUrl": "x", "branch": "feature"}}}); + let t = classify(Some(&before), &after); + assert_eq!(t.kind(), "branch-change"); + if let Transition::BranchChange { from, to } = t { + assert_eq!(from.as_deref(), Some("main")); + assert_eq!(to, "feature"); + } else { + panic!("expected branch-change"); + } + } + + #[test] + fn runtime_change_without_pm() { + let before = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "node"}}); + let after = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "bun"}}); + assert_eq!(classify(Some(&before), &after).kind(), "runtime-change"); + } + + #[test] + fn pm_name_change() { + let before = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "node"}}); + let after = json!({"application": {"packageManager": {"name": "pnpm"}, "runtime": "node"}}); + assert_eq!(classify(Some(&before), &after).kind(), "pm-change"); + } + + #[test] + fn pm_path_change() { + let before = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "node"}}); + let after = json!({"application": {"packageManager": {"name": "npm", "path": "apps/web"}, "runtime": "node"}}); + assert_eq!(classify(Some(&before), &after).kind(), "pm-change"); + } + + #[test] + fn port_change() { + let before = json!({"application": {"port": 3000}}); + let after = json!({"application": {"port": 5173}}); + assert_eq!(classify(Some(&before), &after).kind(), "port-change"); + } + + #[test] + fn identical_configs_is_no_op() { + let config = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "node"}}); + assert_eq!(classify(Some(&config), &config).kind(), "no-op"); + } + + #[test] + fn branch_and_pm_change_emits_the_higher_impact_one() { + let before = json!({ + "git": {"repository": {"cloneUrl": "x", "branch": "main"}}, + "application": {"packageManager": {"name": "npm"}, "runtime": "node"}, + }); + let after = json!({ + "git": {"repository": {"cloneUrl": "x", "branch": "feature"}}, + "application": {"packageManager": {"name": "pnpm"}, "runtime": "node"}, + }); + assert_eq!(classify(Some(&before), &after).kind(), "branch-change"); + } + + #[test] + fn env_added_is_env_change_with_key_names_only() { + let before = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "node"}}); + let after = json!({ + "application": {"packageManager": {"name": "npm"}, "runtime": "node"}, + "env": {"STRIPE_KEY": "sk_test_secret"}, + }); + let t = classify(Some(&before), &after); + assert_eq!(t.kind(), "env-change"); + if let Transition::EnvChange { set, deleted } = t { + assert_eq!(set, vec!["STRIPE_KEY".to_string()]); + assert!(deleted.is_empty()); + } else { + panic!("expected env-change"); + } + } + + #[test] + fn port_change_beats_env_change() { + let before = json!({"application": {"port": 3000}, "env": {"FOO": "1"}}); + let after = json!({"application": {"port": 4000}, "env": {"FOO": "2"}}); + assert_eq!(classify(Some(&before), &after).kind(), "port-change"); + } +} diff --git a/apps/native/crates/local-api/src/config/merge.rs b/apps/native/crates/local-api/src/config/merge.rs new file mode 100644 index 0000000000..bda52b425a --- /dev/null +++ b/apps/native/crates/local-api/src/config/merge.rs @@ -0,0 +1,193 @@ +//! Deep-merge a `ConfigPatch`-shaped JSON value into the current +//! `TenantConfig`. Byte-parity port of +//! `packages/sandbox/daemon/config-store/merge.ts`. +//! +//! Semantics (unchanged from the TS source): +//! - field absent (key missing) -> leave existing +//! - field present -> set (nested objects merge field-by-field, arrays and +//! primitives replace wholesale) +//! - `env` is per-key: string -> upsert, `null` -> delete that key only + +use serde_json::{Map, Value}; + +/// `deepMerge(current, patch)` — always returns an object with only the +/// top-level keys that ended up with a value (mirrors `JSON.stringify` +/// silently dropping `undefined`-valued keys on the TS side). +pub fn deep_merge(current: Option<&Value>, patch: &Value) -> Value { + let base = current.and_then(Value::as_object); + let patch_obj = patch.as_object(); + + let mut out = Map::new(); + for field in ["git", "operator", "application"] { + if let Some(v) = merge_optional_field( + base.and_then(|b| b.get(field)), + patch_obj.and_then(|p| p.get(field)), + ) { + out.insert(field.to_string(), v); + } + } + if let Some(env) = merge_env( + base.and_then(|b| b.get("env")), + patch_obj.and_then(|p| p.get("env")), + ) { + out.insert("env".to_string(), env); + } + Value::Object(out) +} + +fn merge_env(current: Option<&Value>, patch: Option<&Value>) -> Option { + let Some(patch) = patch else { + return current.cloned(); + }; + let mut out: Map = current + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + if let Some(patch_obj) = patch.as_object() { + for (k, v) in patch_obj { + if v.is_null() { + out.remove(k); + } else { + // Byte-parity with the TS `else (out as any)[k] = v` fallback + // for a non-string, non-null value — passed through verbatim + // so validation (not merge) is the layer that rejects it. + out.insert(k.clone(), v.clone()); + } + } + } + if out.is_empty() { + None + } else { + Some(Value::Object(out)) + } +} + +fn merge_optional_field(current: Option<&Value>, patch: Option<&Value>) -> Option { + let Some(patch) = patch else { + return current.cloned(); + }; + match current { + None => Some(patch.clone()), + Some(current) => Some(merge_objects(current, patch)), + } +} + +fn merge_objects(current: &Value, patch: &Value) -> Value { + let (Some(cur_obj), Some(patch_obj)) = (current.as_object(), patch.as_object()) else { + // Non-object patch (array/primitive) replaces wholesale, byte-parity + // with the TS `mergeOptional`'s `isPlainObject` guard. + return patch.clone(); + }; + let mut out = cur_obj.clone(); + for (k, v) in patch_obj { + let existing = cur_obj.get(k); + // `Value::as_object()` only matches the `Object` variant (never + // `Array`), so this already mirrors the TS `isPlainObject` guard + // (plain object on both sides, not an array) without a separate + // array check. + match (existing.and_then(Value::as_object), v.as_object()) { + (Some(existing_obj), Some(_)) => { + out.insert( + k.clone(), + merge_objects(&Value::Object(existing_obj.clone()), v), + ); + } + _ => { + out.insert(k.clone(), v.clone()); + } + } + } + Value::Object(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn returns_patch_when_current_is_none() { + let patch = json!({"application": {"packageManager": {"name": "npm"}, "runtime": "node"}}); + let merged = deep_merge(None, &patch); + assert_eq!(merged["application"]["packageManager"]["name"], "npm"); + } + + #[test] + fn preserves_fields_not_in_patch() { + let current = json!({ + "git": {"repository": {"cloneUrl": "x", "branch": "main"}}, + "application": {"packageManager": {"name": "npm"}, "runtime": "node"}, + }); + let patch = json!({"application": {"packageManager": {"name": "pnpm"}, "runtime": "node"}}); + let merged = deep_merge(Some(¤t), &patch); + assert_eq!(merged["git"]["repository"]["cloneUrl"], "x"); + assert_eq!(merged["application"]["packageManager"]["name"], "pnpm"); + } + + #[test] + fn preserves_operator_when_git_patch_omits_it() { + let current = json!({ + "operator": {"userName": "Jane Doe", "userEmail": "jane@example.com"}, + "git": {"repository": {"cloneUrl": "old"}}, + }); + let patch = json!({ + "git": { + "repository": {"cloneUrl": "new"}, + "identity": {"userName": "Bot", "userEmail": "bot@example.com"}, + }, + }); + let merged = deep_merge(Some(¤t), &patch); + assert_eq!( + merged["operator"], + json!({"userName": "Jane Doe", "userEmail": "jane@example.com"}) + ); + assert_eq!(merged["git"]["repository"]["cloneUrl"], "new"); + } + + #[test] + fn absent_fields_dont_overwrite_existing_ones() { + let current = json!({ + "application": {"packageManager": {"name": "npm"}, "runtime": "node", "port": 3000}, + }); + let patch = json!({"application": {"packageManager": {"name": "pnpm"}, "runtime": "node"}}); + let merged = deep_merge(Some(¤t), &patch); + assert_eq!(merged["application"]["port"], 3000); + assert_eq!(merged["application"]["packageManager"]["name"], "pnpm"); + } + + #[test] + fn env_upserts_keys_without_touching_siblings() { + let merged = deep_merge( + Some(&json!({"env": {"FOO": "1", "BAR": "2"}})), + &json!({"env": {"FOO": "x"}}), + ); + assert_eq!(merged["env"], json!({"FOO": "x", "BAR": "2"})); + } + + #[test] + fn env_deletes_a_key_when_its_value_is_null() { + let merged = deep_merge( + Some(&json!({"env": {"FOO": "1", "BAR": "2"}})), + &json!({"env": {"FOO": null}}), + ); + assert_eq!(merged["env"], json!({"BAR": "2"})); + } + + #[test] + fn env_collapses_to_absent_when_all_keys_are_deleted() { + let merged = deep_merge( + Some(&json!({"env": {"FOO": "1"}})), + &json!({"env": {"FOO": null}}), + ); + assert!(merged.get("env").is_none()); + } + + #[test] + fn env_preserved_when_patch_omits_it() { + let merged = deep_merge( + Some(&json!({"env": {"FOO": "1"}})), + &json!({"application": {"runtime": "node"}}), + ); + assert_eq!(merged["env"], json!({"FOO": "1"})); + } +} diff --git a/apps/native/crates/local-api/src/config/mod.rs b/apps/native/crates/local-api/src/config/mod.rs new file mode 100644 index 0000000000..c00a0eea1e --- /dev/null +++ b/apps/native/crates/local-api/src/config/mod.rs @@ -0,0 +1,12 @@ +pub mod classify; +pub mod merge; +pub mod store; +pub mod validate; + +// `ConfigSnapshot`/`TenantConfig` aren't referenced outside `config::store` +// yet — `state.rs` (a shared file) only imports `ConfigStore`. Kept as a +// re-export (not dead in the "delete it" sense) so a future consumer of the +// documented `snapshot()` shape (see the native module-ownership contract) +// doesn't have to reach into `config::store` directly. +#[allow(unused_imports)] +pub use store::{ConfigSnapshot, ConfigStore, TenantConfig}; diff --git a/apps/native/crates/local-api/src/config/store.rs b/apps/native/crates/local-api/src/config/store.rs new file mode 100644 index 0000000000..eb14adf35b --- /dev/null +++ b/apps/native/crates/local-api/src/config/store.rs @@ -0,0 +1,290 @@ +//! `ConfigStore` — the shared handle behind `GET/POST /_sandbox/config`. +//! +//! Single-writer serial actor: `patch()` holds the lock across the whole +//! merge -> validate -> classify -> apply sequence, so two concurrent +//! `POST`/`PUT /_sandbox/config` calls compose deterministically (last +//! write wins on the same field) — the same guarantee the TS daemon gets +//! from `TenantConfigStore`'s FIFO apply queue (`config-store/store.ts`), +//! achieved here with a `Mutex` instead of an explicit queue since axum +//! handlers already run on a thread pool rather than a single event loop. +//! +//! Every OTHER family only ever calls the read-only accessors +//! (`snapshot()`, `is_configured()`) — for `/health`'s `configured` field, +//! the exec/scripts family's "409 when no package manager is configured +//! yet" gate, and git's "409 not-ready" gate. Only `routes/config.rs` calls +//! `patch()`. +//! +//! Byte-parity target: `packages/sandbox/daemon/config-store/` (`classify.ts` +//! precedence, `merge.ts` deep-merge, `validate.ts` field validation, +//! `store.ts`'s reject/apply/no-op branching). See `config/classify.rs`, +//! `config/merge.rs`, `config/validate.rs` for the ported pieces. + +use std::sync::Mutex; + +use serde_json::{Map, Value}; + +use crate::error::ApiError; + +use super::classify::{classify, Transition}; +use super::merge::deep_merge; +use super::validate::validate_tenant_config; + +/// Opaque JSON `TenantConfig` (`git`/`operator`/`application`/`env` keys) — +/// see the module doc on why this stays JSON rather than a typed struct. +pub type TenantConfig = Value; + +/// `GET /_sandbox/config`'s read model. `config` deliberately EXCLUDES `env` +/// (byte-parity with `routes/config.ts::stripDerived`, which strips it too) +/// — env is only ever exposed as key names via `env_keys`, never values. +/// +/// `configured` mirrors `is_configured()` and is part of the documented +/// bootstrap shape (the native module-ownership contract's `ConfigStore` +/// section: `{ config, env_keys, ready, configured }`) for any future +/// consumer of `snapshot()` as a whole — `routes/config.rs`'s `GET` handler +/// itself reads `is_configured()`'s twin `ready` field but doesn't echo +/// `configured` on the wire (not in the contract doc's GET response field +/// list), hence the allow. +#[allow(dead_code)] +#[derive(Debug, Clone, Default)] +pub struct ConfigSnapshot { + pub config: Option, + pub env_keys: Vec, + pub ready: bool, + pub configured: bool, +} + +/// A successful `patch()` — the shape `routes/config.rs` needs to build the +/// `{ bootId, transition, config }` response and decide whether to emit a +/// `"lifecycle"` broadcaster event (only on a non-`"no-op"` transition, +/// byte-parity with the TS store only notifying subscribers on a real +/// state change). +/// +/// Deliberate signature change from the bootstrap stub's +/// `patch(&self, Value) -> Result`: `ConfigSnapshot` +/// has no room for `transition`, and nothing outside this family calls +/// `patch()` (see the native module-ownership contract's config section), +/// so widening it here doesn't break another family's build. +#[derive(Debug, Clone)] +pub struct ApplyOutcome { + pub transition: &'static str, + /// The merged `TenantConfig` INCLUDING `env` values — byte-parity with + /// `routes/config.ts::makeApplyResponse`, which returns `result.after` + /// (the raw merged object) unstripped, unlike the `GET` handler. This + /// is the TS daemon's actual behavior, not a Rust-side embellishment; + /// see the module doc on `routes/config.rs` for the full reasoning. + pub config: Value, +} + +pub struct ConfigStore { + /// `None` until the first non-no-op `patch()` — mirrors + /// `TenantConfigStore.current` staying `null` through a `{}` patch on a + /// fresh store (see `classify`'s "null -> null = no-op" case). + current: Mutex>, +} + +impl ConfigStore { + pub fn new() -> Self { + Self { + current: Mutex::new(None), + } + } + + /// Current snapshot. Never errors — safe to call from any route, + /// including `/health` (no-auth) and other families' gating checks. + pub fn snapshot(&self) -> ConfigSnapshot { + let current = self.read(); + let config = current.as_ref().map(strip_env); + let env_keys = current + .as_ref() + .and_then(|c| c.get("env")) + .and_then(Value::as_object) + .map(sorted_keys) + .unwrap_or_default(); + let configured = current.is_some(); + ConfigSnapshot { + config, + env_keys, + // No setup/lifecycle pipeline to wait on (dropped family, see + // the contract doc's §C) — "ready" collapses onto "configured", + // the closest local-api has to the TS daemon's + // `lifecycle.current().phase === "running"`. A judgment call: + // no oracle test asserts `ready` becomes `true` post-configure, + // only that it starts `false` on a fresh store (satisfied + // either way). + ready: configured, + configured, + } + } + + pub fn is_configured(&self) -> bool { + self.read().is_some() + } + + /// Apply a `ConfigPatch`-shaped JSON value (already stripped of the + /// wire-only `auth` field by the caller). Byte-parity outcomes: + /// + /// - invalid merged config -> `400 {"error":"invalid"}` (the TS store + /// discards the detailed validation reason on the wire too — see + /// `REJECTION_REASONS.INVALID` in `config-store/types.ts`). + /// - identity conflict (immutable `cloneUrl` change) -> `409 + /// {"error":"immutable: cloneUrl"}`. + /// - no-op transition -> `200`, but internal state is NOT overwritten + /// (byte-parity with `store.ts::runOne`'s early return before + /// `this.current = enrich(merged)`). + /// - otherwise -> `200`, state updated to the merged config. + pub fn patch(&self, patch: Value) -> Result { + let mut guard = self.lock(); + let before = guard.clone(); + let merged = deep_merge(before.as_ref(), &patch); + + if validate_tenant_config(&merged).is_err() { + return Err(ApiError::bad_request("invalid")); + } + + let transition = classify(before.as_ref(), &merged); + if let Transition::IdentityConflict { field } = &transition { + return Err(ApiError::conflict(format!("immutable: {field}"))); + } + + if !matches!(transition, Transition::NoOp) { + *guard = Some(merged.clone()); + } + + Ok(ApplyOutcome { + transition: transition.kind(), + config: merged, + }) + } + + fn read(&self) -> std::sync::MutexGuard<'_, Option> { + self.lock() + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Option> { + match self.current.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + } + } +} + +impl Default for ConfigStore { + fn default() -> Self { + Self::new() + } +} + +/// `{ git, operator, application }` — byte-parity with +/// `routes/config.ts::stripDerived` (env is deliberately omitted; see +/// `ConfigSnapshot`'s doc comment). +fn strip_env(config: &TenantConfig) -> Value { + let mut out = Map::new(); + for key in ["git", "operator", "application"] { + if let Some(v) = config.get(key) { + out.insert(key.to_string(), v.clone()); + } + } + Value::Object(out) +} + +fn sorted_keys(obj: &Map) -> Vec { + let mut keys: Vec = obj.keys().cloned().collect(); + keys.sort(); + keys +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn fresh_store_is_unconfigured() { + let store = ConfigStore::new(); + assert!(!store.is_configured()); + let snap = store.snapshot(); + assert!(snap.config.is_none()); + assert!(snap.env_keys.is_empty()); + assert!(!snap.ready); + } + + #[test] + fn bootstrap_patch_configures_the_store() { + let store = ConfigStore::new(); + let outcome = store + .patch(json!({"application": {"packageManager": {"name": "npm"}}})) + .expect("apply ok"); + assert_eq!(outcome.transition, "bootstrap"); + assert!(store.is_configured()); + let snap = store.snapshot(); + assert_eq!( + snap.config.unwrap()["application"]["packageManager"]["name"], + "npm" + ); + } + + #[test] + fn empty_patch_on_fresh_store_stays_unconfigured() { + let store = ConfigStore::new(); + let outcome = store.patch(json!({})).expect("apply ok"); + assert_eq!(outcome.transition, "no-op"); + assert!(!store.is_configured()); + } + + #[test] + fn immutable_clone_url_change_is_rejected_with_409() { + let store = ConfigStore::new(); + store + .patch(json!({"git": {"repository": {"cloneUrl": "https://example.com/a.git"}}})) + .expect("first apply ok"); + let err = store + .patch(json!({"git": {"repository": {"cloneUrl": "https://example.com/b.git"}}})) + .expect_err("second apply should be rejected"); + assert_eq!(err.status, axum::http::StatusCode::CONFLICT); + assert_eq!(err.body["error"], "immutable: cloneUrl"); + } + + #[test] + fn invalid_patch_is_rejected_with_400_and_no_detail_leak() { + let store = ConfigStore::new(); + let err = store + .patch(json!({"application": {"runtime": "python"}})) + .expect_err("invalid runtime rejected"); + assert_eq!(err.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!(err.body["error"], "invalid"); + } + + #[test] + fn env_keys_round_trip_without_values() { + let store = ConfigStore::new(); + store + .patch(json!({"env": {"FOO": "bar", "BAZ": "qux"}})) + .expect("apply ok"); + let snap = store.snapshot(); + assert_eq!(snap.env_keys, vec!["BAZ".to_string(), "FOO".to_string()]); + // `config` (the GET-shaped view) never carries env values. + assert!(snap.config.unwrap().get("env").is_none()); + } + + #[test] + fn git_credential_refresh_updates_state_and_notifies_via_non_no_op_transition() { + let store = ConfigStore::new(); + store + .patch(json!({ + "git": {"repository": {"cloneUrl": "https://x-access-token:OLD@github.com/org/repo.git"}}, + "application": {"packageManager": {"name": "npm"}, "runtime": "node"}, + })) + .expect("first apply ok"); + let outcome = store + .patch(json!({ + "git": {"repository": {"cloneUrl": "https://x-access-token:NEW@github.com/org/repo.git"}}, + })) + .expect("refresh apply ok"); + assert_eq!(outcome.transition, "git-credential-refresh"); + let snap = store.snapshot(); + assert_eq!( + snap.config.unwrap()["git"]["repository"]["cloneUrl"], + "https://x-access-token:NEW@github.com/org/repo.git" + ); + } +} diff --git a/apps/native/crates/local-api/src/config/validate.rs b/apps/native/crates/local-api/src/config/validate.rs new file mode 100644 index 0000000000..360b417940 --- /dev/null +++ b/apps/native/crates/local-api/src/config/validate.rs @@ -0,0 +1,354 @@ +//! Validate a fully-merged `TenantConfig` (post-merge, pre-persist). Byte-parity +//! port of `packages/sandbox/daemon/validate.ts`'s `validateTenantConfig` + +//! `packages/sandbox/git-co-author.ts`'s `normalizeCoAuthorIdentity` (needed +//! by `operator` validation). +//! +//! All fields are optional — partial configs are valid so callers can patch +//! one field at a time; only PRESENT field *values* are validated. +//! +//! Divergence from the TS source (deliberate, matches this repo's "no +//! `unwrap()`/panic on a request path" convention): a present-but-wrong-type +//! value that the TS source would reach via an unchecked property access +//! (e.g. a numeric `git.repository.branch`, which TS's `isSyntheticBranch` +//! would call `.startsWith` on and throw) is instead classified as invalid +//! here — same 400 outcome, no crash. No test in either e2e suite pins the +//! TS crash path, so this doesn't cost byte-parity on anything exercised. + +use regex::Regex; +use serde_json::Value; +use std::path::{Component, Path}; +use std::sync::LazyLock; + +static BRANCH_RE: LazyLock = LazyLock::new(|| Regex::new(r"^[A-Za-z0-9._/-]+$").unwrap()); +static ENV_KEY_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").unwrap()); +static EMAIL_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$").unwrap()); + +const ENV_VALUE_MAX_BYTES: usize = 32 * 1024; +const VALID_RUNTIMES: &[&str] = &["node", "bun", "deno"]; +const VALID_PMS: &[&str] = &["npm", "pnpm", "yarn", "bun", "deno"]; + +/// `Ok(())` mirrors `{kind:"ok"}`; `Err(reason)` mirrors `{kind:"invalid", +/// reason}` — note the wire response only ever surfaces the constant +/// `"invalid"` (see `config-store/store.ts`'s `REJECTION_REASONS.INVALID`, +/// which discards `reason`), so `reason` here is for local diagnostics/tests +/// only, never echoed to the caller. +pub fn validate_tenant_config(config: &Value) -> Result<(), String> { + if let Some(git) = present(config, "git") { + validate_git(git)?; + } + if let Some(app) = present(config, "application") { + validate_application(app)?; + } + if let Some(env) = present(config, "env") { + validate_env(env)?; + } + if let Some(operator) = present(config, "operator") { + validate_operator(operator)?; + } + Ok(()) +} + +fn present<'a>(config: &'a Value, key: &str) -> Option<&'a Value> { + config.get(key).filter(|v| !v.is_null()) +} + +fn validate_env(env: &Value) -> Result<(), String> { + let obj = env + .as_object() + .ok_or_else(|| "env must be an object".to_string())?; + for (k, v) in obj { + if !ENV_KEY_RE.is_match(k) { + return Err(format!("env key invalid: {k}")); + } + let s = v + .as_str() + .ok_or_else(|| format!("env value for {k} must be a string"))?; + if s.contains('\0') { + return Err(format!("env value for {k} contains NUL")); + } + if s.len() > ENV_VALUE_MAX_BYTES { + return Err(format!( + "env value for {k} exceeds {ENV_VALUE_MAX_BYTES} bytes" + )); + } + } + Ok(()) +} + +fn validate_git(git: &Value) -> Result<(), String> { + let repository = git.get("repository"); + let clone_url = repository + .and_then(|r| r.get("cloneUrl")) + .and_then(Value::as_str); + let clone_url = clone_url.ok_or_else(|| "git.repository.cloneUrl is required".to_string())?; + if clone_url.is_empty() { + return Err("git.repository.cloneUrl is empty".to_string()); + } + if let Some(branch) = repository.and_then(|r| present(r, "branch")) { + let is_synthetic = branch + .as_str() + .is_some_and(|b| b == "ephemeral" || b.starts_with("thread:")); + if !is_synthetic { + let valid = branch + .as_str() + .is_some_and(|b| BRANCH_RE.is_match(b) && !b.starts_with('-')); + if !valid { + return Err(format!( + "git.repository.branch invalid: {}", + display_value(branch) + )); + } + } + } + if let Some(identity) = present(git, "identity") { + let user_name = identity.get("userName").and_then(Value::as_str); + if user_name.is_none_or(str::is_empty) { + return Err("git.identity.userName is required".to_string()); + } + let user_email = identity.get("userEmail").and_then(Value::as_str); + if user_email.is_none_or(str::is_empty) { + return Err("git.identity.userEmail is required".to_string()); + } + } + Ok(()) +} + +fn validate_application(app: &Value) -> Result<(), String> { + if let Some(runtime) = present(app, "runtime") { + let valid = runtime + .as_str() + .is_some_and(|r| VALID_RUNTIMES.contains(&r)); + if !valid { + return Err(format!("runtime invalid: {}", display_value(runtime))); + } + } + if let Some(pm) = present(app, "packageManager") { + if let Some(name) = present(pm, "name") { + let name_str = name + .as_str() + .ok_or_else(|| "application.packageManager.name must be a string".to_string())?; + if !VALID_PMS.contains(&name_str) { + return Err(format!("packageManager invalid: {name_str}")); + } + } + if let Some(path) = present(pm, "path") { + let path = path + .as_str() + .filter(|path| !path.is_empty()) + .ok_or_else(|| "packageManager.path must be non-empty".to_string())?; + validate_package_manager_path(path)?; + } + } + if let Some(port) = present(app, "port") { + if !is_valid_port(port) { + return Err(format!("port invalid: {}", display_value(port))); + } + } + Ok(()) +} + +pub(crate) fn validate_package_manager_path(path: &str) -> Result<(), String> { + let path = Path::new(path); + if path.is_absolute() { + return Err("packageManager.path must be relative to the repository".to_string()); + } + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err("packageManager.path must stay inside the repository".to_string()); + } + Ok(()) +} + +fn validate_operator(operator: &Value) -> Result<(), String> { + let user_name = operator + .get("userName") + .and_then(Value::as_str) + .ok_or_else(|| "operator.userName is required".to_string())?; + let user_email = operator.get("userEmail").and_then(Value::as_str); + let normalized = normalize_co_author_identity(user_name, user_email); + let normalized = normalized.ok_or_else(|| "operator.userName is required".to_string())?; + + if let Some(email_field) = present(operator, "userEmail") { + let email_str = email_field + .as_str() + .ok_or_else(|| "operator.userEmail must be a string".to_string())?; + if !email_str.trim().is_empty() && normalized.1.is_none() { + return Err("operator.userEmail is invalid".to_string()); + } + } + Ok(()) +} + +fn is_valid_port(v: &Value) -> bool { + v.as_f64() + .is_some_and(|p| p.fract() == 0.0 && p > 0.0 && p <= 65535.0) +} + +/// Byte-parity port of `packages/sandbox/git-co-author.ts::normalizeCoAuthorIdentity`. +/// Returns `(userName, userEmail)` — `userEmail` is `None` when absent or +/// invalid (matching the TS source's "degrade, don't reject" email handling +/// once the name itself is valid). +fn normalize_co_author_identity( + user_name: &str, + user_email: Option<&str>, +) -> Option<(String, Option)> { + let name = user_name.trim(); + if name.is_empty() || has_invalid_co_author_chars(name) { + return None; + } + let Some(email) = user_email.map(str::trim).filter(|e| !e.is_empty()) else { + return Some((name.to_string(), None)); + }; + if has_invalid_co_author_chars(email) || !EMAIL_RE.is_match(email) { + return Some((name.to_string(), None)); + } + Some((name.to_string(), Some(email.to_string()))) +} + +fn has_invalid_co_author_chars(s: &str) -> bool { + s.chars().any(|c| matches!(c, '\r' | '\n' | '<' | '>')) +} + +fn display_value(v: &Value) -> String { + v.as_str() + .map(str::to_string) + .unwrap_or_else(|| v.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn empty_config_is_valid() { + assert!(validate_tenant_config(&json!({})).is_ok()); + } + + #[test] + fn valid_full_config() { + let cfg = json!({ + "git": {"repository": {"cloneUrl": "https://x.git", "branch": "main"}}, + "application": {"packageManager": {"name": "npm"}, "runtime": "node", "port": 3000}, + "env": {"FOO": "bar"}, + "operator": {"userName": "Jane Doe", "userEmail": "jane@example.com"}, + }); + assert!(validate_tenant_config(&cfg).is_ok()); + } + + #[test] + fn empty_clone_url_is_invalid() { + let cfg = json!({"git": {"repository": {"cloneUrl": ""}}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn missing_clone_url_is_invalid() { + let cfg = json!({"git": {"repository": {}}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn synthetic_branch_skips_format_check() { + let cfg = json!({"git": {"repository": {"cloneUrl": "x", "branch": "thread:abc-123"}}}); + assert!(validate_tenant_config(&cfg).is_ok()); + let cfg2 = json!({"git": {"repository": {"cloneUrl": "x", "branch": "ephemeral"}}}); + assert!(validate_tenant_config(&cfg2).is_ok()); + } + + #[test] + fn branch_starting_with_dash_is_invalid() { + let cfg = json!({"git": {"repository": {"cloneUrl": "x", "branch": "-evil"}}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn invalid_runtime_is_rejected() { + let cfg = json!({"application": {"runtime": "python"}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn invalid_package_manager_is_rejected() { + let cfg = json!({"application": {"packageManager": {"name": "cargo"}}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn repo_relative_package_manager_path_is_valid() { + let cfg = json!({"application": {"packageManager": {"name": "bun", "path": "apps/web"}}}); + assert!(validate_tenant_config(&cfg).is_ok()); + } + + #[test] + fn absolute_package_manager_path_is_rejected() { + let cfg = + json!({"application": {"packageManager": {"name": "bun", "path": "/tmp/outside"}}}); + let error = validate_tenant_config(&cfg).unwrap_err(); + assert!(error.contains("relative to the repository")); + } + + #[test] + fn traversing_package_manager_path_is_rejected() { + for path in ["..", "../outside", "apps/../../outside"] { + let cfg = json!({"application": {"packageManager": {"name": "bun", "path": path}}}); + let error = validate_tenant_config(&cfg).unwrap_err(); + assert!( + error.contains("stay inside the repository"), + "{path}: {error}" + ); + } + } + + #[test] + fn out_of_range_port_is_rejected() { + assert!(validate_tenant_config(&json!({"application": {"port": 0}})).is_err()); + assert!(validate_tenant_config(&json!({"application": {"port": 70000}})).is_err()); + assert!(validate_tenant_config(&json!({"application": {"port": 3000.5}})).is_err()); + } + + #[test] + fn env_key_must_be_identifier_shaped() { + let cfg = json!({"env": {"1FOO": "bar"}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn env_value_must_be_string() { + let cfg = json!({"env": {"FOO": 1}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn operator_without_username_is_invalid() { + assert!(validate_tenant_config(&json!({"operator": {}})).is_err()); + } + + #[test] + fn operator_with_invalid_email_degrades_gracefully() { + // Byte-parity: an invalid email doesn't reject the whole patch when + // it round-trips through `userEmail is invalid` -- here it's a + // non-empty string that fails EMAIL_RE, which normalize degrades to + // `None`, and validate_operator's own check then rejects it. + let cfg = json!({"operator": {"userName": "Jane", "userEmail": "not-an-email"}}); + assert!(validate_tenant_config(&cfg).is_err()); + } + + #[test] + fn operator_without_email_is_valid() { + let cfg = json!({"operator": {"userName": "Jane"}}); + assert!(validate_tenant_config(&cfg).is_ok()); + } + + #[test] + fn non_string_branch_is_rejected_without_panicking() { + let cfg = json!({"git": {"repository": {"cloneUrl": "x", "branch": 42}}}); + assert!(validate_tenant_config(&cfg).is_err()); + } +} diff --git a/apps/native/crates/local-api/src/cors.rs b/apps/native/crates/local-api/src/cors.rs new file mode 100644 index 0000000000..a882944af1 --- /dev/null +++ b/apps/native/crates/local-api/src/cors.rs @@ -0,0 +1,175 @@ +//! Origin validation + CORS header shaping. +//! +//! the native local-API contract calls +//! this surface "the crown jewels" (chat + org data + process spawn) and is +//! explicit that local-api MUST NOT reproduce the daemon's unconditional +//! `Access-Control-Allow-Origin: *` (`daemon/routes/body-parser.ts`'s +//! `jsonResponse()`) or its `CORS_HEADERS` blanket preflight constant +//! (`entry.ts`). The rule implemented here is the contract's, not the +//! daemon's: +//! - No `Origin` header at all → allowed (same-machine CLI/test harness; +//! `apps/native/e2e` and the daemon-parity oracle both drive requests +//! this way, via Node's `fetch`, which never sets `Origin`). +//! - `Origin` present and on the allowlist → allowed, ECHO that exact +//! origin back (never `*`). +//! - `Origin` present and NOT on the allowlist → `403 forbidden_origin`, +//! checked before auth. +//! +//! `ApiMode::DaemonCompat` (opt-in via `LOCAL_API_MODE=daemon-compat`, see +//! `state.rs`) exists ONLY to widen the origin allowlist check for the +//! parity-oracle CI job (so a wider slice of `daemon.e2e.test.ts`'s CORS +//! assertions can be pointed at this binary for measurement) — it never +//! widens auth, and it still never emits a wildcard +//! `Access-Control-Allow-Origin`. `ApiMode::Strict` (the default, and the +//! only mode the shipped Tauri app ever runs in) is what's described above. +//! See the native module-ownership contract for why the two suites, not +//! this comment, are the actual arbiters of this behavior. + +use axum::http::{HeaderMap, HeaderValue}; + +use crate::state::ApiMode; + +/// Fixed allowlist for the app's own Tauri origin(s). Sandbox previews use +/// `http://localhost:` on a separate unauthenticated listener; +/// that origin is intentionally never accepted by the main API listener. +pub fn allowed_origins() -> &'static [&'static str] { + &["tauri://localhost", "https://tauri.localhost"] +} + +/// In DEBUG builds only, `tauri dev` (`bun run dev:native`) serves the frontend +/// from its built-in dev server (`http://127.0.0.1:`, e.g. 1430) instead +/// of the packaged app's `tauri://localhost`, so the webview's `Origin` is a +/// loopback http URL that isn't on [`allowed_origins`]. Allow those in dev so +/// the dev loop works end-to-end; the release build +/// (`cfg(not(debug_assertions))`) keeps the strict `tauri://` allowlist, so a +/// distributed app never accepts an arbitrary loopback origin. +fn is_dev_loopback_origin(origin: &str) -> bool { + origin.starts_with("http://127.0.0.1:") || origin.starts_with("http://localhost:") +} + +pub struct OriginDecision { + pub allowed: bool, + /// The exact origin to echo in `Access-Control-Allow-Origin`, if any. + /// `None` when there was no `Origin` header to begin with (same-origin + /// / non-browser caller — no CORS header is needed at all). + pub echo_origin: Option, +} + +pub fn validate(headers: &HeaderMap, mode: ApiMode) -> OriginDecision { + let origin = headers + .get(axum::http::header::ORIGIN) + .and_then(|v| v.to_str().ok()); + match origin { + None => OriginDecision { + allowed: true, + echo_origin: None, + }, + Some(o) => { + let on_allowlist = allowed_origins().contains(&o); + // `daemon-compat` widens the ALLOWLIST CHECK ONLY (for the + // parity-oracle job's convenience) — it never affects whether a + // wildcard is emitted; see the module doc comment. + let allowed = on_allowlist + || mode == ApiMode::DaemonCompat + || (cfg!(debug_assertions) && is_dev_loopback_origin(o)); + OriginDecision { + allowed, + echo_origin: if allowed { Some(o.to_string()) } else { None }, + } + } + } +} + +/// Apply the resolved decision's headers to an outgoing response. Never +/// inserts a wildcard. No-ops when `echo_origin` is `None` (nothing to +/// add). Callers should still explicitly NOT call this at all for the +/// reverse-proxy family's responses (byte-parity: the daemon doesn't gate +/// or CORS-decorate proxied responses either — that traffic is the app +/// under test being previewed, not the control API). +pub fn apply_headers(headers: &mut HeaderMap, decision: &OriginDecision) { + if let Some(origin) = &decision.echo_origin { + if let Ok(v) = HeaderValue::from_str(origin) { + headers.insert(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, v); + headers.insert(axum::http::header::VARY, HeaderValue::from_static("Origin")); + // Cross-origin JS cannot READ response headers unless exposed. + // The webview -> local-api hop is always cross-origin (unlike + // prod web, where these calls are same-origin and never hit + // this rule): the MCP streamable-HTTP client must read + // `Mcp-Session-Id`, and OAuth discovery reads + // `WWW-Authenticate` (mesh exposes the latter itself — + // apps/api/src/api/app.ts's exposeHeaders). + headers.insert( + axum::http::header::ACCESS_CONTROL_EXPOSE_HEADERS, + HeaderValue::from_static("Mcp-Session-Id, WWW-Authenticate"), + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::{HeaderMap, HeaderValue}; + + #[test] + fn no_origin_header_is_allowed_with_no_echo() { + let decision = validate(&HeaderMap::new(), ApiMode::Strict); + assert!(decision.allowed); + assert!(decision.echo_origin.is_none()); + } + + #[test] + fn allowlisted_origin_is_echoed_verbatim() { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::ORIGIN, + HeaderValue::from_static("tauri://localhost"), + ); + let decision = validate(&headers, ApiMode::Strict); + assert!(decision.allowed); + assert_eq!(decision.echo_origin.as_deref(), Some("tauri://localhost")); + } + + #[test] + fn unknown_origin_is_rejected_in_strict_mode() { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::ORIGIN, + HeaderValue::from_static("https://evil.example"), + ); + let decision = validate(&headers, ApiMode::Strict); + assert!(!decision.allowed); + assert!(decision.echo_origin.is_none()); + } + + #[test] + fn dev_loopback_origin_allowed_only_in_debug_builds() { + // `tauri dev` (`bun run dev:native`) serves the frontend from + // `http://127.0.0.1:` (e.g. 1430), unlike the packaged app's + // `tauri://localhost` — so it must be accepted in DEBUG builds (where + // this test runs) but rejected in a release build (strict `tauri://`). + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::ORIGIN, + HeaderValue::from_static("http://127.0.0.1:1430"), + ); + assert_eq!( + validate(&headers, ApiMode::Strict).allowed, + cfg!(debug_assertions), + ); + } + + #[test] + fn apply_headers_never_emits_a_wildcard() { + let mut headers = HeaderMap::new(); + let decision = OriginDecision { + allowed: true, + echo_origin: Some("tauri://localhost".to_string()), + }; + apply_headers(&mut headers, &decision); + assert_eq!( + headers.get(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN), + Some(&HeaderValue::from_static("tauri://localhost")) + ); + } +} diff --git a/apps/native/crates/local-api/src/error.rs b/apps/native/crates/local-api/src/error.rs new file mode 100644 index 0000000000..6ebe3431ca --- /dev/null +++ b/apps/native/crates/local-api/src/error.rs @@ -0,0 +1,156 @@ +//! The daemon-parity JSON error envelope. +//! +//! Every route in `packages/sandbox/daemon/routes/*.ts` responds to a +//! rejected request with `{ "error": "" }`, sometimes with extra +//! fields (`detail`, `notReady`, `available`) — see +//! the native local-API contract. `ApiError` is +//! the ONE type every handler in this crate returns on the error path so +//! that shape never drifts between families. Route handlers should return +//! `Result, ApiError>` (or `Result` for +//! non-JSON success bodies like SSE/204/empty) and use the constructors +//! below rather than building `(StatusCode, Json(..))` tuples by hand. + +// Most constructors below (and `ApiResult`) aren't called yet — every route +// handler in `routes/*.rs` is still a bare `ApiError::not_implemented(..)` +// stub. They're reserved for the families that replace those stubs (see +// the native module-ownership contract), not dead in the "should be +// deleted" sense. +#![allow(dead_code)] + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::{json, Value}; + +#[derive(Debug)] +pub struct ApiError { + pub status: StatusCode, + pub body: Value, +} + +impl ApiError { + /// `{ "error": "" }` at an arbitrary status. The building + /// block every other constructor is sugar over. + pub fn new(status: StatusCode, error: impl Into) -> Self { + Self { + status, + body: json!({ "error": error.into() }), + } + } + + /// `{ "error": "", ...extra }` — `extra` must be a JSON + /// object; its keys are merged alongside `error` (contract: "Some + /// routes add fields alongside `error`", e.g. `detail`/`notReady`/ + /// `available`). + pub fn with_extra(status: StatusCode, error: impl Into, extra: Value) -> Self { + let mut body = json!({ "error": error.into() }); + if let (Some(base), Value::Object(more)) = (body.as_object_mut(), extra) { + base.extend(more); + } + Self { status, body } + } + + // --- Shapes pinned by name across the contract + both e2e suites ------- + + /// 401 — byte-parity with `auth.ts::requireToken`. Missing header, + /// wrong scheme, or mismatched token all resolve here (see `auth.rs`). + pub fn unauthorized() -> Self { + Self::new(StatusCode::UNAUTHORIZED, "unauthorized") + } + + /// 403 — Origin header present but not on the allowlist. Checked + /// BEFORE auth (contract: "fail fast, don't waste a timing + /// side-channel on the bearer compare"). + pub fn forbidden_origin() -> Self { + Self::new(StatusCode::FORBIDDEN, "forbidden_origin") + } + + /// 404 — generic "no such route" for both the `/_sandbox/*` catch-all + /// (`Not found: `, byte-parity with the daemon's `vmRouteH` + /// default branch) and any family's own "no such id" case. + pub fn not_found(error: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, error) + } + + pub fn bad_request(error: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, error) + } + + /// 400 `{"error":"bad_input","detail":""}` — the + /// dispatch route's schema-validation-failure shape specifically + /// (contract's error-envelope table). + pub fn bad_input(detail: impl Into) -> Self { + Self::with_extra( + StatusCode::BAD_REQUEST, + "bad_input", + json!({ "detail": detail.into() }), + ) + } + + /// 400 with a `detail` field, for the other "unknown_*"/"missing_*" + /// dispatch-gate errors that also carry extra context. + pub fn bad_request_with_detail(error: impl Into, detail: impl Into) -> Self { + Self::with_extra( + StatusCode::BAD_REQUEST, + error, + json!({ "detail": detail.into() }), + ) + } + + /// 409 `{"error":..., "notReady": true}` — git's "repository not + /// initialized" gate and analogous "not ready yet, retry" cases. + pub fn not_ready(error: impl Into) -> Self { + Self::with_extra(StatusCode::CONFLICT, error, json!({ "notReady": true })) + } + + /// 409 without the `notReady` marker — e.g. config's immutable-field + /// rejection, exec's "no package manager configured yet". + pub fn conflict(error: impl Into) -> Self { + Self::new(StatusCode::CONFLICT, error) + } + + /// 410 — dispatch's post-cancel tombstone window. + pub fn gone(error: impl Into) -> Self { + Self::new(StatusCode::GONE, error) + } + + pub fn payload_too_large(error: impl Into) -> Self { + Self::new(StatusCode::PAYLOAD_TOO_LARGE, error) + } + + /// 502 — upstream/dev-server unreachable (reverse proxy, the app-API + /// proxy, tools/sync). + pub fn bad_gateway(error: impl Into) -> Self { + Self::new(StatusCode::BAD_GATEWAY, error) + } + + /// 500 — reserved for local-api's own bugs (contract: never used for + /// "the caller did something wrong"). + pub fn internal(error: impl Into) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, error) + } + + /// 501 — the bootstrap skeleton's placeholder for every route a family + /// hasn't implemented yet. `route` should be `"METHOD /path"` so a 501 + /// response is self-describing during Phase 1 development. A family + /// MUST replace every `not_implemented` call in the files it owns + /// before that family's e2e subset is expected to go green. + pub fn not_implemented(route: impl Into) -> Self { + Self::new( + StatusCode::NOT_IMPLEMENTED, + format!("not implemented: {}", route.into()), + ) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.status, Json(self.body)).into_response() + } +} + +/// Convenience alias family handlers use in signatures: +/// `async fn handler(..) -> ApiResult`. +pub type ApiResult = Result; diff --git a/apps/native/crates/local-api/src/events/broadcaster.rs b/apps/native/crates/local-api/src/events/broadcaster.rs new file mode 100644 index 0000000000..f1fcef7c26 --- /dev/null +++ b/apps/native/crates/local-api/src/events/broadcaster.rs @@ -0,0 +1,95 @@ +//! `Broadcaster` — the SSE fan-out hub, byte-parity in spirit with +//! `packages/sandbox/daemon/events/broadcast.ts`. +//! +//! Ownership split (see the native module-ownership contract): EVERY +//! family may call `emit()` to publish a named event (bash background -> +//! `"tasks"`, git branch changes -> `"branch"`, config transitions -> +//! `"lifecycle"`, file writes -> `"file-changed"`/`"reload"`, scripts +//! discovery -> `"scripts"`). Only the `events` family +//! (`routes/events.rs`) calls `subscribe()` — it owns turning the broadcast +//! stream into the actual `/_sandbox/events` SSE response (framing via +//! `event: \ndata: \n\n`, replay-on-connect, the 15s heartbeat). +//! No other module should hold a `Receiver`. +//! +//! Nothing calls `emit()`/`subscribe()` yet (no route handler is +//! implemented past a 501 stub) — hence the crate-unusual `dead_code` +//! allow below. Remove it naturally the moment the first family wires a +//! call site; don't carry it forward once that happens. +#![allow(dead_code)] + +use serde_json::Value; +use tokio::sync::broadcast; + +/// Generous enough that a burst of task/log events between two SSE +/// `poll()`s on a slow consumer doesn't lag-drop before the events route +/// has a chance to read them. The `events` family should treat a +/// `RecvError::Lagged` from its receiver as "resync via a fresh replay", +/// not a fatal stream error — see `tokio::sync::broadcast`'s docs. +const DEFAULT_CAPACITY: usize = 1024; + +#[derive(Debug, Clone)] +pub struct Event { + pub name: String, + pub data: Value, +} + +pub struct Broadcaster { + tx: broadcast::Sender, +} + +impl Broadcaster { + pub fn new() -> Self { + let (tx, _rx) = broadcast::channel(DEFAULT_CAPACITY); + Self { tx } + } + + /// Subscribe to the live event stream from this point forward. Reserved + /// for the `events` family's SSE handler — see the module doc comment. + pub fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } + + /// Fan `data` out to every currently-connected `/_sandbox/events` + /// client under event name `name`. A no-op when nobody is subscribed + /// (mirrors the daemon's `Broadcaster.emit`, which silently drops + /// events with no listeners rather than buffering them) — callers + /// never need to check "is anyone listening" first. + pub fn emit(&self, name: &str, data: Value) { + let _ = self.tx.send(Event { + name: name.to_string(), + data, + }); + } + + /// Current subscriber count — useful for tests/health introspection. + pub fn subscriber_count(&self) -> usize { + self.tx.receiver_count() + } +} + +impl Default for Broadcaster { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn emit_reaches_a_live_subscriber() { + let b = Broadcaster::new(); + let mut rx = b.subscribe(); + b.emit("status", serde_json::json!({"state": "running"})); + let evt = rx.recv().await.expect("event delivered"); + assert_eq!(evt.name, "status"); + assert_eq!(evt.data["state"], "running"); + } + + #[test] + fn emit_with_no_subscribers_does_not_panic() { + let b = Broadcaster::new(); + b.emit("status", serde_json::json!({})); + } +} diff --git a/apps/native/crates/local-api/src/events/mod.rs b/apps/native/crates/local-api/src/events/mod.rs new file mode 100644 index 0000000000..1612499bb1 --- /dev/null +++ b/apps/native/crates/local-api/src/events/mod.rs @@ -0,0 +1,8 @@ +pub mod broadcaster; +pub mod snapshot; + +// `Broadcaster` is consumed via `AppState::broadcaster` (see `state.rs`). +// `Event` itself (the `subscribe()` receiver's item type) is read structurally +// (`ev.name`/`ev.data`) by `routes/events.rs` without ever naming the type, +// so it stays unexported here rather than re-exported-but-unused. +pub use broadcaster::Broadcaster; diff --git a/apps/native/crates/local-api/src/events/snapshot.rs b/apps/native/crates/local-api/src/events/snapshot.rs new file mode 100644 index 0000000000..b9efba9b4a --- /dev/null +++ b/apps/native/crates/local-api/src/events/snapshot.rs @@ -0,0 +1,151 @@ +//! Pure SSE snapshot/frame-format logic for the `events` family, kept +//! separate from `routes/events.rs`'s async plumbing so it's covered by +//! plain `#[cfg(test)]` unit tests — no server, no tokio runtime needed. +//! +//! Byte-parity targets: `daemon/events/sse-format.ts` (frame encoding), +//! `daemon/events/types.ts` (`DaemonEventMap` member shapes), +//! `daemon/entry.ts::getActiveTasks` (the active-task projection). + +use bytes::Bytes; +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::tasks::TaskSummary; + +/// `event: \ndata: \n\n` — byte-parity with `sseFormat()` +/// (`daemon/events/sse-format.ts`). `data` is any pre-built JSON value; a +/// serialization failure can't happen for the `Value`s this module builds +/// (no floats/maps with non-string keys), but falls back to `null` rather +/// than panicking on the request path if it ever did. +pub fn frame(name: &str, data: &Value) -> Bytes { + let payload = serde_json::to_string(data).unwrap_or_else(|_| "null".to_string()); + Bytes::from(format!("event: {name}\ndata: {payload}\n\n")) +} + +/// Wraps a `crate::setup::SetupOrchestrator::lifecycle_snapshot()` phase +/// object in the `{"state": ...}` envelope `DaemonEventMap["lifecycle"]` +/// pins — byte-parity with `daemon/events/types.ts::LifecycleState`. The +/// orchestrator is local-api's real clone -> install -> start pipeline +/// (KEPT, not dropped — see `crate::setup`'s module doc), so `phase` is +/// exactly one of `idle`/`cloning`/`checking-out`/`clone-failed`/ +/// `installing`/`install-failed`/`starting`/`running`/`start-failed`/ +/// `crashed`, not a synthetic idle/running collapse. +pub fn lifecycle(phase: Value) -> Value { + json!({ "state": phase }) +} + +/// `local-api` has no pause/crash trigger wired yet (Phase 1) — always +/// `{state:"running"}`, no `reason`. Byte-parity shape with `DaemonStatus` +/// (`daemon/events/types.ts`); `daemon.sse-shapes.e2e.test.ts`'s "status" +/// oracle only pins this shape (plus "no unexpected keys"), not a +/// mechanism to ever leave `"running"`. +pub fn status() -> Value { + json!({ "state": "running" }) +} + +/// Byte-parity projection with `entry.ts::getActiveTasks`, which maps the +/// full `TaskSummary` down to just `{id, command, logName?}` for the SSE +/// wire. Callers pass an already-`Running`-filtered list (see +/// `TaskRegistry::list`) — this function does no filtering itself. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ActiveTask<'a> { + id: &'a str, + command: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + log_name: Option<&'a str>, +} + +pub fn active_tasks(running: &[TaskSummary]) -> Value { + let active: Vec> = running + .iter() + .map(|t| ActiveTask { + id: &t.id, + command: &t.command, + log_name: t.log_name.as_deref(), + }) + .collect(); + json!({ "active": active }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tasks::TaskStatus; + + fn task(id: &str, command: &str, log_name: Option<&str>) -> TaskSummary { + TaskSummary { + id: id.to_string(), + command: command.to_string(), + status: TaskStatus::Running, + exit_code: None, + started_at: 0, + finished_at: None, + timed_out: false, + truncated: false, + log_name: log_name.map(str::to_string), + intentional: None, + } + } + + #[test] + fn frame_matches_daemon_wire_format() { + let f = frame("status", &json!({"state":"running"})); + assert_eq!( + std::str::from_utf8(&f).unwrap(), + "event: status\ndata: {\"state\":\"running\"}\n\n" + ); + } + + #[test] + fn frame_ends_with_blank_line_separator() { + let f = frame("lifecycle", &json!({"state":{"phase":"idle"}})); + let text = std::str::from_utf8(&f).unwrap(); + assert!(text.starts_with("event: lifecycle\ndata: ")); + assert!(text.ends_with("\n\n")); + assert_eq!(text.matches('\n').count(), 3); + } + + #[test] + fn lifecycle_wraps_the_given_phase_in_a_state_envelope() { + assert_eq!( + lifecycle(json!({"phase": "idle"})), + json!({"state": {"phase": "idle"}}) + ); + assert_eq!( + lifecycle(json!({"phase": "running", "port": 3000, "htmlSupport": false})), + json!({"state": {"phase": "running", "port": 3000, "htmlSupport": false}}) + ); + } + + #[test] + fn status_is_always_running_with_no_extra_keys() { + let s = status(); + assert_eq!(s, json!({"state": "running"})); + assert_eq!(s.as_object().unwrap().len(), 1); + } + + #[test] + fn active_tasks_empty_list_is_empty_array() { + assert_eq!(active_tasks(&[]), json!({"active": []})); + } + + #[test] + fn active_tasks_projects_id_command_lognamed() { + let tasks = vec![task("task1", "sleep 10", Some("dev"))]; + assert_eq!( + active_tasks(&tasks), + json!({"active": [{"id":"task1","command":"sleep 10","logName":"dev"}]}) + ); + } + + #[test] + fn active_tasks_omits_lognamed_key_when_absent() { + let tasks = vec![task("task1", "sleep 10", None)]; + let value = active_tasks(&tasks); + let entry = &value["active"][0]; + assert!(entry.get("logName").is_none()); + assert_eq!(entry["id"], "task1"); + assert_eq!(entry["command"], "sleep 10"); + } +} diff --git a/apps/native/crates/local-api/src/lib.rs b/apps/native/crates/local-api/src/lib.rs new file mode 100644 index 0000000000..f998ce93fe --- /dev/null +++ b/apps/native/crates/local-api/src/lib.rs @@ -0,0 +1,1059 @@ +//! `local-api` as a library — the in-process embedding entrypoint the +//! Tauri shell (`apps/native/src-tauri/`) links against directly (no +//! subprocess — see the desktop migration contract's "in-process +//! axum, no sidecar" decision and the native implementation contract §5). This +//! file is a MECHANICAL EXTRACTION of the boot sequence that used to live +//! entirely in `main.rs`'s `fn main()`: every side effect (dir creation, +//! `AppState` construction, router build, listener bind, +//! `routes::git::register`) is byte-for-byte unchanged, just reachable +//! from a function signature instead of only from a binary's entrypoint. +//! `main.rs` now calls into [`boot_from_env`] for exactly what its old +//! `main()` body did — the daemon-parity and local-api contract e2e +//! suites (which only ever observe the compiled `local-api` binary's +//! externally-visible behavior: env vars in, stdout/HTTP/SIGTERM out) +//! should see zero behavior change from this refactor. +//! +//! Added per the Phase 3 (Tauri shell) brief's explicit license: "you MAY +//! add a small pub entrypoint to crates/local-api ... keep it a +//! mechanical extraction." Two entrypoints: +//! +//! - [`boot_from_env`] — used by the `local-api` BINARY's `main()`. Reads +//! the full env contract (`LOCAL_API_TOKEN`/`_PORT`/`_WORKDIR`/ +//! `_BOOT_ID` + legacy `DAEMON_*`/`APP_ROOT`/`PROXY_PORT` aliases), +//! prints the `LOCAL_API_PORT=`/`LOCAL_API_PREVIEW_PORT=`/`LOCAL_API_TOKEN=` +//! stdout lines a test harness may discover, and serves until SIGTERM +//! (unix) / Ctrl+C (non-unix). Behaviorally identical to the old `main()` +//! body, plus the new preview-port line. +//! - [`start`] — used by the Tauri shell. Takes an explicit +//! [`StartOptions`] (no env vars, no stdout side effects — the shell +//! already knows its own per-launch token/port/workdir), binds BOTH the +//! main and preview listeners (the "port router" — see [`ServerHandle`]'s +//! doc comment), builds each router, spawns both serve loops in the +//! background, and returns a [`ServerHandle`] the caller drives: read +//! `.port()`/`.preview_port()`/`.state()`, and call `.shutdown()` on +//! window-close/app-exit. Does NOT install its own SIGTERM handler — the +//! embedding app owns its own process lifecycle (see +//! `src-tauri/src/local_api_embed.rs`). +//! +//! Shutdown is an explicit ordered pipeline: stop listener admission; close +//! request/setup admission; reap chat harnesses; concurrently TERM→KILL and +//! join every global/per-sandbox process owner; run git publish; perform one +//! final awaited registry sweep; then join (or abort+join) both axum tasks. +//! The app-root instance lock remains held until those joins complete. + +mod auth; +mod client_auth; +mod config; +mod cors; +mod error; +mod events; +mod log_store; +mod mutation; +mod process_group; +mod router; +mod routes; +mod sandbox; +mod setup; +mod shutdown; +mod state; +mod tasks; +mod ui; + +// Re-exported so `AppState`'s public fields name types reachable from +// outside this crate (Rust's `private_interfaces` lint would otherwise +// fire on a `pub` struct field whose type lives in a `mod` that isn't +// itself `pub` — these modules stay private; only the specific types an +// embedder needs are named here). +pub use config::ConfigStore; +pub use events::Broadcaster; +pub use sandbox::SandboxManager; +pub use setup::SetupOrchestrator; +pub use shutdown::{ShutdownCoordinator, ShutdownHook}; +pub use state::{ApiMode, AppState}; +pub use tasks::{KillSignal, TaskRegistry}; +pub use ui::{UiAsset, UiAssetProvider}; + +use std::fs::{File, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::net::TcpListener; +use tokio::sync::Notify; + +/// Explicit boot parameters for [`start`] — the Tauri-shell embedding +/// path. Unlike [`boot_from_env`], nothing here is read from process env; +/// the caller (the shell) already resolved its own per-launch +/// token/port/root before calling in. +pub struct StartOptions { + pub token: String, + pub boot_id: String, + /// `LOCAL_API_WORKDIR` equivalent — the fs/bash/git clamp root and the + /// parent of the `.decocms/` local thread-store directory. + pub app_root: PathBuf, + /// TCP port to bind; `0` picks an OS-assigned ephemeral port (read + /// back via [`ServerHandle::port`]). + pub port: u16, + pub mode: ApiMode, +} + +/// Browser-facing authentication and optional bundled-UI configuration for an +/// in-process Tauri server. `expected_host` and `control_origin` are explicit +/// because development traffic can arrive through Vite (`:4420`) while Rust +/// itself listens on another port (`:43121`). +pub struct EmbeddedOptions { + pub expected_host: String, + pub control_origin: String, + /// Extra one-time capabilities for additional windows. `StartOptions.token` + /// is always included as the first bootstrap secret. + pub additional_bootstrap_secrets: Vec, + pub ui_assets: Option>, + /// Mounts the credentialed preview-cookie round-trip probe used by the + /// real WKWebView boot smoke. Disabled by default and never controlled by + /// an HTTP query parameter or other runtime request input. + pub preview_cookie_selftest: bool, +} + +impl EmbeddedOptions { + pub fn new(expected_host: impl Into, control_origin: impl Into) -> Self { + Self { + expected_host: expected_host.into(), + control_origin: control_origin.into(), + additional_bootstrap_secrets: Vec::new(), + ui_assets: None, + preview_cookie_selftest: false, + } + } +} + +/// Selects the caller-auth contract without weakening the existing standalone +/// binary. The `local-api` executable and [`start`] remain bearer-only. +pub enum ClientAuthMode { + Bearer, + Embedded(EmbeddedOptions), +} + +/// Boot-time failure from [`start`]. Deliberately NOT a process-exit call +/// (unlike the binary's [`boot_from_env`]) — an embedder like the Tauri +/// shell must be able to surface this as a UI error rather than have the +/// whole app process die. +#[derive(Debug, thiserror::Error)] +pub enum StartError { + #[error("failed to create repo dir {path:?}: {source}")] + RepoDir { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to create {path:?}: {source}")] + DecocmsDir { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("another local-api instance already owns {path:?}: {source}")] + InstanceLock { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("prior local-api children did not release {path:?}: {source}")] + ChildLifetimeFence { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to recover filesystem mutations under {path:?}: {source}")] + MutationRecovery { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to bind 127.0.0.1:{port}: {source}")] + Bind { + port: u16, + #[source] + source: std::io::Error, + }, + #[error("failed to recover the local chat store: {message}")] + LocalStore { message: String }, + #[error("invalid embedded client-auth configuration: {message}")] + EmbeddedAuth { message: String }, +} + +/// A running server the caller owns the lifecycle of. TWO listeners run +/// under one `ServerHandle` — see the module doc's port-router summary and +/// `router.rs`'s module doc for the exact route split: +/// +/// - the MAIN listener ([`ServerHandle::port`]) — `/health`, `/_sandbox/*`, +/// `/threads*`, `/models`, and the app-API intercept-or-proxy fallback +/// (bearer + Origin gated). +/// - the PREVIEW listener ([`ServerHandle::preview_port`]) — ONLY the +/// reverse-proxy-to-dev-server fallback (`routes::proxy::fallback`), no +/// auth at all. Moved to its own loopback port (rather than sharing the +/// main port behind a `/upstream`-style prefix) so the previewed app runs +/// on a genuinely separate origin from the app's own API — verified via a +/// spike that SSE + WebSocket both stream cleanly through a port-isolated +/// cross-origin iframe in the real WKWebView, unlike the `*.localhost` +/// subdomain approach. +/// +/// Each listener's serve loop runs on its own `tokio::spawn`'d task; dropping +/// a `ServerHandle` WITHOUT calling [`ServerHandle::shutdown`] leaves both +/// tasks running detached (nothing calls `abort()` on drop by design). An +/// unclean process exit, e.g. `SIGKILL`, bypasses git publish and every local +/// cleanup hook; harnesses have their own parent-death watchdog, but callers +/// must use `shutdown()` for the complete ordered reap/publish/drain contract. +pub struct ServerHandle { + port: u16, + preview_port: u16, + state: AppState, + shutdown_notify: Arc, + preview_shutdown_notify: Arc, + serve_task: tokio::task::JoinHandle<()>, + preview_serve_task: tokio::task::JoinHandle<()>, + /// Kernel-held advisory lock for this app root. The file handle—not a + /// stale PID marker—is the lifetime fence: a second process cannot run + /// queue recovery against live harness work, and the OS releases it even + /// after SIGKILL. Graceful shutdown releases it only after every mutation + /// and process owner proves quiescence; on a failed proof the handle is + /// intentionally retained until OS process exit instead of permitting an + /// unsafe same-root restart. + _instance_lock: Arc, +} + +/// How long `shutdown()` waits for in-flight connections to drain before +/// abandoning the wait and letting the process exit. The frontend's +/// long-lived `/_sandbox/events` SSE stream never completes on its own — it +/// ends only when the webview is torn down, which is AFTER we return — so an +/// unbounded wait deadlocks app quit. +const SHUTDOWN_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); +const HARNESS_REAP_TIMEOUT: Duration = Duration::from_secs(5); +const FS_MUTATION_REAP_TIMEOUT: Duration = Duration::from_secs(3); +const CHILD_TERM_GRACE: Duration = Duration::from_secs(2); +const CHILD_KILL_GRACE: Duration = Duration::from_secs(2); +/// Parent-death watchdogs normally finish in roughly one second. Keep startup +/// bounded while allowing generous scheduler/CI slack; expiration fails boot +/// closed instead of recovering durable work beside an indeterminate old +/// child. +const CHILD_CRASH_REAP_TIMEOUT: Duration = Duration::from_secs(15); + +impl ServerHandle { + pub fn port(&self) -> u16 { + self.port + } + + /// The dedicated loopback port serving ONLY the dev-server reverse + /// proxy — see this struct's doc comment. A caller (the Tauri shell, an + /// e2e harness) points a preview iframe/request at + /// `http://localhost:/`, never at [`Self::port`]. + pub fn preview_port(&self) -> u16 { + self.preview_port + } + + pub fn state(&self) -> &AppState { + &self.state + } + + /// Graceful shutdown. Consumes `self` because each listener task and the + /// app-root lock have one lifecycle owner. + pub async fn shutdown(self) { + let ServerHandle { + state, + shutdown_notify, + preview_shutdown_notify, + mut serve_task, + mut preview_serve_task, + _instance_lock, + .. + } = self; + + // Listener shutdown is phase zero: stop accepting HTTP immediately. + // Long-lived responses (notably events SSE) are allowed a bounded + // drain at the end and forcibly closed if they outlive it. + shutdown_notify.notify_one(); + preview_shutdown_notify.notify_one(); + + // Close every source of new side-effecting work before taking any + // process snapshot. The coordinator waits for already-admitted + // spawn+registry critical sections; setup closes are synchronous. + let admission_fence = state.shutdown.begin_shutdown(); + let mutations = state.shutdown.mutations(); + let mutation_fence = mutations.begin_shutdown(FS_MUTATION_REAP_TIMEOUT); + state.setup.close(); + state.sandbox_manager.begin_shutdown(); + let ((), mutation_quiescence) = tokio::join!(admission_fence, mutation_fence); + if !mutation_quiescence.is_quiescent() { + tracing::error!( + owners_remaining = mutation_quiescence.owners_remaining, + commit_quiescent = mutation_quiescence.commit_quiescent, + "shutdown: filesystem mutation owners did not quiesce" + ); + } + + // Chat harnesses own process state outside TaskRegistry. Reap both + // families concurrently before setup/git can observe their worktrees. + let (decopilot_stopped, dispatch_stopped) = tokio::join!( + routes::intercept::decopilot::shutdown_all(HARNESS_REAP_TIMEOUT), + routes::dispatch::shutdown_all(HARNESS_REAP_TIMEOUT), + ); + if !decopilot_stopped { + tracing::error!("shutdown: one or more Decopilot queues did not stop in time"); + } + if !dispatch_stopped { + tracing::error!("shutdown: one or more legacy dispatches did not stop in time"); + } + + // Each setup shutdown internally snapshots once, signals every child + // concurrently, waits one shared TERM grace, then escalates the + // remaining subset under one shared KILL grace. Global and all + // per-sandbox registries run concurrently too. + let (global_setup, sandbox_setups) = tokio::join!( + state.setup.shutdown(CHILD_TERM_GRACE, CHILD_KILL_GRACE), + state + .sandbox_manager + .shutdown_all(CHILD_TERM_GRACE, CHILD_KILL_GRACE), + ); + let global_setup_stopped = + global_setup.worker_stopped && global_setup.tasks.remaining.is_empty(); + if !global_setup_stopped { + tracing::error!( + remaining = ?global_setup.tasks.remaining, + worker_stopped = global_setup.worker_stopped, + "shutdown: global setup did not fully stop" + ); + } + let mut sandbox_setups_stopped = true; + for sandbox in &sandbox_setups { + if !sandbox.result.tasks.remaining.is_empty() || !sandbox.result.worker_stopped { + sandbox_setups_stopped = false; + tracing::error!( + handle = %sandbox.handle, + remaining = ?sandbox.result.tasks.remaining, + worker_stopped = sandbox.result.worker_stopped, + "shutdown: sandbox setup did not fully stop" + ); + } + } + + // Defense-in-depth for global, non-setup request tasks: admission is + // already closed, so this final snapshot is complete and awaited. It + // runs BEFORE publish: a task that survives means quiescence was not + // proven and git must not race it. + let final_tasks = state + .tasks + .kill_all_and_wait(CHILD_TERM_GRACE, CHILD_KILL_GRACE) + .await; + let final_tasks_stopped = final_tasks.remaining.is_empty(); + if !final_tasks_stopped { + tracing::error!( + remaining = ?final_tasks.remaining, + "shutdown: global task registry did not fully stop" + ); + } + + // Only the git publish hook remains registered. Publishing while any + // child family is merely "timed out" would trade shutdown latency for + // silent worktree corruption, so skip it unless EVERY owner proved + // quiescence. Listener/serve cleanup still continues either way. + let process_tree_quiescent = decopilot_stopped + && dispatch_stopped + && global_setup_stopped + && sandbox_setups_stopped + && final_tasks_stopped + && mutation_quiescence.is_quiescent(); + if process_tree_quiescent { + state.shutdown.run().await; + } else { + tracing::error!( + "shutdown: skipping git publish because child quiescence was not proven" + ); + } + + // A publish hook owns its Git process tree through a hidden registry + // entry. If the coordinator's bounded hook budget expires, it drops + // only the hook waiter; the detached owner remains enumerable here. + // Attempt the same bounded TERM -> KILL + join policy as every other + // process family, and report any owner that still cannot prove exit. + let post_publish_tasks = state + .tasks + .kill_all_and_wait(CHILD_TERM_GRACE, CHILD_KILL_GRACE) + .await; + let post_publish_stopped = post_publish_tasks.remaining.is_empty(); + if !post_publish_stopped { + tracing::error!( + remaining = ?post_publish_tasks.remaining, + "shutdown: post-publish git owners did not fully stop" + ); + } + + // `with_graceful_shutdown` waits for every in-flight response. The + // events SSE stream can intentionally live forever, so poll each + // JoinHandle until one shared deadline and remember which handles + // completed. A JoinHandle that already returned Ready MUST NOT be + // polled again (Tokio panics); this explicit loop avoids the former + // `timeout(join!(...))` bug where one listener completed, the other + // timed out, and cleanup awaited the completed handle a second time. + let drain_deadline = tokio::time::Instant::now() + SHUTDOWN_DRAIN_TIMEOUT; + let mut serve_done = false; + let mut preview_done = false; + while !serve_done || !preview_done { + tokio::select! { + _ = &mut serve_task, if !serve_done => serve_done = true, + _ = &mut preview_serve_task, if !preview_done => preview_done = true, + _ = tokio::time::sleep_until(drain_deadline) => break, + } + } + if !serve_done || !preview_done { + tracing::warn!( + "shutdown: graceful drain exceeded {SHUTDOWN_DRAIN_TIMEOUT:?} \ + (long-lived stream still open) — aborting serve tasks" + ); + if !serve_done { + serve_task.abort(); + let _ = serve_task.await; + } + if !preview_done { + preview_serve_task.abort(); + let _ = preview_serve_task.await; + } + } + + // Explicitly last: serve tasks also held clones and have now been + // joined. Release the app-root fence only when every side-effecting + // owner proved quiescence. If a cancellation-resistant owner remains, + // retain this final Arc until OS process exit: graceful shutdown stays + // bounded, but another server in this process cannot acquire the same + // root and race the old owner. The OS still closes the descriptor on + // normal exit, SIGTERM escalation, or SIGKILL. + if process_tree_quiescent && post_publish_stopped { + drop(_instance_lock); + } else { + tracing::error!( + "shutdown: retaining app-root lock until process exit because owner quiescence was not proven" + ); + std::mem::forget(_instance_lock); + } + } +} + +/// Builds `AppState`, the router, binds the listener, and spawns the +/// server in the background. See the module doc comment for the shutdown +/// hooks this registers. +pub async fn start(opts: StartOptions) -> Result { + start_with_client_auth(opts, ClientAuthMode::Bearer).await +} + +/// Embedded counterpart to [`start`]. The standalone/binary path never calls +/// this and therefore keeps its byte-compatible bearer contract. +pub async fn start_embedded( + opts: StartOptions, + embedded: EmbeddedOptions, +) -> Result { + start_with_client_auth(opts, ClientAuthMode::Embedded(embedded)).await +} + +pub async fn start_with_client_auth( + opts: StartOptions, + client_auth_mode: ClientAuthMode, +) -> Result { + let (client_auth, ui_assets, preview_cookie_selftest_origin) = match client_auth_mode { + ClientAuthMode::Bearer => ( + client_auth::ClientAuth::bearer(Arc::::from(opts.token.clone())), + None, + None, + ), + ClientAuthMode::Embedded(embedded) => { + let preview_cookie_selftest_origin = embedded + .preview_cookie_selftest + .then(|| Arc::::from(embedded.control_origin.clone())); + let mut bootstrap_secrets = + Vec::with_capacity(embedded.additional_bootstrap_secrets.len() + 1); + bootstrap_secrets.push(opts.token.clone()); + bootstrap_secrets.extend(embedded.additional_bootstrap_secrets); + let expected_host = embedded.expected_host.clone(); + let control_origin = embedded.control_origin.clone(); + let auth = client_auth::ClientAuth::embedded( + embedded.expected_host, + embedded.control_origin, + bootstrap_secrets, + ) + .map_err(|message| StartError::EmbeddedAuth { message })?; + // The org-filesystem mounts run `rclone` as a child, and it has to + // satisfy this same guard without being a browser: exact Host, + // exact Origin, and the per-launch session cookie. Published once + // here rather than threaded through `AppState`, which this module + // family may not add fields to. + if let Some(token) = auth.session_token() { + sandbox::org_mount::set_credentials(sandbox::org_mount::MountCredentials { + base_url: format!("http://{expected_host}"), + origin: control_origin, + cookie: format!("{}={token}", client_auth::LOCAL_SESSION_COOKIE_NAME), + }); + } + (auth, embedded.ui_assets, preview_cookie_selftest_origin) + } + }; + + let repo_dir = opts.app_root.join("repo"); + // Byte-parity with the daemon's boot-time `mkdirSync(repoDir, { + // recursive: true })` — bash commands with the default cwd shouldn't + // ENOENT before anything has been written into the workspace yet. + std::fs::create_dir_all(&repo_dir).map_err(|source| StartError::RepoDir { + path: repo_dir.clone(), + source, + })?; + // `.decocms/` is local-api's OWN directory (distinct from a project's + // `.deco/tools/` catalog dir); the threads family's SQLite store lives + // under it. + let decocms_dir = opts.app_root.join(".decocms"); + std::fs::create_dir_all(&decocms_dir).map_err(|source| StartError::DecocmsDir { + path: decocms_dir.clone(), + source, + })?; + let instance_lock_path = decocms_dir.join("local-api.lock"); + let instance_lock = Arc::new( + acquire_instance_lock(&instance_lock_path).map_err(|source| StartError::InstanceLock { + path: instance_lock_path, + source, + })?, + ); + // The main instance lock above is deliberately first: a genuinely LIVE + // second process still fails immediately. After SIGKILL the OS releases + // that main lock, while independent watchdogs survive long enough to reap + // old harnesses/tasks. Every watchdog inherited a SHARED lock on this + // separate file; the successor waits for EXCLUSIVE ownership before any + // mutation/queue recovery can observe or promote durable work. + let child_lifetime_lock_path = decocms_dir.join("child-lifetime.lock"); + let child_recovery_fence = + acquire_child_recovery_fence(&child_lifetime_lock_path, CHILD_CRASH_REAP_TIMEOUT) + .await + .map_err(|source| StartError::ChildLifetimeFence { + path: child_lifetime_lock_path.clone(), + source, + })?; + // Staged downloads/catalog swaps and rename-to-trash deletions live + // outside the repository. A crash can leave them behind, so the process + // that owns the app-root lock recovers journaled catalog swaps and clears + // only abort-safe stages before accepting requests. In particular, a + // `previous-catalog` directory may be the only known-good copy and must + // never be blindly deleted. Never recover before acquiring the lock: a + // losing second instance must not touch the live owner's stages. + let mutation_stage_root = decocms_dir.join("mutations"); + routes::fs::recover_mutation_stages(&mutation_stage_root, &repo_dir) + .await + .map_err(|source| StartError::MutationRecovery { + path: mutation_stage_root, + source, + })?; + + let config = Arc::new(ConfigStore::new()); + // `/logs` — the durable, file-backed home for retained log + // output (see the `log_store` module doc: files are the source of truth, + // RAM holds only transient live fan-out). Sibling of `repo_dir` above, + // same as a per-handle `Sandbox`'s own `logs` dir is a sibling of its + // `repo` (see `sandbox/manager.rs::get_or_create`). + let logs = Arc::new(log_store::LogStore::new(opts.app_root.join("logs"))); + let tasks = Arc::new(TaskRegistry::new_with_child_lifetime_lock( + logs, + child_lifetime_lock_path.clone(), + )); + let broadcaster = Arc::new(Broadcaster::new()); + let setup_orchestrator = SetupOrchestrator::new( + repo_dir.clone(), + opts.app_root.clone(), + config.clone(), + tasks.clone(), + broadcaster.clone(), + ); + let shutdown = Arc::new(ShutdownCoordinator::new()); + // `/sandboxes//repo` — created lazily per handle by + // `SandboxManager::ensure`, not eagerly here (unlike `repo_dir` above, + // which every plain-path request needs immediately). + let sandbox_manager = SandboxManager::new(opts.app_root.clone()); + + let state = AppState { + token: opts.token.into(), + boot_id: opts.boot_id.into(), + app_root: opts.app_root, + repo_dir, + mode: opts.mode, + config, + tasks: tasks.clone(), + broadcaster, + shutdown: shutdown.clone(), + setup: setup_orchestrator, + sandbox_manager, + }; + + // Git family's SIGTERM/shutdown publish hook — see + // `routes/git.rs`'s module doc ("Shutdown-hook wiring"). + routes::git::register(&state); + + // Clear worktree registrations orphaned by a sandbox directory that + // vanished while the app was down; git would otherwise refuse to re-add a + // worktree at that path. Detached and best-effort — it only walks the repo + // store, so it must never delay serving. + let prune_root = state.app_root.clone(); + tokio::spawn(async move { + sandbox::repo_store::prune_all(&prune_root).await; + // Org mounts outlive the process that served them: a SIGKILLed app + // leaves the kernel holding NFS mounts backed by nothing, and the + // sandbox that owns each path can never be re-provisioned there until + // it is reclaimed. See `sandbox::org_mount`. + sandbox::org_mount::prune_stale_mounts(&prune_root).await; + }); + + let listener = TcpListener::bind(("127.0.0.1", opts.port)) + .await + .map_err(|source| StartError::Bind { + port: opts.port, + source, + })?; + let bound_port = listener.local_addr().map(|a| a.port()).unwrap_or(opts.port); + + // The preview listener always binds an EPHEMERAL port — there is no + // "requested preview port" the way `opts.port` can pin the main one; + // callers only ever learn it back via `ServerHandle::preview_port`. + let preview_listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .map_err(|source| StartError::Bind { port: 0, source })?; + let preview_port = preview_listener.local_addr().map(|a| a.port()).unwrap_or(0); + // Publish it for the SANDBOX_START intercept, which reports this listener + // as the sandbox's `previewUrl` (see that module's `preview_url`). + routes::intercept::set_preview_port(preview_port); + + // Both listeners are known-good before durable queued work is resumed, so + // a bind failure can never launch an agent in a process that won't serve. + // Recovery still runs before either router starts accepting requests. + // Release EXCLUSIVE immediately before recovery. Any harness or task that + // recovery spawns takes SHARED ownership through the same path before its + // command starts. With the main instance lock still held, no other process + // can enter this handoff. + drop(child_recovery_fence); + routes::intercept::decopilot::recover_durable_queue(&state) + .await + .map_err(|message| StartError::LocalStore { message })?; + + let app = router::build( + state.clone(), + client_auth, + ui_assets, + format!("http://localhost:{preview_port}"), + ); + let preview_app = + router::build_preview(state.clone(), preview_port, preview_cookie_selftest_origin); + + let shutdown_notify = Arc::new(Notify::new()); + let notify_for_task = shutdown_notify.clone(); + let main_instance_lock = instance_lock.clone(); + let serve_task = tokio::spawn(async move { + let _instance_lock = main_instance_lock; + let result = axum::serve(listener, app) + .with_graceful_shutdown(async move { + notify_for_task.notified().await; + }) + .await; + if let Err(err) = result { + tracing::error!(error = %err, "local-api server error"); + } + }); + + // A SEPARATE `Notify` (not the main listener's) — each is a one-shot, + // single-consumer signal (`notify_one` stores a permit for the first + // future `notified().await` even if called before that call starts + // waiting), so two listeners sharing ONE `Notify` would risk + // `notify_one()` waking only whichever task happened to be waiting + // first, leaving the other's serve loop running past shutdown. + let preview_shutdown_notify = Arc::new(Notify::new()); + let preview_notify_for_task = preview_shutdown_notify.clone(); + let preview_instance_lock = instance_lock.clone(); + let preview_serve_task = tokio::spawn(async move { + let _instance_lock = preview_instance_lock; + let result = axum::serve(preview_listener, preview_app) + .with_graceful_shutdown(async move { + preview_notify_for_task.notified().await; + }) + .await; + if let Err(err) = result { + tracing::error!(error = %err, "local-api preview server error"); + } + }); + + Ok(ServerHandle { + port: bound_port, + preview_port, + state, + shutdown_notify, + preview_shutdown_notify, + serve_task, + preview_serve_task, + _instance_lock: instance_lock, + }) +} + +fn acquire_instance_lock(path: &Path) -> std::io::Result { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + options.mode(0o600); + let file = options.open(path)?; + // `mode` applies only on create; clamp a pre-existing permissive file + // before locking so the ownership fence never leaks app metadata. + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + file.try_lock()?; + Ok(file) + } + #[cfg(not(unix))] + { + let file = options.open(path)?; + file.try_lock()?; + Ok(file) + } +} + +async fn acquire_child_recovery_fence(path: &Path, budget: Duration) -> std::io::Result { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + options.mode(0o600); + let file = options.open(path)?; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + acquire_exclusive_with_budget(file, path, budget).await + } + #[cfg(not(unix))] + { + let file = options.open(path)?; + acquire_exclusive_with_budget(file, path, budget).await + } +} + +async fn acquire_exclusive_with_budget( + file: File, + path: &Path, + budget: Duration, +) -> std::io::Result { + let deadline = tokio::time::Instant::now() + budget; + loop { + match file.try_lock() { + Ok(()) => return Ok(file), + Err(std::fs::TryLockError::WouldBlock) => { + if tokio::time::Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!( + "timed out after {}ms waiting for old child watchdogs on {path:?}", + budget.as_millis() + ), + )); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + Err(std::fs::TryLockError::Error(error)) => return Err(error), + } + } +} + +/// The `local-api` binary's entire boot sequence — see the module doc +/// comment. Reads the env contract, prints the discovery stdout lines, +/// serves until SIGTERM/Ctrl+C, then runs shutdown hooks. Exits the +/// process (`std::process::exit(1)`) on any boot failure, matching the old +/// `main()` body's behavior exactly. +pub async fn boot_from_env() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + // Signal ownership must exist before `start()` opens and recovers the + // durable queue. A dev-loop SIGTERM delivered during that await used to + // take the platform default path and bypass every shutdown fence. The + // listener records the first signal without cancelling startup; once + // startup returns, the already-queued request flows through the normal + // ordered `ServerHandle::shutdown`. A second signal remains an operator + // escape hatch if recovery or shutdown itself is wedged. + let termination = InstalledTerminationSignal::install(); + + let token = resolve_token(); + let boot_id = resolve_boot_id(); + let app_root = resolve_app_root(); + let mode = ApiMode::from_env(); + // Bind ONLY 127.0.0.1, never 0.0.0.0 — deliberate divergence from the + // daemon (which binds 0.0.0.0, acceptable for a sandboxed cluster + // pod/VM, unacceptable for a process running on a user's laptop). See + // the native local-API contract. + let port = resolve_port(); + + let handle = match start(StartOptions { + token, + boot_id, + app_root, + port, + mode, + }) + .await + { + Ok(h) => h, + Err(err) => { + eprintln!("[local-api] {err}"); + std::process::exit(1); + } + }; + + // A test harness that requested port 0 (ephemeral) discovers the real + // port from this line — see the contract doc's `LOCAL_API_PORT` row. + // Printed unconditionally (not only when 0 was requested) since it's + // harmless and keeps the contract simple. + println!("LOCAL_API_PORT={}", handle.port()); + // The preview listener's port is ALWAYS ephemeral (see `start`'s doc + // comment) — this is the only way a caller (an e2e harness, in + // particular) ever learns it. + println!("LOCAL_API_PREVIEW_PORT={}", handle.preview_port()); + tracing::info!(port = handle.port(), preview_port = handle.preview_port(), boot_id = %handle.state().boot_id, "local-api listening"); + + termination.wait().await; + tracing::info!("shutdown signal received, running shutdown hooks"); + handle.shutdown().await; +} + +/// Resolves `LOCAL_API_TOKEN` (canonical) or `DAEMON_TOKEN` (legacy alias +/// — required so the daemon-parity oracle's `daemon.e2e.helpers.ts` can +/// spawn this binary unmodified). New name wins when both are set. If +/// neither is set, generates a fresh 32-byte token, hex-encoded, and +/// prints it as `LOCAL_API_TOKEN=` on its own line before the +/// process starts listening — mirrors how the token reaches a caller today +/// when nothing pre-seeds it. +fn resolve_token() -> String { + if let Ok(v) = std::env::var("LOCAL_API_TOKEN") { + if !v.is_empty() { + return v; + } + } + if let Ok(v) = std::env::var("DAEMON_TOKEN") { + if !v.is_empty() { + return v; + } + } + let token = generate_token(); + println!("LOCAL_API_TOKEN={token}"); + token +} + +fn generate_token() -> String { + use rand::RngCore; + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// `LOCAL_API_BOOT_ID` / `DAEMON_BOOT_ID`, else a generated UUID — mirrors +/// `entry.ts` generating `DAEMON_BOOT_ID` when unset. +fn resolve_boot_id() -> String { + std::env::var("LOCAL_API_BOOT_ID") + .or_else(|_| std::env::var("DAEMON_BOOT_ID")) + .unwrap_or_else(|_| uuid::Uuid::new_v4().to_string()) +} + +/// `LOCAL_API_WORKDIR` / `APP_ROOT` — required. Unlike the daemon (which +/// falls back to `"/"`), local-api refuses to boot without an explicit +/// workdir: a silent `/` clamp on a user's laptop is a much scarier +/// default than it is inside a disposable sandbox container. +fn resolve_app_root() -> PathBuf { + let raw = std::env::var("LOCAL_API_WORKDIR") + .or_else(|_| std::env::var("APP_ROOT")) + .unwrap_or_else(|_| { + eprintln!( + "[local-api] missing required env: set LOCAL_API_WORKDIR (or the legacy APP_ROOT alias)" + ); + std::process::exit(1); + }); + PathBuf::from(raw) +} + +/// `LOCAL_API_PORT` / `PROXY_PORT` — required. `0` picks an ephemeral port +/// (see `resolve_token`'s sibling stdout contract for how a caller +/// discovers it). +fn resolve_port() -> u16 { + let raw = std::env::var("LOCAL_API_PORT") + .or_else(|_| std::env::var("PROXY_PORT")) + .unwrap_or_else(|_| { + eprintln!( + "[local-api] missing required env: set LOCAL_API_PORT (or the legacy PROXY_PORT alias)" + ); + std::process::exit(1); + }); + raw.parse().unwrap_or_else(|_| { + eprintln!("[local-api] invalid port value: {raw:?}"); + std::process::exit(1); + }) +} + +/// Pre-armed standalone-binary signal receiver. The Tauri shell has its own +/// equivalent in `src-tauri/src/setup.rs`, routing signals through +/// `AppHandle::exit` and the retained embedded [`ServerHandle`]. +struct InstalledTerminationSignal { + first: tokio::sync::oneshot::Receiver<()>, +} + +impl InstalledTerminationSignal { + fn install() -> Self { + let (first_tx, first) = tokio::sync::oneshot::channel(); + + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + + // `signal(...)` installs Tokio's process-wide handlers + // synchronously here—before the startup/recovery future is first + // polled. The spawned task only waits on already-installed streams. + let mut terminate = + signal(SignalKind::terminate()).expect("failed to install SIGTERM handler"); + let mut interrupt = + signal(SignalKind::interrupt()).expect("failed to install SIGINT handler"); + tokio::spawn(async move { + let first_name = tokio::select! { + _ = terminate.recv() => "SIGTERM", + _ = interrupt.recv() => "SIGINT", + }; + tracing::info!(signal = first_name, "standalone shutdown signal queued"); + let _ = first_tx.send(()); + + let second_name = tokio::select! { + _ = terminate.recv() => "SIGTERM", + _ = interrupt.recv() => "SIGINT", + }; + tracing::error!( + signal = second_name, + "second standalone shutdown signal received; forcing exit" + ); + std::process::exit(1); + }); + } + + #[cfg(not(unix))] + tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + let _ = first_tx.send(()); + let _ = tokio::signal::ctrl_c().await; + std::process::exit(1); + }); + + Self { first } + } + + async fn wait(self) { + let _ = self.first.await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn instance_lock_is_exclusive_and_kernel_released() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("local-api.lock"); + let first = acquire_instance_lock(&path).unwrap(); + let error = acquire_instance_lock(&path).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); + + drop(first); + // Parallel process-spawning tests can briefly retain a fork-inherited + // descriptor before exec closes it. The kernel release is still + // bounded; avoid asserting on that transient pre-exec window. + let deadline = std::time::Instant::now() + Duration::from_secs(1); + let _restarted = loop { + match acquire_instance_lock(&path) { + Ok(lock) => break lock, + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + && std::time::Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("instance lock was not released: {error}"), + } + }; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + + #[tokio::test] + async fn child_recovery_fence_waits_for_shared_watchdog_and_is_bounded() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("child-lifetime.lock"); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + let shared = options.open(&path).unwrap(); + shared.try_lock_shared().unwrap(); + + let error = acquire_child_recovery_fence(&path, Duration::from_millis(40)) + .await + .unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); + + drop(shared); + let exclusive = acquire_child_recovery_fence(&path, Duration::from_secs(1)) + .await + .expect("exclusive recovery ownership after shared watchdog exits"); + let contender = options.open(&path).unwrap(); + assert!(matches!( + contender.try_lock_shared(), + Err(std::fs::TryLockError::WouldBlock) + )); + drop(exclusive); + } + + #[tokio::test] + async fn server_start_rejects_same_root_until_owner_shuts_down() { + let dir = tempfile::tempdir().unwrap(); + let options = |boot_id: &str| StartOptions { + token: "t".repeat(32), + boot_id: boot_id.to_string(), + app_root: dir.path().to_path_buf(), + port: 0, + mode: ApiMode::Strict, + }; + let first = start(options("first")).await.unwrap(); + match start(options("second")).await { + Err(StartError::InstanceLock { source, .. }) => { + assert_eq!(source.kind(), std::io::ErrorKind::WouldBlock) + } + Ok(second) => { + second.shutdown().await; + panic!("a second server acquired the live app root") + } + Err(error) => panic!("unexpected second-start error: {error}"), + } + + first.shutdown().await; + let deadline = tokio::time::Instant::now() + Duration::from_secs(1); + let restarted = loop { + match start(options("restarted")).await { + Ok(server) => break server, + Err(StartError::InstanceLock { source, .. }) + if source.kind() == std::io::ErrorKind::WouldBlock + && tokio::time::Instant::now() < deadline => + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(error) => panic!("server could not restart after shutdown: {error}"), + } + }; + restarted.shutdown().await; + } +} diff --git a/apps/native/crates/local-api/src/log_store/mod.rs b/apps/native/crates/local-api/src/log_store/mod.rs new file mode 100644 index 0000000000..90b3ad2d43 --- /dev/null +++ b/apps/native/crates/local-api/src/log_store/mod.rs @@ -0,0 +1,54 @@ +//! File-backed log retention — the durable, on-disk half of this crate's +//! log pipeline. Owner decision (see the module-owner report this shipped +//! with): **files are the source of truth for RETAINED output; RAM holds +//! only the TRANSIENT live fan-out**. Concretely: +//! +//! - [`store::LogStore`] owns every byte anyone might want to read back +//! later — SSE replay-on-connect frames (`routes/events.rs`), a task's +//! buffered output (`GET /_sandbox/tasks/:id`), and a task's own +//! replay-then-live stream (`GET /_sandbox/tasks/:id/stream`). One +//! instance per app workdir (the process-global one wired in `lib.rs`, +//! one more per `sandbox::manager::Sandbox`), rooted at `/logs`. +//! - The broadcaster (`events::Broadcaster`) and a task's own +//! `chunks_tx` (`tasks::registry::TaskEntry`) still exist, but ONLY as +//! in-memory fan-out for whoever is live-subscribed RIGHT NOW — neither +//! buffers a backlog anymore. A subscriber that connects late gets +//! caught up by reading [`store::LogStore`] instead. +//! +//! Byte-parity target: `packages/sandbox/daemon/process/log-tee.ts` +//! (`LogTee`, the at-cap rotation behavior — see [`store::WRITE_CAP_BYTES`]) +//! and `packages/sandbox/daemon/events/replay.ts` (`ReplayBuffer`, the +//! tail-read size — see [`store::DEFAULT_TAIL_BYTES`]). The old daemon kept +//! these as TWO separate mechanisms (an in-RAM 256KB replay buffer feeding +//! SSE, a 10MB on-disk tee purely for audit) that happened to duplicate the +//! same bytes; this crate collapses them into ONE file-backed mechanism +//! that serves both jobs — write once at up to 10MB (with LogTee-style +//! rotation), read back only the last [`store::DEFAULT_TAIL_BYTES`] for a +//! replay frame. +//! +//! Two independent key namespaces share the same [`store::LogStore`] root +//! (see [`store::app_key`] and `tasks::registry`'s task-scoped keys): +//! - `"app/"` — a STABLE, reused path per named source +//! (`"setup"`, `"dev"`, `"start"` stay verbatim; unsafe names use a +//! reversible encoding), mirroring the old daemon's +//! `appLogPath(logsDir, name)` layout. Fed by the setup pipeline +//! (`setup/{clone,install,dev}.rs`); read by `routes/events.rs`'s +//! connect-time `"log"` frame replay. +//! - `"tasks//.{out,err}"` — a FRESH private path per +//! task even though daemon-compatible public ids restart at `task1` each +//! boot, split by stream like the old in-RAM per-task ring buffers. Fed and +//! read by `tasks::registry::TaskRegistry`; cleaned up when a task is deleted +//! (`DELETE /_sandbox/tasks/:id`). +//! +//! One deliberate behavior change from the old (fully in-RAM) daemon: since +//! files persist across a `local-api` relaunch, a fresh SSE connect can +//! replay a transcript that a PREVIOUS process instance wrote (e.g. the +//! last clone's output, if the app was quit and reopened before a new one +//! ran). The old daemon lost all replay history on every restart (nothing +//! was ever persisted). This is intentional, not a bug: it follows directly +//! from "files are the source of truth" and the wire contract (frame +//! shape/timing) is unchanged either way. + +pub mod store; + +pub use store::{app_key, LogStore, DEFAULT_TAIL_BYTES}; diff --git a/apps/native/crates/local-api/src/log_store/store.rs b/apps/native/crates/local-api/src/log_store/store.rs new file mode 100644 index 0000000000..0cb6fa4dbc --- /dev/null +++ b/apps/native/crates/local-api/src/log_store/store.rs @@ -0,0 +1,641 @@ +//! [`LogStore`] — async, file-backed append-only log retention. See the +//! `log_store` module doc for the design rationale (files are the source of +//! truth for retained output; RAM holds only transient live fan-out). + +use std::collections::HashMap; +use std::path::PathBuf; + +use tokio::fs::{self, File, OpenOptions}; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt, BufWriter}; +use tokio::sync::Mutex; + +/// Byte-parity with `LOG_MAX_BYTES` (`process/task-manager.ts`) / +/// `INSTALL_LOG_MAX_BYTES` (`setup/orchestrator.ts`) — both 10MB in the old +/// daemon. Once a file's accumulated size would exceed this, [`LogStore::append`] +/// rotates: flush + drop the old handle, delete the file, reopen fresh, and +/// write a `[log rotated at N bytes]` marker line before the chunk that +/// triggered rotation — byte-parity in behavior (not text: the old marker +/// used a bare `maxBytes` number too) with `process/log-tee.ts::LogTee`'s +/// `rotate()`. +pub const WRITE_CAP_BYTES: u64 = 10 * 1024 * 1024; + +/// Byte-parity with `REPLAY_BYTES` (`packages/sandbox/daemon/constants.ts`) +/// — the default/expected read size for an SSE replay-on-connect frame: +/// only the LAST 256KB of a (possibly up-to-10MB) file is ever handed back +/// by [`LogStore::tail_read`] for that purpose. Callers may pass any +/// `max_bytes`; this is just the value `routes/events.rs` and +/// `tasks::registry` both use, so they agree without hardcoding it twice. +pub const DEFAULT_TAIL_BYTES: usize = 256 * 1024; + +/// Builds the `"app/"` [`LogStore`] key for a named, STABLE log +/// source (`"setup"`, `"dev"`, `"start"`) — byte-parity in spirit with the +/// old daemon's `appLogPath(logsDir, name)` on-disk layout. Shared by every +/// writer (`setup/{clone,install,dev}.rs`) and the one reader +/// (`routes/events.rs`) so both sides always agree on the exact key for a +/// given source name. +pub fn app_key(source: &str) -> String { + format!("app/{}", encode_app_source(source)) +} + +/// Unsafe source names must remain reversible when the file-backed catalog is +/// rebuilt after a process restart. A lossy filename sanitizer made +/// `dev:web` and `dev_web` share one file and replayed both as `dev_web`. +/// Keep the familiar `app/setup` and `app/dev` paths for ordinary names; use a +/// reserved, hex-encoded form for everything else (including names beginning +/// with the reserved prefix, so the mapping stays one-to-one). +const APP_SOURCE_ESCAPE_PREFIX: &str = "__decocms_source_v1__"; + +fn encode_app_source(source: &str) -> String { + let can_use_verbatim = !source.is_empty() + && !source.starts_with(APP_SOURCE_ESCAPE_PREFIX) + && !source.contains('/') + && sanitize_segment(source) == source; + if can_use_verbatim { + return source.to_string(); + } + + let mut encoded = String::with_capacity(APP_SOURCE_ESCAPE_PREFIX.len() + source.len() * 2); + encoded.push_str(APP_SOURCE_ESCAPE_PREFIX); + for byte in source.as_bytes() { + use std::fmt::Write as _; + let _ = write!(encoded, "{byte:02x}"); + } + encoded +} + +fn decode_app_source(filename: &str) -> Option { + let Some(hex) = filename.strip_prefix(APP_SOURCE_ESCAPE_PREFIX) else { + return Some(filename.to_string()); + }; + if hex.len() % 2 != 0 { + return None; + } + let mut bytes = Vec::with_capacity(hex.len() / 2); + for pair in hex.as_bytes().chunks_exact(2) { + let pair = std::str::from_utf8(pair).ok()?; + bytes.push(u8::from_str_radix(pair, 16).ok()?); + } + String::from_utf8(bytes).ok() +} + +struct Writer { + file: BufWriter, + /// Bytes written since this file was last (re)opened fresh — reset to + /// the marker's length on rotation, seeded from the on-disk file size + /// the FIRST time a key is opened (so a file `LogStore` didn't itself + /// write this process's lifetime, e.g. left over from truncate/rotate + /// bookkeeping, still counts correctly toward the cap). + written: u64, + /// Latches `true` the first time this key rotates and never resets — + /// byte-parity with `LogTee.isTruncated()`'s `rotatedOnce` flag. + rotated: bool, +} + +/// See the `log_store` module doc for the full design. One instance per app +/// workdir, rooted at `/logs`. +pub struct LogStore { + root: PathBuf, + cap_bytes: u64, + writers: Mutex>, + #[cfg(test)] + append_gate: std::sync::Mutex>, +} + +#[cfg(test)] +struct AppendTestGate { + entered: std::sync::Arc, + release: std::sync::Arc, +} + +impl LogStore { + pub fn new(root: PathBuf) -> Self { + Self::with_cap(root, WRITE_CAP_BYTES) + } + + pub(crate) fn root(&self) -> &std::path::Path { + &self.root + } + + /// Same as [`Self::new`] but with a caller-chosen rotation cap — used by + /// this module's own tests to exercise rotation without writing 10MB of + /// fixture data. Not `#[cfg(test)]`-gated so other modules' tests in + /// this crate can use it too (still `pub(crate)`: no reason for an + /// embedder to override the byte-parity cap). + pub(crate) fn with_cap(root: PathBuf, cap_bytes: u64) -> Self { + Self { + root, + cap_bytes, + writers: Mutex::new(HashMap::new()), + #[cfg(test)] + append_gate: std::sync::Mutex::new(None), + } + } + + /// Maps a `/`-separated key (e.g. `"app/setup"`, `"tasks/task5.out"`) + /// onto a real path under `root`, sanitizing EACH segment + /// independently — a source/task-id name is never trusted verbatim as + /// a path component (no traversal, no hidden-file/absolute-path + /// confusion). + fn path_for(&self, key: &str) -> PathBuf { + let mut path = self.root.clone(); + for segment in key.split('/') { + path.push(sanitize_segment(segment)); + } + path + } + + /// Appends `data` to `key`'s file, rotating first if this write would + /// push the file past the configured cap. A no-op for empty `data` or + /// on any I/O error (log writes must never crash the pipeline that + /// produced them — byte-parity in spirit with `LogTee.write()`'s own + /// swallowed-error catch blocks). + /// + /// Returns once the write is flushed to the OS — callers MUST complete + /// this await before emitting the corresponding LIVE broadcast frame, + /// so a client that connects between the two never sees "neither + /// replay nor live" for that chunk (see the `log_store` module doc). + pub async fn append(&self, key: &str, data: &str) { + if data.is_empty() { + return; + } + #[cfg(test)] + self.wait_on_append_gate().await; + let bytes = data.as_bytes(); + let mut guard = self.writers.lock().await; + if !self.ensure_writer(&mut guard, key).await { + return; + } + let would_exceed = guard + .get(key) + .map(|w| w.written + bytes.len() as u64 > self.cap_bytes) + .unwrap_or(false); + if would_exceed { + self.rotate(&mut guard, key).await; + } + if let Some(writer) = guard.get_mut(key) { + Self::write_bytes(writer, bytes).await; + } + } + + /// Resets `key` to an empty, fresh file — byte-parity in spirit with + /// `setup/orchestrator.ts`'s `unlinkSync(cloneLogPath)` / + /// `unlinkSync(installLogPath)` right before opening a new tee for a + /// FRESH clone/install run. Used by `setup/clone.rs` exactly once per + /// genuinely fresh clone (not on a checkout-existing-repo re-run) — see + /// that file's own doc comment for why only that branch resets it. + pub async fn truncate(&self, key: &str) { + let mut guard = self.writers.lock().await; + self.close_and_delete(&mut guard, key).await; + } + + /// Removes `key` entirely — closes any open handle and deletes the + /// file. Used by `tasks::registry::TaskRegistry::remove` to clean up a + /// deleted task's per-task files (lifecycle completeness: no orphaned + /// files survive a task the caller explicitly removed). + pub async fn remove(&self, key: &str) { + let mut guard = self.writers.lock().await; + self.close_and_delete(&mut guard, key).await; + } + + /// `true` once `key` has rotated at least once (this process's + /// lifetime) — surfaced as `TaskSummary::truncated`. + pub async fn is_truncated(&self, key: &str) -> bool { + let guard = self.writers.lock().await; + guard.get(key).map(|w| w.rotated).unwrap_or(false) + } + + /// Returns the last (up to) `max_bytes` bytes of `key`'s file, decoded + /// lossily (a byte offset chosen purely by size can land mid-codepoint; + /// `from_utf8_lossy` turns any truncated sequence into replacement + /// characters rather than panicking or corrupting the rest of the + /// text). Missing file / any I/O error -> `""` (never a hard failure — + /// a replay frame with nothing to show is just skipped by the caller). + pub async fn tail_read(&self, key: &str, max_bytes: usize) -> String { + let path = self.path_for(key); + if !Self::make_owner_only(&path).await { + return String::new(); + } + let Ok(mut file) = File::open(&path).await else { + return String::new(); + }; + let Ok(metadata) = file.metadata().await else { + return String::new(); + }; + let len = metadata.len(); + let start = len.saturating_sub(max_bytes as u64); + if start > 0 && file.seek(std::io::SeekFrom::Start(start)).await.is_err() { + return String::new(); + } + let mut buf = Vec::new(); + if file.read_to_end(&mut buf).await.is_err() { + return String::new(); + } + String::from_utf8_lossy(&buf).into_owned() + } + + /// Lists every stable application-log source currently retained on disk. + /// + /// The filesystem is the source of truth for replay, so this deliberately + /// does not consult [`crate::tasks::TaskRegistry`]. A restarted process has + /// an empty task registry while `/logs/app/*` still contains the + /// setup/dev transcripts the terminal must replay. Results are sorted for + /// deterministic SSE snapshot ordering and only regular, safely-named + /// files are returned (directories and symlinks are ignored). + pub async fn app_sources(&self) -> Vec { + let app_dir = self.root.join("app"); + let Ok(mut entries) = fs::read_dir(app_dir).await else { + return Vec::new(); + }; + let mut sources = Vec::new(); + loop { + let entry = match entries.next_entry().await { + Ok(Some(entry)) => entry, + Ok(None) | Err(_) => break, + }; + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if !file_type.is_file() { + continue; + } + let Ok(filename) = entry.file_name().into_string() else { + continue; + }; + if filename.is_empty() || sanitize_segment(&filename) != filename { + continue; + } + let Some(source) = decode_app_source(&filename) else { + continue; + }; + sources.push(source); + } + sources.sort_unstable(); + sources.dedup(); + sources + } + + /// Drops an already-flushed writer handle without deleting its retained + /// file. Task finalization uses this to keep file descriptors and writer + /// metadata bounded by the actively-writing task set rather than every + /// task seen since boot. A concurrent write owns the mutex and makes this + /// a harmless no-op; its next finalization/removal gets another chance. + pub(crate) fn close_writer_if_idle(&self, key: &str) { + if let Ok(mut guard) = self.writers.try_lock() { + guard.remove(key); + } + } + + #[cfg(test)] + pub(crate) fn block_next_append( + &self, + ) -> ( + std::sync::Arc, + std::sync::Arc, + ) { + let entered = std::sync::Arc::new(tokio::sync::Semaphore::new(0)); + let release = std::sync::Arc::new(tokio::sync::Semaphore::new(0)); + let mut guard = self + .append_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *guard = Some(AppendTestGate { + entered: entered.clone(), + release: release.clone(), + }); + (entered, release) + } + + #[cfg(test)] + async fn wait_on_append_gate(&self) { + let gate = self + .append_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + let Some(gate) = gate else { + return; + }; + gate.entered.add_permits(1); + if let Ok(permit) = gate.release.acquire().await { + permit.forget(); + }; + } + + #[cfg(test)] + pub(crate) async fn open_writer_count(&self) -> usize { + self.writers.lock().await.len() + } + + async fn ensure_writer(&self, guard: &mut HashMap, key: &str) -> bool { + if guard.contains_key(key) { + return true; + } + let path = self.path_for(key); + if let Some(parent) = path.parent() { + if fs::create_dir_all(parent).await.is_err() { + return false; + } + } + let mut options = OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + options.mode(0o600); + let Ok(file) = options.open(&path).await else { + return false; + }; + if !Self::make_owner_only(&path).await { + return false; + } + let written = fs::metadata(&path).await.map(|m| m.len()).unwrap_or(0); + guard.insert( + key.to_string(), + Writer { + file: BufWriter::new(file), + written, + rotated: false, + }, + ); + true + } + + async fn make_owner_only(path: &std::path::Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .await + .is_ok() + } + #[cfg(not(unix))] + { + let _ = path; + true + } + } + + async fn rotate(&self, guard: &mut HashMap, key: &str) { + self.close_and_delete(guard, key).await; + if !self.ensure_writer(guard, key).await { + return; + } + let marker = format!("[log rotated at {} bytes]\n", self.cap_bytes); + if let Some(writer) = guard.get_mut(key) { + Self::write_bytes(writer, marker.as_bytes()).await; + writer.rotated = true; + } + } + + async fn close_and_delete(&self, guard: &mut HashMap, key: &str) { + if let Some(mut writer) = guard.remove(key) { + let _ = writer.file.flush().await; + } + let path = self.path_for(key); + let _ = fs::remove_file(&path).await; + } + + async fn write_bytes(writer: &mut Writer, bytes: &[u8]) { + if writer.file.write_all(bytes).await.is_err() { + return; + } + if writer.file.flush().await.is_err() { + return; + } + writer.written += bytes.len() as u64; + } +} + +/// Lowercases nothing (source/task-id names are already the display form) — +/// just maps every char outside `[A-Za-z0-9._-]` to `_` and strips leading +/// dots, so a key segment can never escape `root` (no `..`, no absolute +/// path, no hidden file) regardless of what a future caller passes as a +/// source/task-id name. +fn sanitize_segment(raw: &str) -> String { + let mapped: String = raw + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + }) + .collect(); + let trimmed = mapped.trim_start_matches('.'); + if trimmed.is_empty() { + "_".to_string() + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store(cap: u64) -> (tempfile::TempDir, LogStore) { + let dir = tempfile::tempdir().unwrap(); + let store = LogStore::with_cap(dir.path().to_path_buf(), cap); + (dir, store) + } + + #[tokio::test] + async fn append_then_tail_read_roundtrips() { + let (_dir, store) = store(WRITE_CAP_BYTES); + store.append("app/setup", "hello ").await; + store.append("app/setup", "world\n").await; + assert_eq!( + store.tail_read("app/setup", DEFAULT_TAIL_BYTES).await, + "hello world\n" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn log_files_are_owner_only_on_create_and_reopen() { + use std::os::unix::fs::PermissionsExt; + + let (dir, store) = store(WRITE_CAP_BYTES); + store.append("app/setup", "first\n").await; + let path = dir.path().join("app/setup"); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + store.close_writer_if_idle("app/setup"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!(store.tail_read("app/setup", 4096).await, "first\n"); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + store.append("app/setup", "second\n").await; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[tokio::test] + async fn tail_read_of_missing_key_is_empty() { + let (_dir, store) = store(WRITE_CAP_BYTES); + assert_eq!(store.tail_read("app/never-written", 1024).await, ""); + } + + #[tokio::test] + async fn tail_read_only_returns_the_last_max_bytes() { + let (_dir, store) = store(WRITE_CAP_BYTES); + store.append("app/setup", "0123456789").await; + assert_eq!(store.tail_read("app/setup", 4).await, "6789"); + } + + #[tokio::test] + async fn tail_read_cuts_lossily_at_a_multibyte_boundary() { + let (_dir, store) = store(WRITE_CAP_BYTES); + // "é" is 2 bytes (0xC3 0xA9); "a é" = ['a',' ',0xC3,0xA9] — asking + // for the last 1 byte lands mid-codepoint (just the trailing 0xA9), + // which must decode lossily rather than panicking. + store.append("app/setup", "a é").await; + let tail = store.tail_read("app/setup", 1).await; + assert_eq!(tail.chars().count(), 1); + assert_eq!(tail, "\u{FFFD}"); + } + + #[tokio::test] + async fn append_rotates_at_cap_with_a_marker_line() { + let (_dir, store) = store(10); + store.append("app/setup", "0123456789").await; // exactly at cap, no rotation yet + assert!(!store.is_truncated("app/setup").await); + store.append("app/setup", "x").await; // would exceed -> rotates + assert!(store.is_truncated("app/setup").await); + let content = store.tail_read("app/setup", 4096).await; + assert!( + content.starts_with("[log rotated at 10 bytes]\n"), + "got: {content:?}" + ); + assert!(content.ends_with('x'), "got: {content:?}"); + // The pre-rotation content must be GONE (rotation deletes, not + // trims) — byte-parity with LogTee's rotate-by-unlink, not a + // sliding window. + assert!(!content.contains("0123456789")); + } + + #[tokio::test] + async fn truncate_resets_to_an_empty_fresh_file() { + let (_dir, store) = store(WRITE_CAP_BYTES); + store.append("app/setup", "old transcript\n").await; + store.truncate("app/setup").await; + assert_eq!(store.tail_read("app/setup", 4096).await, ""); + assert!(!store.is_truncated("app/setup").await); + store.append("app/setup", "fresh\n").await; + assert_eq!(store.tail_read("app/setup", 4096).await, "fresh\n"); + } + + #[tokio::test] + async fn remove_deletes_the_file() { + let (_dir, store) = store(WRITE_CAP_BYTES); + store.append("tasks/task1.out", "hi\n").await; + store.remove("tasks/task1.out").await; + assert_eq!(store.tail_read("tasks/task1.out", 4096).await, ""); + } + + #[tokio::test] + async fn sanitizes_unsafe_key_segments() { + let (_dir, store) = store(WRITE_CAP_BYTES); + store.append("app/../../etc/passwd", "nope\n").await; + // Whatever path this resolved to, it must stay under `root` — the + // sanitizer strips leading dots per-segment, so this lands at + // `root/app/etc/passwd` (or similar), never escaping upward. + let path = store.path_for("app/../../etc/passwd"); + assert!(path.starts_with(&store.root)); + } + + #[tokio::test] + async fn concurrent_appends_to_the_same_key_never_corrupt_or_drop_data() { + let (_dir, store) = store(WRITE_CAP_BYTES); + let store = std::sync::Arc::new(store); + let mut handles = Vec::new(); + for i in 0..20 { + let store = store.clone(); + handles.push(tokio::spawn(async move { + store.append("app/setup", &format!("line-{i}\n")).await; + })); + } + for h in handles { + h.await.unwrap(); + } + let content = store.tail_read("app/setup", 4096).await; + for i in 0..20 { + assert!( + content.contains(&format!("line-{i}\n")), + "missing line-{i} in: {content:?}" + ); + } + } + + #[tokio::test] + async fn app_sources_are_discovered_from_preexisting_files_in_stable_order() { + let (dir, store) = store(WRITE_CAP_BYTES); + let app_dir = dir.path().join("app"); + tokio::fs::create_dir_all(app_dir.join("not-a-source")) + .await + .unwrap(); + tokio::fs::write(app_dir.join("setup"), "old setup\n") + .await + .unwrap(); + tokio::fs::write(app_dir.join("dev"), "old dev\n") + .await + .unwrap(); + tokio::fs::write(app_dir.join(".hidden"), "ignore me\n") + .await + .unwrap(); + + assert_eq!(store.app_sources().await, vec!["dev", "setup"]); + } + + #[tokio::test] + async fn app_source_filenames_are_reversible_and_collision_free_after_restart() { + let dir = tempfile::tempdir().unwrap(); + let store = LogStore::new(dir.path().to_path_buf()); + + assert_eq!(app_key("setup"), "app/setup"); + assert_eq!(app_key("dev"), "app/dev"); + assert_ne!(app_key("dev:web"), app_key("dev_web")); + store.append(&app_key("setup"), "setup output\n").await; + store.append(&app_key("dev:web"), "colon output\n").await; + store + .append(&app_key("dev_web"), "underscore output\n") + .await; + + let restarted = LogStore::new(dir.path().to_path_buf()); + assert_eq!( + restarted.app_sources().await, + vec!["dev:web", "dev_web", "setup"] + ); + assert_eq!( + restarted.tail_read(&app_key("dev:web"), 4096).await, + "colon output\n" + ); + assert_eq!( + restarted.tail_read(&app_key("dev_web"), 4096).await, + "underscore output\n" + ); + assert!(dir.path().join("app/setup").is_file()); + } + + #[tokio::test] + async fn rotated_app_log_remains_discoverable() { + let (_dir, store) = store(10); + store.append("app/setup", "0123456789").await; + store.append("app/setup", "x").await; + + assert_eq!(store.app_sources().await, vec!["setup"]); + assert!(store + .tail_read("app/setup", 4096) + .await + .starts_with("[log rotated at 10 bytes]\n")); + } +} diff --git a/apps/native/crates/local-api/src/main.rs b/apps/native/crates/local-api/src/main.rs new file mode 100644 index 0000000000..682dadc2ed --- /dev/null +++ b/apps/native/crates/local-api/src/main.rs @@ -0,0 +1,17 @@ +//! `local-api` — the desktop app's Rust HTTP core, binary entrypoint. +//! +//! SHARED FILE (see the native module-ownership contract) — family +//! implementers should never need to touch it. +//! +//! Phase 3 note: the actual boot sequence (env parsing, dir creation, +//! `AppState`/router construction, bind, serve-until-SIGTERM) now lives in +//! `lib.rs::boot_from_env()` — a mechanical extraction so the Tauri shell +//! (`apps/native/src-tauri/`) can embed the exact same server-construction +//! logic in-process (via `lib.rs::start()`) without going through env vars, +//! stdout, or a subprocess at all. This binary's behavior is unchanged: +//! same env contract, same stdout lines, same SIGTERM handling. + +#[tokio::main] +async fn main() { + local_api::boot_from_env().await; +} diff --git a/apps/native/crates/local-api/src/mutation.rs b/apps/native/crates/local-api/src/mutation.rs new file mode 100644 index 0000000000..5cfc4967d1 --- /dev/null +++ b/apps/native/crates/local-api/src/mutation.rs @@ -0,0 +1,519 @@ +//! Shutdown-safe ownership for filesystem mutations. +//! +//! Long preparation (network fetches, catalog rendering) runs outside the +//! repository and is registered here as a cancellable owner. The only code +//! allowed to make a prepared result visible in the repository holds a short +//! [`MutationCommitPermit`]. Every admitted commit runs in a separate, +//! non-aborted task that owns its permit until the filesystem operation really +//! returns. This matters for `tokio::fs`: dropping its async waiter does not +//! cancel an already-running blocking-pool syscall. Shutdown closes both +//! admission paths synchronously, first asks every long owner to cancel +//! cooperatively, then aborts owners that exceed that grace. Only after those +//! owners retire does it cross the independent commit barrier before git +//! publish is allowed to start. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use futures_util::FutureExt; +use tokio::sync::{oneshot, watch, OwnedRwLockReadGuard, RwLock}; +use tokio::task::AbortHandle; +use tokio::time::Instant; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MutationOwnerError { + ShuttingDown, + Panicked, + Stopped, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MutationShutdownOutcome { + pub owners_remaining: usize, + pub commit_quiescent: bool, +} + +impl MutationShutdownOutcome { + pub fn is_quiescent(self) -> bool { + self.owners_remaining == 0 && self.commit_quiescent + } +} + +/// Cancellation receiver handed to a long mutation owner. Owners select on +/// [`Self::cancelled`] around every potentially long await and clean their +/// staging directory before returning. +pub struct MutationCancellation { + receiver: watch::Receiver, +} + +impl MutationCancellation { + pub fn is_cancelled(&self) -> bool { + *self.receiver.borrow() + } + + pub async fn cancelled(&mut self) { + if self.is_cancelled() { + return; + } + while self.receiver.changed().await.is_ok() { + if self.is_cancelled() { + return; + } + } + } +} + +/// Proof that this short final filesystem transition linearized before +/// shutdown. The guard is intentionally opaque; dropping it ends the commit. +struct MutationCommitPermit { + _guard: OwnedRwLockReadGuard<()>, +} + +struct MutationOwnerControl { + cancellation: watch::Sender, + abort: AbortHandle, +} + +/// Registration is retired by the owner future's destruction, not by a line +/// of code after its await. This proves all preparation locals have dropped on +/// normal return, panic, or forced abort. Final commit tasks are deliberately +/// independent: their permits remain visible through the commit barrier even +/// after the preparation owner has retired. +struct MutationOwnerCompletion { + coordinator: Arc, + id: u64, +} + +impl Drop for MutationOwnerCompletion { + fn drop(&mut self) { + self.coordinator.finish_owner(self.id); + } +} + +pub struct MutationCoordinator { + accepting: AtomicBool, + commits: Arc>, + owners: Mutex>, + owner_changes: watch::Sender, + next_owner: AtomicU64, +} + +impl MutationCoordinator { + pub fn new() -> Self { + let (owner_changes, _) = watch::channel(0); + Self { + accepting: AtomicBool::new(true), + commits: Arc::new(RwLock::new(())), + owners: Mutex::new(HashMap::new()), + owner_changes, + next_owner: AtomicU64::new(1), + } + } + + /// Admits one short, final repository transition. The second check after + /// acquiring the read lock closes the queued-reader race with shutdown's + /// writer barrier. + async fn admit_commit(&self) -> Option { + if !self.accepting.load(Ordering::SeqCst) { + return None; + } + let guard = self.commits.clone().read_owned().await; + if !self.accepting.load(Ordering::SeqCst) { + return None; + } + Some(MutationCommitPermit { _guard: guard }) + } + + /// Runs one final repository transition in a detached, non-aborted task. + /// + /// `tokio::fs` delegates filesystem calls to the blocking pool. Aborting + /// the async waiter does not cancel a syscall that has already started, so + /// a permit owned by an abortable request/preparation future could be + /// released before the underlying rename/remove completed. The task + /// spawned here has no exposed abort handle: caller disconnect and forced + /// owner abort only drop the result receiver, while this task keeps the + /// read permit until `operation` actually returns. Shutdown's write barrier + /// therefore stays non-quiescent for the true commit lifetime. + pub async fn run_commit( + self: &Arc, + operation: F, + ) -> Result + where + T: Send + 'static, + F: FnOnce() -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + if !self.accepting.load(Ordering::SeqCst) { + return Err(MutationOwnerError::ShuttingDown); + } + + let coordinator = self.clone(); + let (result_tx, result_rx) = oneshot::channel(); + tokio::spawn(async move { + let outcome = match coordinator.admit_commit().await { + Some(permit) => { + let outcome = std::panic::AssertUnwindSafe(operation()) + .catch_unwind() + .await + .map_err(|_| MutationOwnerError::Panicked); + // Be explicit about the ordering proof: the operation's + // future is complete before the barrier permit is released. + drop(permit); + outcome + } + None => Err(MutationOwnerError::ShuttingDown), + }; + let _ = result_tx.send(outcome); + }); + + result_rx.await.unwrap_or(Err(MutationOwnerError::Stopped)) + } + + /// Detaches a long preparation from its request future and registers it + /// before spawning. Client disconnects therefore cannot orphan unknown + /// work, and shutdown can cancel and join it through the registry. + pub async fn run_owned(self: &Arc, work: F) -> Result + where + T: Send + 'static, + F: FnOnce(MutationCancellation) -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + if !self.accepting.load(Ordering::SeqCst) { + return Err(MutationOwnerError::ShuttingDown); + } + + let id = self.next_owner.fetch_add(1, Ordering::Relaxed); + let (cancel, receiver) = watch::channel(false); + let (start_tx, start_rx) = oneshot::channel(); + let (result_tx, result_rx) = oneshot::channel::>(); + // The start gate keeps user work dormant until its cancellation and + // abort controls are both visible in the registry. + let owner_task = tokio::spawn(async move { + if start_rx.await.is_err() { + return None; + } + Some( + std::panic::AssertUnwindSafe(work(MutationCancellation { receiver })) + .catch_unwind() + .await, + ) + }); + let abort = owner_task.abort_handle(); + + let completion = MutationOwnerCompletion { + coordinator: self.clone(), + id, + }; + // A separate completion owner holds the JoinHandle. `JoinHandle::await` + // becomes ready only after the preparation future has returned or has + // actually been dropped by abort. Registry retirement therefore cannot + // race ahead of destruction of the work future it represents. + tokio::spawn(async move { + let outcome = match owner_task.await { + Ok(Some(Ok(value))) => Ok(value), + Ok(Some(Err(_))) => Err(MutationOwnerError::Panicked), + Ok(None) | Err(_) => Err(MutationOwnerError::Stopped), + }; + drop(completion); + let _ = result_tx.send(outcome); + }); + + { + let mut owners = match self.owners.lock() { + Ok(owners) => owners, + Err(poisoned) => poisoned.into_inner(), + }; + if !self.accepting.load(Ordering::SeqCst) { + drop(owners); + abort.abort(); + return Err(MutationOwnerError::ShuttingDown); + } + owners.insert( + id, + MutationOwnerControl { + cancellation: cancel, + abort, + }, + ); + } + let _ = start_tx.send(()); + + result_rx.await.unwrap_or(Err(MutationOwnerError::Stopped)) + } + + /// Synchronously closes admission and signals all registered owners. The + /// returned future gives cooperative cleanup one `budget`, force-aborts + /// the remaining task futures, then gives actual future destruction a + /// second `budget`. The commit barrier shares whichever owner phase is + /// active: registration removal therefore happens before a true terminal + /// outcome, and a false outcome remains a hard publish gate. + pub fn begin_shutdown( + &self, + budget: Duration, + ) -> impl Future + '_ { + self.accepting.store(false, Ordering::SeqCst); + let cooperative_deadline = Instant::now() + budget; + let cancellations: Vec> = { + let owners = match self.owners.lock() { + Ok(owners) => owners, + Err(poisoned) => poisoned.into_inner(), + }; + owners + .values() + .map(|owner| owner.cancellation.clone()) + .collect() + }; + for cancellation in cancellations { + let _ = cancellation.send(true); + } + + async move { + let mut barrier_deadline = cooperative_deadline; + let mut owners_remaining = self.wait_for_owners(cooperative_deadline).await; + if owners_remaining > 0 { + let aborts: Vec = { + let owners = match self.owners.lock() { + Ok(owners) => owners, + Err(poisoned) => poisoned.into_inner(), + }; + owners.values().map(|owner| owner.abort.clone()).collect() + }; + for abort in aborts { + abort.abort(); + } + barrier_deadline = Instant::now() + budget; + owners_remaining = self.wait_for_owners(barrier_deadline).await; + } + let commit_quiescent = tokio::time::timeout_at(barrier_deadline, self.commits.write()) + .await + .is_ok(); + MutationShutdownOutcome { + owners_remaining, + commit_quiescent, + } + } + } + + fn finish_owner(&self, id: u64) { + let removed = { + let mut owners = match self.owners.lock() { + Ok(owners) => owners, + Err(poisoned) => poisoned.into_inner(), + }; + owners.remove(&id).is_some() + }; + if removed { + self.owner_changes + .send_modify(|generation| *generation += 1); + } + } + + async fn wait_for_owners(&self, deadline: Instant) -> usize { + let mut changes = self.owner_changes.subscribe(); + loop { + let remaining = match self.owners.lock() { + Ok(owners) => owners.len(), + Err(poisoned) => poisoned.into_inner().len(), + }; + if remaining == 0 { + return 0; + } + if tokio::time::timeout_at(deadline, changes.changed()) + .await + .is_err() + { + return match self.owners.lock() { + Ok(owners) => owners.len(), + Err(poisoned) => poisoned.into_inner().len(), + }; + } + } + } +} + +impl Default for MutationCoordinator { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicBool; + + struct DropProbe(Arc); + + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + #[tokio::test] + async fn shutdown_waits_for_an_admitted_commit_before_quiescence() { + let coordinator = Arc::new(MutationCoordinator::new()); + let permit = coordinator.admit_commit().await.expect("commit admitted"); + let shutting_down = { + let coordinator = coordinator.clone(); + tokio::spawn(async move { coordinator.begin_shutdown(Duration::from_secs(1)).await }) + }; + tokio::task::yield_now().await; + assert!(!shutting_down.is_finished()); + assert!(coordinator.admit_commit().await.is_none()); + + drop(permit); + assert!(shutting_down.await.unwrap().is_quiescent()); + } + + #[tokio::test] + async fn shutdown_cancels_and_joins_long_owner_cleanup() { + let coordinator = Arc::new(MutationCoordinator::new()); + let cleaned = Arc::new(AtomicBool::new(false)); + let owner_cleaned = cleaned.clone(); + let (started_tx, started_rx) = oneshot::channel(); + let owner = { + let coordinator = coordinator.clone(); + tokio::spawn(async move { + coordinator + .run_owned(move |mut cancellation| async move { + let _ = started_tx.send(()); + cancellation.cancelled().await; + owner_cleaned.store(true, Ordering::SeqCst); + "cancelled" + }) + .await + }) + }; + started_rx.await.unwrap(); + + let outcome = coordinator.begin_shutdown(Duration::from_secs(1)).await; + assert!(outcome.is_quiescent()); + assert!(cleaned.load(Ordering::SeqCst)); + assert_eq!(owner.await.unwrap().unwrap(), "cancelled"); + } + + #[tokio::test] + async fn ignored_cooperative_cancellation_is_force_aborted_and_quiescent() { + let coordinator = Arc::new(MutationCoordinator::new()); + let (started_tx, started_rx) = oneshot::channel(); + let owner = { + let coordinator = coordinator.clone(); + tokio::spawn(async move { + coordinator + .run_owned(move |_cancellation| async move { + let _ = started_tx.send(()); + std::future::pending::<()>().await; + }) + .await + }) + }; + started_rx.await.unwrap(); + + let outcome = coordinator.begin_shutdown(Duration::from_millis(50)).await; + assert!(outcome.is_quiescent()); + assert_eq!(owner.await.unwrap(), Err(MutationOwnerError::Stopped)); + } + + #[tokio::test] + async fn forced_abort_drops_the_owned_preparation_before_quiescence() { + let coordinator = Arc::new(MutationCoordinator::new()); + let dropped = Arc::new(AtomicBool::new(false)); + let owner_dropped = dropped.clone(); + let (started_tx, started_rx) = oneshot::channel(); + let owner = { + let coordinator = coordinator.clone(); + tokio::spawn(async move { + coordinator + .run_owned(move |_cancellation| async move { + let _probe = DropProbe(owner_dropped); + let _ = started_tx.send(()); + std::future::pending::<()>().await; + }) + .await + }) + }; + started_rx.await.unwrap(); + + let outcome = coordinator.begin_shutdown(Duration::from_millis(50)).await; + assert!(outcome.is_quiescent()); + assert!( + dropped.load(Ordering::SeqCst), + "quiescence was reported before the owner future's locals dropped" + ); + assert_eq!(owner.await.unwrap(), Err(MutationOwnerError::Stopped)); + } + + #[tokio::test] + async fn forced_owner_abort_cannot_release_an_in_flight_commit_barrier() { + let coordinator = Arc::new(MutationCoordinator::new()); + let (commit_started_tx, commit_started_rx) = oneshot::channel(); + let (release_commit_tx, release_commit_rx) = oneshot::channel(); + let owner = { + let coordinator = coordinator.clone(); + tokio::spawn(async move { + let commit_coordinator = coordinator.clone(); + coordinator + .run_owned(move |_cancellation| async move { + commit_coordinator + .run_commit(move || async move { + let _ = commit_started_tx.send(()); + let _ = release_commit_rx.await; + "committed" + }) + .await + }) + .await + }) + }; + commit_started_rx.await.unwrap(); + + let first = coordinator.begin_shutdown(Duration::from_millis(25)).await; + assert_eq!(first.owners_remaining, 0); + assert!( + !first.commit_quiescent, + "forced owner abort must not hide the detached commit task" + ); + assert_eq!(owner.await.unwrap(), Err(MutationOwnerError::Stopped)); + + release_commit_tx.send(()).unwrap(); + let second = coordinator.begin_shutdown(Duration::from_secs(1)).await; + assert!( + second.is_quiescent(), + "commit barrier did not release after the real operation completed" + ); + } + + #[tokio::test] + async fn caller_disconnect_does_not_orphan_detached_owner() { + let coordinator = Arc::new(MutationCoordinator::new()); + let cleaned = Arc::new(AtomicBool::new(false)); + let owner_cleaned = cleaned.clone(); + let (started_tx, started_rx) = oneshot::channel(); + let caller = { + let coordinator = coordinator.clone(); + tokio::spawn(async move { + coordinator + .run_owned(move |mut cancellation| async move { + let _ = started_tx.send(()); + cancellation.cancelled().await; + owner_cleaned.store(true, Ordering::SeqCst); + }) + .await + }) + }; + started_rx.await.unwrap(); + + caller.abort(); + assert!(caller.await.unwrap_err().is_cancelled()); + let outcome = coordinator.begin_shutdown(Duration::from_secs(1)).await; + assert!(outcome.is_quiescent()); + assert!( + cleaned.load(Ordering::SeqCst), + "dropping the request waiter must not detach owner state from shutdown" + ); + } +} diff --git a/apps/native/crates/local-api/src/process_group.rs b/apps/native/crates/local-api/src/process_group.rs new file mode 100644 index 0000000000..3f3b13e4bb --- /dev/null +++ b/apps/native/crates/local-api/src/process_group.rs @@ -0,0 +1,896 @@ +//! Ownership fence for a subprocess and every member of its process group. +//! +//! The command's immediate child is not a sufficient lifetime fence. A shell +//! can start `server >/dev/null 2>&1 &` and exit immediately: its pipes close +//! and its PID becomes reapable while the server remains in the same process +//! group. Treating that leader status as terminal both lies to `TaskRegistry` +//! and leaves the server outside coordinated shutdown. +//! +//! On Unix, [`ProcessGroupChild::spawn`] therefore starts an independent +//! watchdog as the process-group leader and joins the requested command to its +//! already-live group. The watchdog: +//! +//! - pins the PGID until Studio has proved that no other group member exists; +//! - stays alive across an immediate command-leader exit or closed stdio; +//! - receives a parent-liveness pipe, so an uncatchable Studio crash makes it +//! TERM, then KILL, the remaining group; +//! - ignores TERM itself, allowing graceful TERM to reach the workload while +//! preserving the ownership anchor for a later KILL/reap. +//! +//! A successful [`ProcessGroupChild::wait`] means the direct child was reaped, +//! no same-PG descendant remained, and the watchdog itself was reaped under the +//! same ownership fence used by signaling. Only that result authorizes a task's +//! terminal transition. + +use std::fs::OpenOptions; +use std::path::Path; +use std::process::{ExitStatus, Output, Stdio}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use tokio::io::AsyncReadExt; +#[cfg(unix)] +use tokio::process::ChildStdin; +use tokio::process::{Child, ChildStderr, ChildStdout, Command}; + +use crate::tasks::KillSignal; + +type SignalGroup = fn(u32, KillSignal) -> bool; + +const POLL_INTERVAL: Duration = Duration::from_millis(20); + +/// Parent-death fallback. The writer is held only by local-api. If that writer +/// disappears without an orderly owner cleanup, EOF wakes the watchdog and it +/// reaps the whole group. TERM is ignored by the anchor itself so the normal +/// TERM -> KILL lifecycle can keep a stable PGID throughout its grace period. +/// +/// macOS `pgrep` excludes its own ancestors, so from inside this watchdog +/// `pgrep -g $$ .` enumerates only the sibling workload and its descendants, +/// not the anchor. The explicit pattern is required by BSD pgrep. Enumeration +/// errors retain the anchor (and its shared child-lifetime lock) forever: an +/// indeterminate cleanup must fail closed, never unblock durable recovery. +#[cfg(unix)] +const PARENT_LIVENESS_WATCHDOG: &str = r#" +trap '' TERM +while IFS= read -r _; do :; done + +term_round=0 +while [ "$term_round" -lt 20 ]; do + members="$(pgrep -g "$$" . 2>/dev/null)" + status=$? + if [ "$status" -eq 1 ]; then + exit 0 + fi + if [ "$status" -ne 0 ]; then + while :; do sleep 60; done + fi + found=0 + for pid in $members; do + if [ "$pid" -eq "$$" ]; then continue; fi + found=1 + kill -TERM "$pid" 2>/dev/null || true + done + if [ "$found" -eq 0 ]; then exit 0; fi + term_round=$((term_round + 1)) + sleep 0.05 +done + +while :; do + members="$(pgrep -g "$$" . 2>/dev/null)" + status=$? + if [ "$status" -eq 1 ]; then + exit 0 + fi + if [ "$status" -ne 0 ]; then + while :; do sleep 60; done + fi + found=0 + for pid in $members; do + if [ "$pid" -eq "$$" ]; then continue; fi + found=1 + kill -KILL "$pid" 2>/dev/null || true + done + if [ "$found" -eq 0 ]; then exit 0; fi + sleep 0.05 +done +"#; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum OwnershipState { + /// The independent group anchor has not been successfully reaped. Its PID + /// therefore still pins the numeric PGID, even if the command leader has + /// already exited. + Armed(Option), + /// Successful group-quiescence proof is a one-way transition. Never signal + /// this number again, even if a later process receives it. + Reaped(ExitStatus), + /// The owner was dropped without a completed proof. Its best-effort KILL + /// ran and every escaped control is permanently inert. + Released, +} + +struct ProcessGroupOwnership { + state: Mutex, + signal_group: SignalGroup, +} + +impl ProcessGroupOwnership { + fn new(pid: Option, signal_group: SignalGroup) -> Self { + Self { + state: Mutex::new(OwnershipState::Armed(pid)), + signal_group, + } + } + + fn lock(&self) -> MutexGuard<'_, OwnershipState> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Runs the signal command while holding the ownership fence. A concurrent + /// successful wait cannot reap/release the anchor until this signal has + /// completed; conversely, once wait disarms first this fails closed. + fn signal(&self, signal: KillSignal) -> bool { + let state = self.lock(); + let OwnershipState::Armed(Some(pid)) = *state else { + return false; + }; + (self.signal_group)(pid, signal) + } + + fn cleanup_on_owner_drop(&self) { + let mut state = self.lock(); + if let OwnershipState::Armed(pid) = *state { + if let Some(pid) = pid { + let _ = (self.signal_group)(pid, KillSignal::Kill); + } + *state = OwnershipState::Released; + } + } + + #[cfg(test)] + fn state(&self) -> OwnershipState { + *self.lock() + } +} + +#[cfg(unix)] +struct GroupAnchor { + child: Child, + parent_liveness: Option, + shutdown_started: bool, +} + +/// Owns one spawned command leader, its independent group anchor, and all +/// authority to signal their PGID. +pub(crate) struct ProcessGroupChild { + // Drop runs our explicit `Drop` implementation before fields are dropped, + // so group cleanup happens while both child handles still pin identities. + child: Child, + ownership: Arc, + leader_status: Option, + #[cfg(unix)] + anchor: Option, +} + +/// Signal capability for select loops whose wait future already mutably +/// borrows the owning [`ProcessGroupChild`]. It cannot extend signal authority +/// past a successful whole-group reap: both handles share the same one-way +/// ownership state. +pub(crate) struct ProcessGroupControl { + ownership: Arc, +} + +impl ProcessGroupControl { + pub(crate) async fn signal(&self, signal: KillSignal) -> bool { + let ownership = Arc::clone(&self.ownership); + tokio::task::spawn_blocking(move || ownership.signal(signal)) + .await + .unwrap_or(false) + } +} + +impl ProcessGroupChild { + /// Spawns `command` into an independently anchored process group. + /// + /// Callers should configure stdio, cwd, environment, and `kill_on_drop`, + /// but must use this method instead of `Command::spawn`: wrapping an + /// already-spawned group leader cannot protect the leader-exited descendant + /// case because that leader itself is the identity that must stay pinned. + pub(crate) async fn spawn( + command: &mut Command, + child_lifetime_lock_path: &Path, + ) -> std::io::Result { + #[cfg(unix)] + { + if let Some(parent) = child_lifetime_lock_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + // Deep callers (notably Git helpers and freshly materialized + // sandbox registries) may select the app-wide fence before + // anything else has created its directory. Do this on Tokio's + // filesystem pool before the synchronous anchor setup so a + // valid spawn cannot fail merely because its retention path + // has not been touched yet. + tokio::fs::create_dir_all(parent).await?; + } + let (mut anchor_child, parent_liveness, group_id) = + spawn_group_anchor(child_lifetime_lock_path)?; + let process_group = i32::try_from(group_id) + .map_err(|_| std::io::Error::other("watchdog pid does not fit a process group"))?; + command.process_group(process_group); + let child = match command.spawn() { + Ok(child) => child, + Err(error) => { + drop(parent_liveness); + // No workload was spawned. Let the watchdog prove that + // its group is empty before it exits and releases the + // inherited shared child-lifetime fence. + let _ = anchor_child.wait().await; + return Err(error); + } + }; + let ownership = Arc::new(ProcessGroupOwnership::new( + Some(group_id), + signal_anchored_process_group, + )); + Ok(Self { + child, + ownership, + leader_status: None, + anchor: Some(GroupAnchor { + child: anchor_child, + parent_liveness: Some(parent_liveness), + shutdown_started: false, + }), + }) + } + + #[cfg(not(unix))] + { + let child = command.spawn()?; + Ok(Self::new(child)) + } + } + + /// Unanchored constructor retained for non-Unix and ownership-fence unit + /// tests. Production Unix route/setup spawns must use [`Self::spawn`]. + #[cfg(not(unix))] + fn new(child: Child) -> Self { + Self::new_with_signaller(child, signal_process_group) + } + + #[cfg(any(test, not(unix)))] + fn new_with_signaller(child: Child, signal_group: SignalGroup) -> Self { + let ownership = Arc::new(ProcessGroupOwnership::new(child.id(), signal_group)); + Self { + child, + ownership, + leader_status: None, + #[cfg(unix)] + anchor: None, + } + } + + /// Returns the owned process-group id. On Unix this is the independent + /// anchor's pid, not necessarily the immediate command child's pid. + pub(crate) fn id(&self) -> Option { + match *self.ownership.lock() { + OwnershipState::Armed(pid) => pid, + OwnershipState::Reaped(_) | OwnershipState::Released => None, + } + } + + pub(crate) fn control(&self) -> ProcessGroupControl { + ProcessGroupControl { + ownership: Arc::clone(&self.ownership), + } + } + + pub(crate) fn take_stdout(&mut self) -> Option { + self.child.stdout.take() + } + + pub(crate) fn take_stderr(&mut self) -> Option { + self.child.stderr.take() + } + + /// Signals the owned process group if and only if its anchor has not been + /// successfully reaped. The blocking `kill`/`taskkill` invocation runs off + /// the async worker thread and is serialized with the final disarm. + pub(crate) async fn signal(&self, signal: KillSignal) -> bool { + self.control().signal(signal).await + } + + /// Waits until the direct leader AND every same-process-group descendant + /// are gone, then reaps the independent anchor and atomically retires all + /// signal authority. + /// + /// Group-enumeration failures retry forever rather than returning an + /// unproven terminal result. Keeping this future pending deliberately + /// leaves the corresponding TaskRegistry entry owned and Running, so a + /// later Stop/shutdown can retry signaling it. + pub(crate) async fn wait(&mut self) -> std::io::Result { + loop { + { + let state = self.ownership.lock(); + if let OwnershipState::Reaped(status) = *state { + return Ok(status); + } + if matches!(*state, OwnershipState::Released) { + return Err(std::io::Error::other( + "process-group owner was already released", + )); + } + } + + if self.leader_status.is_none() { + match self.child.try_wait() { + Ok(Some(status)) => self.leader_status = Some(status), + Ok(None) => {} + Err(error) => { + tracing::error!(%error, "cannot reap process-group command leader; retaining ownership"); + } + } + } + + #[cfg(unix)] + let anchored_group = match (self.leader_status, self.anchor.as_ref()) { + (Some(leader_status), Some(_)) => { + let group_id = self.id().ok_or_else(|| { + std::io::Error::other("process-group ownership disappeared before reap") + })?; + Some((leader_status, group_id)) + } + _ => None, + }; + #[cfg(unix)] + if let Some((leader_status, group_id)) = anchored_group { + match group_has_non_anchor_members(group_id).await { + Ok(true) => {} + Ok(false) => { + // Once only the anchor remains, no process capable of + // forking another same-group member exists. Kill/reap + // the anchor under the signal fence; that exact + // critical section closes the PGID-reuse window. + let mut state = self.ownership.lock(); + if let OwnershipState::Reaped(status) = *state { + return Ok(status); + } + if matches!(*state, OwnershipState::Released) { + return Err(std::io::Error::other( + "process-group owner was released during reap", + )); + } + let Some(anchor) = self.anchor.as_mut() else { + return Err(std::io::Error::other( + "process-group anchor disappeared during reap", + )); + }; + if !anchor.shutdown_started { + let _ = anchor.child.start_kill(); + anchor.shutdown_started = true; + } + match anchor.child.try_wait() { + Ok(Some(_)) => { + drop(anchor.parent_liveness.take()); + *state = OwnershipState::Reaped(leader_status); + return Ok(leader_status); + } + Ok(None) => {} + Err(error) => { + tracing::error!(%error, "cannot reap process-group anchor; retaining ownership"); + } + } + } + Err(error) => { + tracing::error!( + %error, + group_id, + "cannot prove process-group quiescence; retaining ownership" + ); + } + } + } + + #[cfg(not(unix))] + if let Some(status) = self.leader_status { + let mut state = self.ownership.lock(); + *state = OwnershipState::Reaped(status); + return Ok(status); + } + + // Unit-only unanchored Unix owners retain the previous + // direct-child fence semantics. All production Unix call sites use + // `spawn`, which always installs `anchor`. + #[cfg(unix)] + if self.anchor.is_none() { + if let Some(status) = self.leader_status { + let mut state = self.ownership.lock(); + *state = OwnershipState::Reaped(status); + return Ok(status); + } + } + + tokio::time::sleep(POLL_INTERVAL).await; + } + } + + /// KILLs the group and keeps owning it until [`Self::wait`] proves the + /// complete reap. `warning_after` is diagnostic only: expiration never + /// turns an unproven cleanup into a terminal task. + pub(crate) async fn kill_and_reap( + &mut self, + warning_after: Duration, + context: &'static str, + ) -> ExitStatus { + let _ = self.signal(KillSignal::Kill).await; + match tokio::time::timeout(warning_after, self.wait()).await { + Ok(Ok(status)) => return status, + Ok(Err(error)) => { + tracing::error!(%error, context, "process-group reap failed; retaining ownership"); + } + Err(_) => { + tracing::error!( + context, + "process-group reap exceeded warning deadline; retaining ownership" + ); + } + } + loop { + match self.wait().await { + Ok(status) => return status, + Err(error) => { + tracing::error!(%error, context, "process-group reap retry failed; retaining ownership"); + tokio::time::sleep(POLL_INTERVAL).await; + } + } + } + } + + /// Borrowing `wait_with_output`: drain both pipes concurrently while + /// retaining the group owner, then wait for whole-group quiescence. + pub(crate) async fn wait_with_output(&mut self) -> std::io::Result { + let stdout = self.take_stdout(); + let stderr = self.take_stderr(); + let read_stdout = async move { + let mut bytes = Vec::new(); + if let Some(mut pipe) = stdout { + pipe.read_to_end(&mut bytes).await?; + } + Ok::<_, std::io::Error>(bytes) + }; + let read_stderr = async move { + let mut bytes = Vec::new(); + if let Some(mut pipe) = stderr { + pipe.read_to_end(&mut bytes).await?; + } + Ok::<_, std::io::Error>(bytes) + }; + let (stdout, stderr) = tokio::try_join!(read_stdout, read_stderr)?; + let status = self.wait().await?; + Ok(Output { + status, + stdout, + stderr, + }) + } +} + +impl Drop for ProcessGroupChild { + fn drop(&mut self) { + // Best effort and intentionally synchronous: Drop may run during + // runtime teardown. The parent-liveness writer is dropped immediately + // afterward, waking the independent watchdog if the direct KILL did + // not complete before the runtime disappeared. + self.ownership.cleanup_on_owner_drop(); + } +} + +#[cfg(unix)] +fn spawn_group_anchor( + child_lifetime_lock_path: &Path, +) -> std::io::Result<(Child, ChildStdin, u32)> { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).mode(0o600); + let lifetime_lock = options.open(child_lifetime_lock_path)?; + lifetime_lock.set_permissions(std::fs::Permissions::from_mode(0o600))?; + lifetime_lock.try_lock_shared()?; + + let mut command = Command::new("/bin/sh"); + command + .arg("-c") + .arg(PARENT_LIVENESS_WATCHDOG) + .arg("decocms-local-api-watchdog") + .stdin(Stdio::piped()) + // The locked File moves into an exec-open descriptor. The anchor is + // now the sole owner of this shared fence, including after local-api + // is SIGKILLed. + .stdout(Stdio::from(lifetime_lock)) + .stderr(Stdio::null()) + .process_group(0) + // False is intentional: on abrupt runtime death the liveness pipe, + // not Tokio's child drop path, lets this process reap the whole group. + .kill_on_drop(false); + let mut child = command.spawn()?; + let group_id = child + .id() + .ok_or_else(|| std::io::Error::other("process-group watchdog reported no pid"))?; + let parent_liveness = child + .stdin + .take() + .ok_or_else(|| std::io::Error::other("process-group watchdog stdin was not piped"))?; + Ok((child, parent_liveness, group_id)) +} + +#[cfg(unix)] +async fn group_has_non_anchor_members(group_id: u32) -> std::io::Result { + let output = Command::new("pgrep") + .args(["-g", &group_id.to_string(), "."]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output() + .await?; + // `pgrep` exits 1 when it matched nothing. That is a valid empty group + // observation (e.g. the anchor was already KILLed but remains our unreaped + // child handle); all other nonzero statuses are indeterminate. + if !output.status.success() { + if output.status.code() == Some(1) { + return Ok(false); + } + return Err(std::io::Error::other(format!( + "pgrep exited {:?}", + output.status.code() + ))); + } + let anchor_pid = group_id.to_string(); + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .any(|pid| !pid.is_empty() && pid != anchor_pid)) +} + +/// Signal every current member except the anchor. The anchor owns both the +/// PGID identity and an inherited shared child-lifetime lock; group-KILLing it +/// would release the restart fence before resistant descendants were proven +/// gone. Enumeration is immediate and never persisted. Any indeterminate +/// result sends nothing and leaves the EOF watchdog to fail closed. +#[cfg(unix)] +fn signal_anchored_process_group(group_id: u32, signal: KillSignal) -> bool { + let output = match std::process::Command::new("pgrep") + .args(["-g", &group_id.to_string(), "."]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + { + Ok(output) => output, + Err(error) => { + tracing::error!(%error, group_id, "cannot enumerate anchored process group"); + return false; + } + }; + if !output.status.success() { + if output.status.code() != Some(1) { + tracing::error!( + status = ?output.status.code(), + group_id, + "indeterminate anchored process-group enumeration" + ); + } + return output.status.code() == Some(1); + } + + let mut all_signaled = true; + for pid in String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .filter_map(|raw| raw.parse::().ok()) + .filter(|pid| *pid != group_id) + { + let signaled = std::process::Command::new("kill") + .arg(signal.flag()) + .arg(pid.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + all_signaled &= signaled; + } + all_signaled +} + +#[cfg(not(unix))] +fn signal_process_group(pid: u32, _signal: KillSignal) -> bool { + std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + static REUSE_SIGNALS: AtomicUsize = AtomicUsize::new(0); + static WAIT_SIGNALS: AtomicUsize = AtomicUsize::new(0); + static DROP_SIGNALS: AtomicUsize = AtomicUsize::new(0); + static ESCAPED_SIGNALS: AtomicUsize = AtomicUsize::new(0); + static IGNORED_SIGNALS: AtomicUsize = AtomicUsize::new(0); + + fn record_reuse_signal(_pid: u32, _signal: KillSignal) -> bool { + REUSE_SIGNALS.fetch_add(1, Ordering::SeqCst); + true + } + + fn record_wait_signal(_pid: u32, _signal: KillSignal) -> bool { + WAIT_SIGNALS.fetch_add(1, Ordering::SeqCst); + true + } + + fn record_drop_signal(_pid: u32, _signal: KillSignal) -> bool { + DROP_SIGNALS.fetch_add(1, Ordering::SeqCst); + true + } + + fn record_escaped_signal(_pid: u32, _signal: KillSignal) -> bool { + ESCAPED_SIGNALS.fetch_add(1, Ordering::SeqCst); + true + } + + fn ignore_signal(_pid: u32, _signal: KillSignal) -> bool { + IGNORED_SIGNALS.fetch_add(1, Ordering::SeqCst); + true + } + + #[test] + fn missing_process_identity_fails_closed() { + let ownership = ProcessGroupOwnership::new(None, record_reuse_signal); + assert_eq!(ownership.state(), OwnershipState::Armed(None)); + assert!(!ownership.signal(KillSignal::Term)); + } + + #[test] + fn reaped_pid_is_never_signaled_even_if_the_number_is_reused() { + REUSE_SIGNALS.store(0, Ordering::SeqCst); + let ownership = ProcessGroupOwnership::new(Some(41_337), record_reuse_signal); + *ownership.lock() = OwnershipState::Reaped(success_status()); + assert!(!ownership.signal(KillSignal::Kill)); + assert_eq!(REUSE_SIGNALS.load(Ordering::SeqCst), 0); + } + + #[cfg(unix)] + fn success_status() -> ExitStatus { + use std::os::unix::process::ExitStatusExt; + ExitStatus::from_raw(0) + } + + #[cfg(windows)] + fn success_status() -> ExitStatus { + use std::os::windows::process::ExitStatusExt; + ExitStatus::from_raw(0) + } + + #[cfg(unix)] + #[tokio::test] + async fn spawn_materializes_a_missing_child_lifetime_lock_parent() { + let dir = tempfile::tempdir().unwrap(); + let lifetime_lock_path = dir + .path() + .join("not-yet-created") + .join("nested") + .join("child-lifetime.lock"); + let mut command = Command::new("sh"); + command + .args(["-c", "exit 0"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true); + + let mut owned = ProcessGroupChild::spawn(&mut command, &lifetime_lock_path) + .await + .expect("spawn creates the child-lifetime fence parent"); + tokio::time::timeout(Duration::from_secs(2), owned.wait()) + .await + .expect("fixture group completed") + .expect("fixture wait succeeded"); + + assert!(lifetime_lock_path.is_file()); + } + + #[cfg(unix)] + #[tokio::test] + async fn leader_exit_and_closed_stdio_do_not_hide_a_live_group_descendant() { + let dir = tempfile::tempdir().unwrap(); + let pid_file = dir.path().join("descendant.pid"); + let lifetime_lock_path = dir.path().join("child-lifetime.lock"); + let mut command = Command::new("sh"); + command + .args([ + "-c", + &format!( + "sleep 30 >/dev/null 2>&1 & echo $! > {}", + pid_file.display() + ), + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut owned = ProcessGroupChild::spawn(&mut command, &lifetime_lock_path) + .await + .expect("spawn anchored fixture"); + + let descendant = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Ok(raw) = tokio::fs::read_to_string(&pid_file).await { + if let Ok(pid) = raw.trim().parse::() { + break pid; + } + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }) + .await + .expect("fixture wrote descendant pid"); + + assert!( + tokio::time::timeout(Duration::from_millis(150), owned.wait()) + .await + .is_err(), + "leader exit and stdio EOF must not look like whole-group terminal" + ); + let contender = OpenOptions::new() + .read(true) + .write(true) + .open(&lifetime_lock_path) + .expect("open lifetime fence contender"); + assert!( + matches!(contender.try_lock(), Err(std::fs::TryLockError::WouldBlock)), + "anchor must retain its shared restart fence while a descendant lives" + ); + assert!(owned.signal(KillSignal::Term).await); + let status = tokio::time::timeout(Duration::from_secs(3), owned.wait()) + .await + .expect("TERM reaped background descendant") + .expect("group reap succeeded"); + assert!(status.success(), "direct shell leader exited successfully"); + assert!(!process_alive(descendant)); + assert!(matches!(owned.ownership.state(), OwnershipState::Reaped(_))); + let successor = OpenOptions::new() + .read(true) + .write(true) + .open(&lifetime_lock_path) + .expect("open post-reap lifetime fence"); + successor + .try_lock() + .expect("exclusive recovery fence becomes available only after reap"); + } + + #[cfg(unix)] + #[tokio::test] + async fn cleanup_warning_deadline_does_not_release_an_unreaped_owner() { + IGNORED_SIGNALS.store(0, Ordering::SeqCst); + let mut command = Command::new("sh"); + command + .args(["-c", "sleep 30"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .process_group(0); + let child = command.spawn().expect("spawn cleanup-timeout fixture"); + let pid = child.id().expect("fixture pid"); + let owned = ProcessGroupChild::new_with_signaller(child, ignore_signal); + let control = owned.control(); + let cleanup = tokio::spawn(async move { + let mut owned = owned; + owned + .kill_and_reap(Duration::from_millis(25), "test cleanup") + .await + }); + + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + !cleanup.is_finished(), + "warning timeout must retain the live owner instead of returning terminal" + ); + assert!(control.signal(KillSignal::Term).await); + assert!(IGNORED_SIGNALS.load(Ordering::SeqCst) >= 2); + + let _ = std::process::Command::new("kill") + .args(["-KILL", &format!("-{pid}")]) + .status(); + tokio::time::timeout(Duration::from_secs(2), cleanup) + .await + .expect("cleanup completed after a proven reap") + .expect("cleanup task did not panic"); + } + + #[cfg(unix)] + #[tokio::test] + async fn panic_after_successful_wait_cannot_run_drop_cleanup() { + WAIT_SIGNALS.store(0, Ordering::SeqCst); + let mut command = Command::new("sh"); + command + .args(["-c", "exit 0"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .process_group(0); + let child = command.spawn().expect("spawn process-group fixture"); + let mut owned = ProcessGroupChild::new_with_signaller(child, record_wait_signal); + owned.wait().await.expect("wait fixture"); + + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _owned = owned; + panic!("panic after wait"); + })); + assert!(panic.is_err()); + assert_eq!(WAIT_SIGNALS.load(Ordering::SeqCst), 0); + } + + #[cfg(unix)] + #[tokio::test] + async fn dropping_unreaped_owner_runs_group_cleanup_once() { + DROP_SIGNALS.store(0, Ordering::SeqCst); + let mut command = Command::new("sh"); + command + .args(["-c", "sleep 30"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .process_group(0); + let child = command.spawn().expect("spawn process-group fixture"); + let owned = ProcessGroupChild::new_with_signaller(child, record_drop_signal); + drop(owned); + assert_eq!(DROP_SIGNALS.load(Ordering::SeqCst), 1); + } + + #[cfg(unix)] + #[tokio::test] + async fn control_that_outlives_owner_is_permanently_inert() { + ESCAPED_SIGNALS.store(0, Ordering::SeqCst); + let mut command = Command::new("sh"); + command + .args(["-c", "sleep 30"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .process_group(0); + let child = command.spawn().expect("spawn process-group fixture"); + let owned = ProcessGroupChild::new_with_signaller(child, record_escaped_signal); + let escaped = owned.control(); + + drop(owned); + assert_eq!(ESCAPED_SIGNALS.load(Ordering::SeqCst), 1); + assert!(!escaped.signal(KillSignal::Term).await); + assert_eq!(ESCAPED_SIGNALS.load(Ordering::SeqCst), 1); + } + + #[cfg(unix)] + fn process_alive(pid: u32) -> bool { + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) + } +} diff --git a/apps/native/crates/local-api/src/router.rs b/apps/native/crates/local-api/src/router.rs new file mode 100644 index 0000000000..e5000fd47e --- /dev/null +++ b/apps/native/crates/local-api/src/router.rs @@ -0,0 +1,960 @@ +//! The route table — TWO separate routers, one per loopback listener (see +//! `lib.rs`'s `start`/`ServerHandle` doc comments for why: the app's own API +//! and the dev-server preview now live on distinct origins, verified by a +//! spike as necessary for SSE/WebSocket to stream cleanly through a +//! port-isolated cross-origin iframe in the real WKWebView). SHARED FILE +//! (see the native module-ownership contract) — adding, removing, or +//! re-pathing a route is a change here; family implementers edit their +//! `routes/*.rs` handler bodies, never this file's wiring. If a family +//! genuinely needs a new path this file doesn't have, that's an interface +//! request, not a local edit. +//! +//! ## [`build`] — the MAIN listener +//! +//! 1. Public zone — `GET /health`; in embedded mode also exact UI assets, +//! HTML-navigation SPA fallback, and one-time `/_local/session/bootstrap`. +//! 2. `/_sandbox/*`, `/threads*`, `/models` — nested sub-routers, each +//! wrapped in [`guard`] (standalone Origin + bearer, or embedded exact +//! Host/unsafe-Origin + HttpOnly session cookie) +//! and each with its own JSON-404 fallback so an unmatched path WITHIN +//! one of these prefixes returns `{"error":"Not found: "}` rather +//! than falling through to the app-API fallback. +//! 3. Everything else (no matching prefix and not a public UI response) — +//! [`app_or_ui_fallback`] authenticates, removes only the local control +//! cookie, then invokes `routes::upstream::proxy`, the app-API +//! intercept-or-proxy catchall. Formerly reached only via a +//! `/upstream` prefix; a bare path now gets the exact same treatment, +//! since the reverse-proxy family that used to share this fallback +//! moved to its own listener (below). WRAPPED in [`guard]` — merged in +//! from a small sub-router (`app_api`) that carries its own +//! `.layer(guard)`, exactly like the zone-2 nests above (see [`build`]'s +//! body for why `.merge()`, not `.nest()`, is what mounts it at the +//! root with no path prefix). +//! +//! ## [`build_preview`] — the PREVIEW listener +//! +//! `routes::proxy::fallback` (the reverse-HTTP-proxy-to-dev-server catchall — +//! handle-header/active routing, the sniffed port, and the WS upgrade). No +//! local-api auth is applied here: sandbox cookies and authorization belong to +//! the application under preview and are proxied unchanged. An explicitly +//! enabled selftest route is the sole exception to the otherwise-all-proxy +//! route table. +//! +//! ## OPTIONS preflight +//! +//! Intercepted by [`intercept_options`] as the OUTERMOST layer — before +//! ANY routing, including the zone-2 guards — so a preflight never risks a +//! 401/405. This is a deliberate reading of a genuinely ambiguous sentence +//! in the native local-API contract, which lists +//! "OPTIONS preflight" among examples of daemon-unauthenticated routes now +//! "tightened" to require the bearer. Taken completely literally that +//! would mean every real browser CORS preflight 401s (browsers never +//! attach `Authorization` to a preflight — that's the whole point of +//! preflight: ask permission BEFORE sending credentials), which would make +//! cross-origin `fetch()` from the embedded webview permanently broken. +//! No test in either suite pins OPTIONS-requires-bearer specifically (see +//! the bootstrap report). Implemented here: OPTIONS still enforces the +//! Origin allowlist (403 `forbidden_origin` on an unrecognized Origin, +//! matching the "checked before auth, fail fast" security posture) but +//! never the bearer. Flagged for the Phase 3 (Tauri shell) owner to +//! confirm against a real WKWebView preflight. + +use std::sync::Arc; + +use axum::extract::{Extension, OriginalUri, State}; +use axum::http::{header, Method, StatusCode}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use axum::Router; + +use crate::client_auth::ClientAuth; +use crate::cors; +use crate::error::ApiError; +use crate::routes; +use crate::state::AppState; +use crate::ui::{self, UiAssetProvider}; + +type Request = axum::extract::Request; + +#[derive(Clone)] +struct GuardState { + auth: ClientAuth, + mode: crate::state::ApiMode, +} + +#[derive(Clone)] +struct MainRuntime { + auth: ClientAuth, + ui_assets: Option>, + preview_origin: Arc, +} + +pub fn build( + state: AppState, + auth: ClientAuth, + ui_assets: Option>, + preview_origin: String, +) -> Router { + let guard_state = GuardState { + auth: auth.clone(), + mode: state.mode, + }; + let runtime = MainRuntime { + auth: auth.clone(), + ui_assets, + preview_origin: preview_origin.into(), + }; + let sandbox = Router::new() + .route("/read", post(routes::fs::read)) + .route("/write", post(routes::fs::write)) + .route("/unlink", post(routes::fs::unlink)) + .route("/mkdir", post(routes::fs::mkdir)) + .route("/rename", post(routes::fs::rename)) + .route("/edit", post(routes::fs::edit)) + .route("/grep", post(routes::fs::grep)) + .route("/glob", post(routes::fs::glob)) + .route("/write_from_url", post(routes::fs::write_from_url)) + .route("/upload_to_url", post(routes::fs::upload_to_url)) + .route("/tools/sync", post(routes::fs::tools_sync)) + .route("/bash", post(routes::bash::bash)) + .route("/tasks", get(routes::tasks::list)) + .route("/tasks/kill-all", post(routes::tasks::kill_all)) + .route( + "/tasks/:id", + get(routes::tasks::get).delete(routes::tasks::delete), + ) + .route("/tasks/:id/kill", post(routes::tasks::kill)) + .route("/tasks/:id/stream", get(routes::tasks::stream)) + .route("/git/status", get(routes::git::status)) + .route("/git/diff", get(routes::git::diff).post(routes::git::diff)) + .route("/git/publish", post(routes::git::publish)) + .route("/git/discard", post(routes::git::discard)) + .route("/git/rebase", post(routes::git::rebase)) + .route( + "/config", + get(routes::config::read) + .post(routes::config::update) + .put(routes::config::update), + ) + .route("/setup/clone", post(routes::setup::clone)) + .route("/setup/install", post(routes::setup::install)) + .route("/setup/ensure", post(routes::setup::ensure)) + .route("/setup/start", post(routes::setup::start)) + // NEW — no daemon precedent (see routes::setup::stop's own doc + // comment): kills the resolved target's running dev/start task + // WITHOUT respawning, giving the sandbox drawer's Stop button a + // real desktop-local action to call. + .route("/setup/stop", post(routes::setup::stop)) + .route("/orgfs-config", post(routes::orgfs::orgfs_config)) + // `/_sandbox/orgfs/:org/:volume/**` — the loopback WebDAV surface + // rclone mounts as the org filesystem (P1 of + // `apps/native/docs/org-fs-plan.md`). Nested as a catchall rather + // than routed per path/method: everything after `/` is + // the in-volume path, and PROPFIND/MKCOL/MOVE are extension methods + // `axum::routing` has no filter for. Nested BEFORE `.layer(guard)` + // below so it inherits the same Origin + bearer/cookie + // authentication every other `/_sandbox` route gets. + .nest("/orgfs", routes::webdav::router()) + .route("/scripts", get(routes::scripts::list)) + .route("/exec/:name", post(routes::scripts::exec)) + .route("/exec/:name/kill", post(routes::scripts::exec_kill)) + .route("/events", get(routes::events::events)) + .route("/dispatch", post(routes::dispatch::dispatch)) + .route("/runs/:id", delete(routes::dispatch::cancel_run)) + .route("/preview-handle", post(routes::proxy::set_preview_handle)) + // GET /_sandbox/repo-dir — see routes::repo_dir's module doc (a + // git-backed sandbox's absolute workdir, for the webview's + // "Open in VS Code/Cursor" deep link). + .route("/repo-dir", get(routes::repo_dir::get)) + .fallback(sandbox_not_found) + .layer(middleware::from_fn_with_state(guard_state.clone(), guard)); + + let threads = Router::new() + .route( + "/", + get(routes::threads::list).post(routes::threads::create), + ) + .route( + "/:id", + get(routes::threads::get) + .patch(routes::threads::update) + .delete(routes::threads::delete), + ) + .route( + "/:id/messages", + get(routes::threads::messages_list).post(routes::threads::messages_create), + ) + .route("/:id/runs", get(routes::threads::runs_list)) + .fallback(sandbox_not_found) + .layer(middleware::from_fn_with_state(guard_state.clone(), guard)); + + let models = Router::new() + .route("/", get(routes::models::list)) + .layer(middleware::from_fn_with_state(guard_state.clone(), guard)); + + // Literal internal app-API routes. The general app-API catchall is owned + // by `app_or_ui_fallback`, where public UI navigation can be distinguished + // from a private upstream request before authentication is selected. + let app_api = Router::new() + // Literal route, matched BEFORE the app-API `proxy` fallback below + // — see `routes/upstream.rs::complete_session`'s doc comment for + // why this HTTP mirror of the `auth_complete_session` Tauri command + // exists (the e2e contract suite has no Tauri context to invoke IPC + // commands through). Renamed from the old `/upstream/_complete_session` + // now that the `/upstream` prefix is gone — a stable, collision-free + // bare path under its own `/_auth` namespace (distinct from any + // `/api/*` path a real mesh org might route). + .route( + "/_auth/complete-session", + post(routes::upstream::complete_session), + ) + // Black-box mirror of the Tauri `auth_status` command. Kept beside + // the existing complete-session/logout mirrors so all three observe + // the same process-wide upstream session. + .route("/_auth/status", get(routes::upstream::status)) + // Same rationale as `/_auth/complete-session` right above — see + // `routes/upstream.rs::logout`'s doc comment. Renamed from + // `/upstream/_logout`. + .route("/_auth/logout", post(routes::upstream::logout)) + // The WEBVIEW half of the browser-completed MCP OAuth flow. Guarded + // like everything else here: a parked authorization code must only be + // readable by a caller that proved it is this app. Its unauthenticated + // sibling — the redirect target the system browser actually lands on — + // is registered outside this layer; see `routes::mcp_callback`. + .route( + "/_auth/mcp-callback/result", + get(routes::mcp_callback::result).delete(routes::mcp_callback::discard), + ) + .layer(middleware::from_fn_with_state(guard_state.clone(), guard)); + + let mut app = Router::new() + .route("/health", get(routes::health::health)) + // CORS-only — `.route_layer()` wraps ONLY the routes registered so + // far (`/health`), never the `.nest()`ed zone-2 routers below (each + // already wrapped in the full `guard`, applied independently) or + // `app_api`'s own fallback merged in next (same reasoning — it + // carries its own `guard`). See [`cors_only`]'s doc comment for why + // `/health` needs this at all. + .route_layer(middleware::from_fn_with_state( + guard_state.clone(), + cors_only, + )) + .nest("/_sandbox", sandbox) + .nest("/threads", threads) + .nest("/models", models) + // `.merge()`, not `.nest()`: these literal routes live at the root. + .merge(app_api) + // Registered OUTSIDE `guard`, deliberately. This is where an MCP + // OAuth provider redirects the user's SYSTEM BROWSER, which has no + // per-launch session cookie and no way to be given one, so requiring + // it would 401 every consent that just succeeded. It is not the + // security boundary — the webview keeps `state` in memory and rejects + // any callback that does not match it — and it only ever parks bytes; + // reading them back still requires the guard. See + // `routes::mcp_callback`'s doc comment. + .route("/_auth/mcp-callback", get(routes::mcp_callback::receive)); + + if auth.is_embedded() { + let private_session = Router::new() + .route("/_local/session", get(session_status)) + .layer(middleware::from_fn_with_state(guard_state.clone(), guard)); + app = app.merge(private_session).route( + "/_local/session/bootstrap", + post(bootstrap_embedded_session), + ); + } + + app.fallback(app_or_ui_fallback) + .layer(middleware::from_fn_with_state( + guard_state, + intercept_options, + )) + .layer(Extension(runtime)) + // Exact Host validation is intentionally outermost so public UI, + // health, bootstrap, OPTIONS, and private routes share the same + // DNS-rebinding boundary. + .layer(middleware::from_fn_with_state(auth, require_expected_host)) + .with_state(state) +} + +/// The PREVIEW listener's router — see the module doc's "[`build_preview`] +/// — the PREVIEW listener" section. No cookie or authorization header is +/// consumed or rewritten before the selected sandbox sees the request. +pub fn build_preview( + state: AppState, + preview_port: u16, + cookie_selftest_control_origin: Option>, +) -> Router { + let mut app = Router::new().fallback(routes::proxy::fallback); + if let Some(control_origin) = cookie_selftest_control_origin { + app = app + .route( + "/_local/selftest/preview-cookie", + get(routes::proxy::preview_cookie_selftest) + .options(routes::proxy::preview_cookie_selftest), + ) + .route( + "/_local/selftest/preview-frame", + get(routes::proxy::preview_frame_selftest), + ) + .layer(Extension(routes::proxy::PreviewCookieSelftest { + control_origin, + expected_host: Arc::from(format!("localhost:{preview_port}")), + cookie_value: Arc::from(uuid::Uuid::new_v4().simple().to_string()), + })); + } + app.with_state(state) +} + +/// `{"error":"Not found: "}` — byte-parity in spirit +/// with the daemon's `vmRouteH` default branch +/// (`jsonResponse({ error: \`Not found: ${prefix}${vmPath}\` }, 404)`). +/// Uses `OriginalUri` (not the post-`nest()` rewritten `Uri`) so the +/// message carries the full path a caller actually requested, e.g. +/// `/_sandbox/does-not-exist`, not the nest-relative `/does-not-exist`. +async fn sandbox_not_found(OriginalUri(uri): OriginalUri) -> ApiError { + ApiError::not_found(format!("Not found: {}", uri.path())) +} + +/// Standalone: Origin allowlist then bearer auth. Embedded: exact Host, +/// exact Origin on unsafe methods, then the per-launch session cookie. +/// Applied to every zone-2 sub-router (`/_sandbox`, `/threads`, `/models`) +/// AND to `app_api` (the merged-in, no-prefix app-API fallback + its +/// `/_auth/*` literal routes), including their own 404 fallbacks, via +/// `.layer()` (not `.route_layer()`, which would skip the fallback and let +/// an unauthenticated caller enumerate unmatched routes without ever +/// proving caller authority). +async fn guard(State(state): State, mut req: Request, next: Next) -> Response { + let decision = match authorize_private(&state, &mut req) { + Ok(decision) => decision, + Err(error) => return error.into_response(), + }; + let mut response = next.run(req).await; + if let Some(decision) = decision { + cors::apply_headers(response.headers_mut(), &decision); + } + response +} + +/// CORS-only half of [`guard`] — validates `Origin` and applies the +/// `Access-Control-Allow-Origin`/`Vary` headers, but never checks the +/// bearer. Applied via `.route_layer()` to `GET /health` only. +/// +/// `/health` is intentionally unauthenticated in both states +/// (the native local-API contract, byte-parity with the daemon), but the +/// contract's `#origin-validation--cors` section is explicit that "**every +/// request** (including simple GETs)" gets Origin-checked and CORS-headed — +/// the original bootstrap wired `/health` directly on the top-level router +/// with no CORS treatment at all, so a real cross-origin `fetch()` from the +/// webview (`tauri://localhost` -> `http://127.0.0.1:`) got a `200` +/// with no `Access-Control-Allow-Origin` header, which the browser's own +/// CORS enforcement then turns into an opaque network-error at the +/// `fetch()` call site. Discovered by Phase 3's self-test +/// (`src-tauri/selftest/bundle.js`'s `health200NoAuth` check — see +/// the native Tauri integration's "Discovered gap" section, now fixed here). +async fn cors_only(State(state): State, req: Request, next: Next) -> Response { + if state.auth.is_embedded() { + return next.run(req).await; + } + let decision = cors::validate(req.headers(), state.mode); + if !decision.allowed { + return ApiError::forbidden_origin().into_response(); + } + let mut res = next.run(req).await; + cors::apply_headers(res.headers_mut(), &decision); + res +} + +/// See the module doc comment's "OPTIONS preflight" section for why this +/// is the outermost layer and never requires the bearer. +async fn intercept_options(State(state): State, req: Request, next: Next) -> Response { + if req.method() != Method::OPTIONS { + return next.run(req).await; + } + if let Err(error) = state.auth.require_expected_host(req.headers()) { + return error.into_response(); + } + if state.auth.is_embedded() { + if let Err(error) = state.auth.require_exact_origin(req.headers()) { + return error.into_response(); + } + return StatusCode::NO_CONTENT.into_response(); + } + let decision = cors::validate(req.headers(), state.mode); + if !decision.allowed { + return ApiError::forbidden_origin().into_response(); + } + let mut res = StatusCode::NO_CONTENT.into_response(); + cors::apply_headers(res.headers_mut(), &decision); + let headers = res.headers_mut(); + headers.insert( + axum::http::header::ACCESS_CONTROL_ALLOW_METHODS, + axum::http::HeaderValue::from_static("GET, POST, PUT, PATCH, DELETE"), + ); + // Echo the requested headers back for an allowed origin instead of + // maintaining a hardcoded list: the origin allowlist is the actual + // security boundary here, and a fixed list silently breaks any client + // that adds a header (found live: the MCP client's + // `mcp-protocol-version` — which mesh's own CORS allowlists — was + // rejected by this preflight, killing every /api/:org/mcp/* call in + // the webview). Fall back to the static set when no header was asked. + let allow_headers = req + .headers() + .get(axum::http::header::ACCESS_CONTROL_REQUEST_HEADERS) + .cloned() + .unwrap_or_else(|| { + axum::http::HeaderValue::from_static("Content-Type, Accept, Authorization") + }); + headers.insert( + axum::http::header::ACCESS_CONTROL_ALLOW_HEADERS, + allow_headers, + ); + res +} + +async fn require_expected_host( + State(auth): State, + req: Request, + next: Next, +) -> Response { + if let Err(error) = auth.require_expected_host(req.headers()) { + return error.into_response(); + } + next.run(req).await +} + +fn authorize_private( + state: &GuardState, + req: &mut Request, +) -> Result, ApiError> { + state.auth.require_expected_host(req.headers())?; + if state.auth.is_embedded() { + state + .auth + .require_unsafe_origin(req.method(), req.headers())?; + state.auth.require_private(req.headers())?; + Ok(None) + } else { + let decision = cors::validate(req.headers(), state.mode); + if !decision.allowed { + return Err(ApiError::forbidden_origin()); + } + state.auth.require_private(req.headers())?; + Ok(Some(decision)) + } +} + +async fn bootstrap_embedded_session( + Extension(runtime): Extension, + req: Request, +) -> Response { + let (parts, body) = req.into_parts(); + if let Err(error) = runtime.auth.require_expected_host(&parts.headers) { + return error.into_response(); + } + if let Err(error) = runtime.auth.require_exact_origin(&parts.headers) { + return error.into_response(); + } + match axum::body::to_bytes(body, 1).await { + Ok(bytes) if bytes.is_empty() => {} + Ok(_) | Err(_) => { + return ApiError::bad_request("bootstrap body must be empty").into_response() + } + } + let set_cookie = match runtime.auth.consume_bootstrap(&parts.headers) { + Ok(cookie) => cookie, + Err(error) => return error.into_response(), + }; + let mut response = StatusCode::NO_CONTENT.into_response(); + match axum::http::HeaderValue::from_str(&set_cookie) { + Ok(value) => { + response.headers_mut().insert(header::SET_COOKIE, value); + response + } + Err(_) => ApiError::unauthorized().into_response(), + } +} + +async fn session_status() -> StatusCode { + StatusCode::NO_CONTENT +} + +async fn app_or_ui_fallback( + State(state): State, + Extension(runtime): Extension, + mut req: Request, +) -> Response { + if let Some(response) = try_serve_ui(&runtime, &req) { + return response; + } + + let guard_state = GuardState { + auth: runtime.auth, + mode: state.mode, + }; + let decision = match authorize_private(&guard_state, &mut req) { + Ok(decision) => decision, + Err(error) => return error.into_response(), + }; + // The control credential is only for webview -> local-api. Remove that + // named cookie (and no application cookies) at the Studio proxy boundary; + // sandbox handlers and the preview proxy never pass through here. + crate::client_auth::remove_local_session_cookie(req.headers_mut()); + let mut response = routes::upstream::proxy(State(state), req).await; + if let Some(decision) = decision { + cors::apply_headers(response.headers_mut(), &decision); + } + response +} + +fn try_serve_ui(runtime: &MainRuntime, req: &Request) -> Option { + let provider = runtime.ui_assets.as_ref()?; + if !matches!(*req.method(), Method::GET | Method::HEAD) { + return None; + } + let path = req.uri().path(); + if is_reserved_api_path(path) { + return None; + } + if !ui::is_safe_asset_path(path) { + return Some(ApiError::not_found("Not found").into_response()); + } + + if matches!(path, "/" | "/index.html") { + return Some(index_response(provider, runtime, req.method())); + } + if let Some(asset) = provider.asset(path) { + return Some(ui::asset_response(asset, req.method())); + } + if is_spa_navigation(req) { + return Some(index_response(provider, runtime, req.method())); + } + if last_segment_has_extension(path) { + return Some(ApiError::not_found("Not found").into_response()); + } + None +} + +fn index_response( + provider: &Arc, + runtime: &MainRuntime, + method: &Method, +) -> Response { + let Some(index) = provider.index() else { + return ApiError::not_found("UI index not found").into_response(); + }; + ui::index_response( + index, + method, + &provider.content_security_policy(), + &runtime.preview_origin, + ) +} + +fn is_spa_navigation(req: &Request) -> bool { + req.headers() + .get(header::ACCEPT) + .and_then(|value| value.to_str().ok()) + .is_some_and(|accept| { + accept + .split(',') + .any(|value| value.trim().starts_with("text/html")) + }) +} + +fn last_segment_has_extension(path: &str) -> bool { + path.rsplit('/') + .next() + .is_some_and(|segment| segment.contains('.')) +} + +fn is_reserved_api_path(path: &str) -> bool { + const PREFIXES: &[&str] = &[ + "/api", + "/mcp", + "/oauth-proxy", + "/.well-known", + "/org", + "/_auth", + "/_local", + "/_sandbox", + "/threads", + "/models", + "/health", + "/metrics", + ]; + PREFIXES + .iter() + .any(|prefix| path == *prefix || path.starts_with(&format!("{prefix}/"))) +} + +#[cfg(test)] +mod tests { + use axum::body::{to_bytes, Body}; + use axum::http::{Request as HttpRequest, Uri}; + use bytes::Bytes; + use tower::ServiceExt; + + use super::*; + use crate::ui::UiAsset; + + struct TestAssets; + + impl UiAssetProvider for TestAssets { + fn asset(&self, path: &str) -> Option { + match path { + "/assets/app.abc.js" => Some(UiAsset::new( + Bytes::from_static(b"console.log('ok')"), + "text/javascript", + "public, max-age=31536000, immutable", + )), + _ => None, + } + } + + fn index(&self) -> Option { + Some(UiAsset::new( + Bytes::from_static(b"
"), + "text/html; charset=utf-8", + "public, max-age=60", + )) + } + + fn content_security_policy(&self) -> String { + "default-src 'self'; connect-src 'self'; frame-src 'none'".into() + } + } + + fn embedded_router() -> (Router, tempfile::TempDir) { + let root = tempfile::tempdir().unwrap(); + let state = crate::routes::intercept::test_state(root.path()); + let auth = ClientAuth::embedded( + "127.0.0.1:43120".into(), + "http://127.0.0.1:43120".into(), + vec!["one-time-bootstrap".into()], + ) + .unwrap(); + ( + build( + state, + auth, + Some(Arc::new(TestAssets)), + "http://localhost:61234".into(), + ), + root, + ) + } + + fn request(method: Method, uri: &str) -> axum::extract::Request { + HttpRequest::builder() + .method(method) + .uri(uri) + .header(header::HOST, "127.0.0.1:43120") + .body(Body::empty()) + .unwrap() + } + + async fn bootstrap(app: &Router) -> (StatusCode, String) { + let mut req = request(Method::POST, "/_local/session/bootstrap"); + req.headers_mut().insert( + header::ORIGIN, + axum::http::HeaderValue::from_static("http://127.0.0.1:43120"), + ); + req.headers_mut().insert( + header::AUTHORIZATION, + axum::http::HeaderValue::from_static("Bearer one-time-bootstrap"), + ); + let response = app.clone().oneshot(req).await.unwrap(); + let cookie = response + .headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + (response.status(), cookie) + } + + #[tokio::test] + async fn public_index_and_exact_assets_need_no_cookie_and_get_http_security_headers() { + let (app, _root) = embedded_router(); + let index = app + .clone() + .oneshot(request(Method::GET, "/")) + .await + .unwrap(); + assert_eq!(index.status(), StatusCode::OK); + assert_eq!(index.headers()[header::CACHE_CONTROL], "no-store"); + let csp = index.headers()[header::CONTENT_SECURITY_POLICY] + .to_str() + .unwrap(); + assert!(csp.contains("connect-src 'self' http://localhost:61234 ws://localhost:61234")); + assert!(csp.contains("frame-src http://localhost:61234")); + + let asset = app + .oneshot(request(Method::GET, "/assets/app.abc.js")) + .await + .unwrap(); + assert_eq!(asset.status(), StatusCode::OK); + assert_eq!( + asset.headers()[header::CACHE_CONTROL], + "public, max-age=31536000, immutable" + ); + assert_eq!(asset.headers()[header::CONTENT_TYPE], "text/javascript"); + } + + #[tokio::test] + async fn preview_cookie_probe_is_explicitly_enabled_and_plain_localhost_bound() { + let enabled_root = tempfile::tempdir().unwrap(); + let enabled = build_preview( + crate::routes::intercept::test_state(enabled_root.path()), + 61234, + Some(Arc::from("http://localhost:43120")), + ); + let probe = || { + HttpRequest::builder() + .method(Method::GET) + .uri("/_local/selftest/preview-cookie") + .header(header::HOST, "localhost:61234") + .header(header::ORIGIN, "http://localhost:43120") + .body(Body::empty()) + .unwrap() + }; + let frame = enabled + .clone() + .oneshot( + HttpRequest::builder() + .method(Method::GET) + .uri("/_local/selftest/preview-frame") + .header(header::HOST, "localhost:61234") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(frame.status(), StatusCode::OK); + let frame_body = to_bytes(frame.into_body(), usize::MAX).await.unwrap(); + assert!(String::from_utf8_lossy(&frame_body).contains("decocms-preview-selftest-ready")); + + let mut first_request = probe(); + first_request.headers_mut().insert( + header::COOKIE, + axum::http::HeaderValue::from_static("decocms-preview-selftest=stale-persistent-value"), + ); + let first = enabled.clone().oneshot(first_request).await.unwrap(); + assert_eq!(first.status(), StatusCode::OK); + assert_eq!( + first.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], + "http://localhost:43120" + ); + assert_eq!( + first.headers()[header::ACCESS_CONTROL_ALLOW_CREDENTIALS], + "true" + ); + let cookie = first.headers()[header::SET_COOKIE] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + assert_ne!(cookie, "decocms-preview-selftest=stale-persistent-value"); + let first_body = to_bytes(first.into_body(), usize::MAX).await.unwrap(); + assert_eq!(first_body.as_ref(), br#"{"received":false}"#); + let mut second_request = probe(); + second_request.headers_mut().insert( + header::COOKIE, + axum::http::HeaderValue::from_str(&cookie).unwrap(), + ); + let second = enabled.oneshot(second_request).await.unwrap(); + let body = to_bytes(second.into_body(), usize::MAX).await.unwrap(); + assert_eq!(body.as_ref(), br#"{"received":true}"#); + + let disabled_root = tempfile::tempdir().unwrap(); + let disabled = build_preview( + crate::routes::intercept::test_state(disabled_root.path()), + 61234, + None, + ); + let response = disabled.oneshot(probe()).await.unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(response.headers().get(header::SET_COOKIE).is_none()); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert!(String::from_utf8_lossy(&body).contains("No dev server running")); + } + + #[tokio::test] + async fn html_spa_fallback_never_swallows_reserved_api_paths_or_missing_assets() { + let (app, _root) = embedded_router(); + let mut navigation = request(Method::GET, "/settings/profile"); + navigation.headers_mut().insert( + header::ACCEPT, + axum::http::HeaderValue::from_static("text/html"), + ); + assert_eq!( + app.clone().oneshot(navigation).await.unwrap().status(), + StatusCode::OK + ); + + let mut api = request(Method::GET, "/api/acme/things"); + api.headers_mut().insert( + header::ACCEPT, + axum::http::HeaderValue::from_static("text/html"), + ); + assert_eq!( + app.clone().oneshot(api).await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + + assert_eq!( + app.oneshot(request(Method::GET, "/assets/missing.js")) + .await + .unwrap() + .status(), + StatusCode::NOT_FOUND + ); + } + + #[tokio::test] + async fn bootstrap_is_origin_bound_one_time_and_session_probe_uses_cookie() { + let (app, _root) = embedded_router(); + let nonempty = HttpRequest::builder() + .method(Method::POST) + .uri("/_local/session/bootstrap") + .header(header::HOST, "127.0.0.1:43120") + .header(header::ORIGIN, "http://127.0.0.1:43120") + .header(header::AUTHORIZATION, "Bearer one-time-bootstrap") + .body(Body::from("not-empty")) + .unwrap(); + assert_eq!( + app.clone().oneshot(nonempty).await.unwrap().status(), + StatusCode::BAD_REQUEST + ); + + let (status, cookie) = bootstrap(&app).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + let replay = { + let mut req = request(Method::POST, "/_local/session/bootstrap"); + req.headers_mut().insert( + header::ORIGIN, + axum::http::HeaderValue::from_static("http://127.0.0.1:43120"), + ); + req.headers_mut().insert( + header::AUTHORIZATION, + axum::http::HeaderValue::from_static("Bearer one-time-bootstrap"), + ); + app.clone().oneshot(req).await.unwrap() + }; + assert_eq!(replay.status(), StatusCode::UNAUTHORIZED); + + assert_eq!( + app.clone() + .oneshot(request(Method::GET, "/_local/session")) + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + let mut probe = request(Method::GET, "/_local/session"); + probe.headers_mut().insert( + header::COOKIE, + axum::http::HeaderValue::from_str(&cookie).unwrap(), + ); + assert_eq!( + app.oneshot(probe).await.unwrap().status(), + StatusCode::NO_CONTENT + ); + } + + #[tokio::test] + async fn bootstrap_and_mutations_reject_missing_or_wrong_origin_and_host() { + let (app, _root) = embedded_router(); + let mut no_origin = request(Method::POST, "/_local/session/bootstrap"); + no_origin.headers_mut().insert( + header::AUTHORIZATION, + axum::http::HeaderValue::from_static("Bearer one-time-bootstrap"), + ); + assert_eq!( + app.clone().oneshot(no_origin).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + + let mut wrong_host = request(Method::GET, "/"); + wrong_host.headers_mut().insert( + header::HOST, + axum::http::HeaderValue::from_static("127.0.0.1:43121"), + ); + assert_eq!( + app.clone().oneshot(wrong_host).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + + let (_, cookie) = bootstrap(&app).await; + let mut mutation = request(Method::POST, "/_sandbox/setup/start"); + mutation.headers_mut().insert( + header::COOKIE, + axum::http::HeaderValue::from_str(&cookie).unwrap(), + ); + assert_eq!( + app.oneshot(mutation).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + } + + #[tokio::test] + async fn head_index_has_no_body_and_api_like_extensions_stay_private() { + let (app, _root) = embedded_router(); + let head = app + .clone() + .oneshot(request(Method::HEAD, "/index.html")) + .await + .unwrap(); + assert_eq!(head.status(), StatusCode::OK); + assert!(to_bytes(head.into_body(), usize::MAX) + .await + .unwrap() + .is_empty()); + assert!(is_reserved_api_path("/api/x")); + assert!(!is_reserved_api_path("/apiary")); + assert!(is_reserved_api_path("/metrics")); + assert!(!is_reserved_api_path("/metrics-dashboard")); + assert_eq!( + "/settings/profile".parse::().unwrap().path(), + "/settings/profile" + ); + } + + #[test] + fn studio_proxy_boundary_removes_only_control_cookie() { + let auth = ClientAuth::embedded( + "127.0.0.1:43120".into(), + "http://127.0.0.1:43120".into(), + vec!["bootstrap".into()], + ) + .unwrap(); + let set_cookie = auth + .consume_bootstrap(&{ + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + axum::http::HeaderValue::from_static("Bearer bootstrap"), + ); + headers + }) + .unwrap(); + let local_cookie = set_cookie.split(';').next().unwrap(); + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + header::COOKIE, + axum::http::HeaderValue::from_str(&format!("sandbox=keep; {local_cookie}; theme=dark")) + .unwrap(), + ); + crate::client_auth::remove_local_session_cookie(&mut headers); + assert_eq!(headers[header::COOKIE], "sandbox=keep; theme=dark"); + } +} diff --git a/apps/native/crates/local-api/src/routes/bash.rs b/apps/native/crates/local-api/src/routes/bash.rs new file mode 100644 index 0000000000..875657db73 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/bash.rs @@ -0,0 +1,882 @@ +//! `POST /_sandbox/bash` — byte-parity target: `daemon/routes/bash.ts`, +//! oracle `daemon.e2e.test.ts` (`bash` describe block). +//! +//! Modes: +//! - `"await"` (default): spawns `bash -c `, waits for it to +//! finish (or the clamp timeout to expire), returns +//! `{stdout,stderr,exitCode,timedOut,truncated}`. Owned by an INTERNAL +//! `state.tasks` entry so request abort and app shutdown can reap it, but +//! deliberately hidden from task-list/stream wire surfaces to preserve the +//! daemon contract for await-mode commands. +//! - `"background"`: spawns the same way but returns `{taskId,status}` +//! immediately; a detached tokio task drains stdout/stderr into +//! `state.tasks` (both the file-backed retention — see `tasks/registry.rs`'s +//! `LogStore`-backed `append_output` — AND the `/stream` broadcast channel) +//! and finalizes the task's terminal status on exit, emitting a `"tasks"` +//! broadcaster event on every registration and every exit (byte-parity +//! with `TaskManager`'s `onChange` hook in `entry.ts`). +//! +//! Timeout clamp: default 30s, ceiling 120s (`await`) / 15min +//! (`background`) — byte-parity with `clampTimeout` in +//! `daemon/routes/bash.ts`. +//! +//! Process-group kill: an independent watchdog anchors a new process group per +//! spawn (`ProcessGroupChild::spawn`) and the group is killed via `kill` +//! (`kill -SIG -`) rather than a raw `kill(2)` syscall, since this +//! crate has no `libc`/`nix` dependency (`Cargo.toml` is a shared file — +//! see the native module-ownership contract). This kills the whole +//! subprocess tree (e.g. `sleep` spawned by `bash -c "sleep 30"`), matching +//! `daemon/process/task-manager.ts`'s `process.kill(-pgid, signal)`. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use futures_util::FutureExt; +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::io::AsyncReadExt; +use tokio::process::{ChildStderr, ChildStdout, Command}; +use tokio::sync::oneshot; + +use crate::error::{ApiError, ApiResult}; +use crate::process_group::ProcessGroupChild; +use crate::state::AppState; +use crate::tasks::{ + now_ms, KillHandle, KillSignal, OutputStream, ProcessController, RingBuffer, TaskEntry, + TaskStatus, TaskSummary, +}; + +const DEFAULT_TIMEOUT_MS: u64 = 30_000; +const AWAIT_CEILING_MS: u64 = 120_000; +const BACKGROUND_CEILING_MS: u64 = 15 * 60 * 1000; +/// Byte-parity with `RING_BUFFER_BYTES` in `process/task-manager.ts` — caps +/// the `mode:"await"` response the same way a background task's retained +/// output is capped for a replay read (`tasks/registry.rs` reads the same +/// 256KB tail from its `LogStore`-backed files — see +/// `crate::log_store::DEFAULT_TAIL_BYTES`). Await-mode itself is never persisted +/// or replayed (the HTTP response IS the output), so it keeps its own +/// request-scoped, in-RAM `RingBuffer` rather than going through +/// `LogStore` — nothing outside this one request ever needs to read it +/// back. +const OUTPUT_CAP_BYTES: usize = 256 * 1024; +/// How long to wait for a process to actually exit after a `SIGKILL` before +/// giving up on the reap and reporting the timeout anyway. Not part of any +/// pinned contract — just a bound so a truly wedged process (rare: a kernel +/// waiting on uninterruptible I/O) can't hang the request/task forever. +const REAP_GRACE: Duration = Duration::from_secs(2); +/// Byte-parity with `killWithEscalation`'s 3s TERM->KILL escalation window +/// in `process/task-manager.ts`. +const ESCALATE_AFTER: Duration = Duration::from_secs(3); + +#[derive(Deserialize, Default)] +struct BashBody { + command: Option, + timeout: Option, + cwd: Option, + env: Option>, + mode: Option, +} + +pub async fn bash(State(state): State, body: Bytes) -> ApiResult> { + let body: BashBody = serde_json::from_slice(&body) + .map_err(|e| ApiError::bad_request(format!("invalid JSON body: {e}")))?; + let command = match body.command { + Some(c) if !c.is_empty() => c, + _ => return Err(ApiError::bad_request("command is required")), + }; + let background = body.mode.as_deref() == Some("background"); + let timeout_ms = clamp_timeout(body.timeout, background); + let cwd = body + .cwd + .map(PathBuf::from) + .unwrap_or_else(|| state.repo_dir.clone()); + + if background { + return spawn_background(&state, command, cwd, body.env, timeout_ms).await; + } + + let result = run_await(&state, &command, &cwd, body.env.as_ref(), timeout_ms).await?; + Ok(Json(json!({ + "stdout": result.stdout, + "stderr": result.stderr, + "exitCode": result.exit_code, + "timedOut": result.timed_out, + "truncated": result.truncated, + }))) +} + +fn clamp_timeout(raw: Option, background: bool) -> u64 { + let requested = match raw { + Some(v) if v > 0 => v as u64, + _ => DEFAULT_TIMEOUT_MS, + }; + let ceiling = if background { + BACKGROUND_CEILING_MS + } else { + AWAIT_CEILING_MS + }; + requested.min(ceiling) +} + +fn build_command(command: &str, cwd: &Path, env: Option<&HashMap>) -> Command { + let mut cmd = Command::new("bash"); + cmd.arg("-c").arg(command); + cmd.current_dir(cwd); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + // The detached owner below is the primary lifecycle fence. This is its + // last-resort backstop: aborting that owner (runtime teardown/panic) kills + // the immediate shell instead of letting it outlive local-api. The normal + // shutdown/request-abort path signals the whole process group first. + cmd.kill_on_drop(true); + // Byte-parity with `bash.ts`: an explicit `env` REPLACES the inherited + // environment rather than merging with it (Node's `spawn` semantics + // when `options.env` is set at all) — `deps.env` is always undefined in + // the daemon's own wiring, so `body.env` alone determines this. + if let Some(env) = env { + cmd.env_clear(); + cmd.envs(env); + } + cmd +} + +#[cfg(unix)] +fn exit_status_to_code(status: std::process::ExitStatus) -> i32 { + use std::os::unix::process::ExitStatusExt; + match status.signal() { + // Shell convention (128 + signal number) — byte-parity with the + // `signal ? 128 + SIGNAL_NUMBERS[signal] : (code ?? 1)` mapping in + // `task-manager.ts`'s `child.on("close", ...)`. + Some(sig) => 128 + sig, + None => status.code().unwrap_or(1), + } +} + +#[cfg(not(unix))] +fn exit_status_to_code(status: std::process::ExitStatus) -> i32 { + status.code().unwrap_or(1) +} + +fn classify_status(timed_out: bool, exit_code: i32) -> TaskStatus { + if timed_out { + TaskStatus::Timeout + } else if exit_code == 0 { + TaskStatus::Exited + } else if exit_code == -1 { + TaskStatus::Failed + } else if exit_code > 128 { + TaskStatus::Killed + } else { + TaskStatus::Exited + } +} + +/// Carries dangling bytes of a multi-byte UTF-8 sequence across chunk +/// boundaries so a character split across two pipe reads isn't mangled into +/// replacement characters — byte-parity in spirit with `task-manager.ts`'s +/// use of `node:string_decoder`'s `StringDecoder`. +struct Utf8ChunkDecoder { + pending: Vec, +} + +impl Utf8ChunkDecoder { + fn new() -> Self { + Self { + pending: Vec::new(), + } + } + + /// Feed in newly-read bytes, returning whatever complete UTF-8 text is + /// now available (dangling trailing bytes are held back for the next + /// call). + fn push(&mut self, bytes: &[u8]) -> String { + self.pending.extend_from_slice(bytes); + match std::str::from_utf8(&self.pending) { + Ok(s) => { + let out = s.to_string(); + self.pending.clear(); + out + } + Err(e) => { + let valid_len = e.valid_up_to(); + let out = String::from_utf8_lossy(&self.pending[..valid_len]).into_owned(); + self.pending.drain(..valid_len); + // A valid UTF-8 sequence is at most 4 bytes; anything larger + // still pending can't be "a partial character waiting for + // more bytes" — it's genuinely invalid. Flush it lossily + // rather than growing unboundedly. + if self.pending.len() > 4 { + let lossy = String::from_utf8_lossy(&self.pending).into_owned(); + self.pending.clear(); + return out + &lossy; + } + out + } + } + } + + /// Flush whatever's left at stream end (a genuinely truncated trailing + /// sequence, decoded lossily rather than silently dropped). + fn finish(&mut self) -> String { + if self.pending.is_empty() { + return String::new(); + } + let out = String::from_utf8_lossy(&self.pending).into_owned(); + self.pending.clear(); + out + } +} + +struct RunResult { + stdout: String, + stderr: String, + exit_code: i32, + timed_out: bool, + truncated: bool, +} + +/// Synchronous cancellation bridge for an await-mode HTTP request. Axum may +/// drop a handler future when the client disconnects; the child lives in a +/// detached owner, so dropping this guard requests process-group TERM instead +/// of either leaking the child or tying ownership to the socket future. +struct CancelOnDrop { + kill: Option, +} + +impl CancelOnDrop { + fn new(kill: KillHandle) -> Self { + Self { kill: Some(kill) } + } + + fn disarm(&mut self) { + self.kill = None; + } +} + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + if let Some(kill) = self.kill.take() { + let _ = kill(KillSignal::Term); + } + } +} + +async fn run_await( + state: &AppState, + command: &str, + cwd: &Path, + env: Option<&HashMap>, + timeout_ms: u64, +) -> ApiResult { + let Some(admission) = state.shutdown.admit_work().await else { + return Err(ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "application is shutting down", + )); + }; + + let mut cmd = build_command(command, cwd, env); + let mut child = + match ProcessGroupChild::spawn(&mut cmd, state.tasks.child_lifetime_lock_path()).await { + Ok(child) => child, + Err(e) => { + return Ok(RunResult { + stdout: String::new(), + stderr: format!("spawn error: {e}\n"), + exit_code: -1, + timed_out: false, + truncated: false, + }); + } + }; + // Internal ownership must not consume the public `task` sequence: an + // await-mode call was never observable in `/tasks`, including indirectly + // through the id assigned to the next background task. + let id = format!("internal-bash-{}", uuid::Uuid::new_v4()); + let controller = ProcessController::new(); + let kill_handle = controller.kill_handle(); + state.tasks.insert(TaskEntry::new_internal( + TaskSummary { + id: id.clone(), + command: command.to_string(), + status: TaskStatus::Running, + exit_code: None, + started_at: now_ms(), + finished_at: None, + timed_out: false, + truncated: false, + log_name: None, + intentional: None, + }, + Some(kill_handle.clone()), + )); + + // Registration is intentionally before these infallible-for-piped-command + // takes. From the instant `spawn()` succeeds, shutdown can find and signal + // the owner; there is no spawn -> registry cancellation window. + let (Some(stdout_pipe), Some(stderr_pipe)) = (child.take_stdout(), child.take_stderr()) else { + spawn_missing_stdio_cleanup(state.clone(), id, child, false); + drop(admission); + return Ok(RunResult { + stdout: String::new(), + stderr: "spawn error: missing stdio pipe\n".to_string(), + exit_code: -1, + timed_out: false, + truncated: false, + }); + }; + + let (result_tx, result_rx) = oneshot::channel(); + spawn_process_owner( + state.clone(), + id, + child, + stdout_pipe, + stderr_pipe, + timeout_ms, + controller, + Some(result_tx), + false, + ); + drop(admission); + + let mut cancel_on_drop = CancelOnDrop::new(kill_handle); + let result = result_rx + .await + .map_err(|_| ApiError::internal("bash process owner stopped unexpectedly"))?; + cancel_on_drop.disarm(); + Ok(result) +} + +async fn spawn_background( + state: &AppState, + command: String, + cwd: PathBuf, + env: Option>, + timeout_ms: u64, +) -> ApiResult> { + let Some(admission) = state.shutdown.admit_work().await else { + return Err(ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "application is shutting down", + )); + }; + let mut cmd = build_command(&command, &cwd, env.as_ref()); + let id = state.tasks.next_id(); + let controller = ProcessController::new(); + let kill_handle = controller.kill_handle(); + + let mut child = + match ProcessGroupChild::spawn(&mut cmd, state.tasks.child_lifetime_lock_path()).await { + Ok(child) => child, + Err(_) => { + let started_at = now_ms(); + let summary = TaskSummary { + id: id.clone(), + command, + status: TaskStatus::Failed, + exit_code: Some(-1), + started_at, + finished_at: Some(started_at), + timed_out: false, + truncated: false, + log_name: None, + intentional: None, + }; + state.tasks.insert(TaskEntry::new(summary, None)); + emit_tasks_event(state); + return Ok(Json(json!({"taskId": id, "status": "failed"}))); + } + }; + + let summary = TaskSummary { + id: id.clone(), + command, + status: TaskStatus::Running, + exit_code: None, + started_at: now_ms(), + finished_at: None, + timed_out: false, + truncated: false, + log_name: None, + intentional: None, + }; + state + .tasks + .insert(TaskEntry::new(summary, Some(kill_handle))); + emit_tasks_event(state); + + let (Some(stdout_pipe), Some(stderr_pipe)) = (child.take_stdout(), child.take_stderr()) else { + spawn_missing_stdio_cleanup(state.clone(), id.clone(), child, true); + drop(admission); + return Ok(Json(json!({"taskId": id, "status": "failed"}))); + }; + + spawn_process_owner( + state.clone(), + id.clone(), + child, + stdout_pipe, + stderr_pipe, + timeout_ms, + controller, + None, + true, + ); + drop(admission); + + Ok(Json(json!({"taskId": id, "status": "running"}))) +} + +fn spawn_missing_stdio_cleanup( + state: AppState, + id: String, + mut child: ProcessGroupChild, + visible: bool, +) { + tokio::spawn(async move { + child + .kill_and_reap(REAP_GRACE, "bash missing-stdio cleanup") + .await; + state.tasks.finalize(&id, TaskStatus::Failed, -1, false); + if visible { + emit_tasks_event(&state); + } else { + let _ = state.tasks.remove(&id).await; + } + }); +} + +#[allow(clippy::too_many_arguments)] +fn spawn_process_owner( + state: AppState, + id: String, + mut child: ProcessGroupChild, + stdout_pipe: ChildStdout, + stderr_pipe: ChildStderr, + timeout_ms: u64, + controller: ProcessController, + result_tx: Option>, + visible: bool, +) { + tokio::spawn(async move { + let capture_output = result_tx.is_some(); + let driven = std::panic::AssertUnwindSafe(drive_process( + state.clone(), + id.clone(), + &mut child, + stdout_pipe, + stderr_pipe, + timeout_ms, + controller, + capture_output, + )) + .catch_unwind() + .await; + + let result = match driven { + Ok(result) => result, + Err(_) => { + child + .kill_and_reap(REAP_GRACE, "bash process-owner panic cleanup") + .await; + RunResult { + stdout: String::new(), + stderr: "process owner panicked\n".to_string(), + exit_code: -1, + timed_out: false, + truncated: false, + } + } + }; + let status = classify_status(result.timed_out, result.exit_code); + state + .tasks + .finalize(&id, status, result.exit_code, result.timed_out); + if visible { + emit_tasks_event(&state); + } else { + let _ = state.tasks.remove(&id).await; + } + if let Some(result_tx) = result_tx { + let _ = result_tx.send(result); + } + }); +} + +#[allow(clippy::too_many_arguments)] +async fn drive_process( + state: AppState, + id: String, + child: &mut ProcessGroupChild, + mut stdout_pipe: ChildStdout, + mut stderr_pipe: ChildStderr, + timeout_ms: u64, + controller: ProcessController, + capture_output: bool, +) -> RunResult { + let mut stdout_decoder = Utf8ChunkDecoder::new(); + let mut stderr_decoder = Utf8ChunkDecoder::new(); + let mut stdout_open = true; + let mut stderr_open = true; + let mut exited = false; + let mut exit_status: Option = None; + let mut timed_out = false; + let mut timer_active = true; + let mut observed_signal = None; + // Background output is already retained by the file-backed TaskRegistry; + // only await mode needs a request-result copy in RAM. + let mut stdout_buf = capture_output.then(|| RingBuffer::new(OUTPUT_CAP_BYTES)); + let mut stderr_buf = capture_output.then(|| RingBuffer::new(OUTPUT_CAP_BYTES)); + + let mut so_chunk = [0u8; 8192]; + let mut se_chunk = [0u8; 8192]; + let sleep = tokio::time::sleep(Duration::from_millis(timeout_ms)); + tokio::pin!(sleep); + let escalation = tokio::time::sleep(ESCALATE_AFTER); + tokio::pin!(escalation); + let mut escalation_active = false; + + loop { + if exited && !stdout_open && !stderr_open { + break; + } + tokio::select! { + res = stdout_pipe.read(&mut so_chunk), if stdout_open => { + match res { + Ok(0) | Err(_) => stdout_open = false, + Ok(n) => { + let text = stdout_decoder.push(&so_chunk[..n]); + if !text.is_empty() { + if let Some(buf) = &mut stdout_buf { + buf.append(&text); + } + state.tasks.append_output(&id, OutputStream::Stdout, &text).await; + } + } + } + } + res = stderr_pipe.read(&mut se_chunk), if stderr_open => { + match res { + Ok(0) | Err(_) => stderr_open = false, + Ok(n) => { + let text = stderr_decoder.push(&se_chunk[..n]); + if !text.is_empty() { + if let Some(buf) = &mut stderr_buf { + buf.append(&text); + } + state.tasks.append_output(&id, OutputStream::Stderr, &text).await; + } + } + } + } + // Do not reap the group leader while descendants may still own an + // inherited pipe. Keeping the leader unreaped pins its PID/PGID, + // so every timeout/controller signal remains ownership-safe. + status = child.wait(), if !exited && !stdout_open && !stderr_open => { + exited = true; + timer_active = false; + escalation_active = false; + exit_status = status.ok(); + } + _ = &mut sleep, if timer_active && !exited => { + timer_active = false; + escalation_active = false; + timed_out = true; + child.signal(KillSignal::Kill).await; + } + sig = controller.wait_for_change(observed_signal), if !exited => { + observed_signal = Some(sig); + if child.signal(sig).await { + if matches!(sig, KillSignal::Term) { + escalation.as_mut().reset(tokio::time::Instant::now() + ESCALATE_AFTER); + escalation_active = true; + } else { + escalation_active = false; + } + } + } + _ = &mut escalation, if escalation_active && !exited => { + escalation_active = false; + child.signal(KillSignal::Kill).await; + } + } + } + + // Flush any dangling partial-UTF8 tail left in the decoders. + let so_tail = stdout_decoder.finish(); + if !so_tail.is_empty() { + if let Some(buf) = &mut stdout_buf { + buf.append(&so_tail); + } + state + .tasks + .append_output(&id, OutputStream::Stdout, &so_tail) + .await; + } + let se_tail = stderr_decoder.finish(); + if !se_tail.is_empty() { + if let Some(buf) = &mut stderr_buf { + buf.append(&se_tail); + } + state + .tasks + .append_output(&id, OutputStream::Stderr, &se_tail) + .await; + } + + let raw_exit_code = exit_status.map(exit_status_to_code).unwrap_or(-1); + let exit_code = if timed_out { -1 } else { raw_exit_code }; + let (stdout, so_truncated) = stdout_buf + .as_ref() + .map(RingBuffer::read) + .unwrap_or_default(); + let (stderr, se_truncated) = stderr_buf + .as_ref() + .map(RingBuffer::read) + .unwrap_or_default(); + RunResult { + stdout, + stderr, + exit_code, + timed_out, + truncated: so_truncated || se_truncated, + } +} + +/// `{"active": [{id, command, logName?}]}` — byte-parity with +/// `getActiveTasks()` in `entry.ts`, broadcast under the `"tasks"` name on +/// every registration and every exit (see this module's doc comment). +fn emit_tasks_event(state: &AppState) { + let active: Vec = state + .tasks + .list(Some(&[TaskStatus::Running])) + .into_iter() + .map(|t| { + let mut obj = json!({"id": t.id, "command": t.command}); + if let Some(name) = t.log_name { + obj["logName"] = json!(name); + } + obj + }) + .collect(); + state.broadcaster.emit("tasks", json!({"active": active})); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + fn test_state() -> AppState { + let app_root = tempfile::tempdir().unwrap().keep(); + let repo_dir = app_root.clone(); + let config = Arc::new(crate::config::ConfigStore::new()); + let logs = Arc::new(crate::log_store::LogStore::new(app_root.join("logs"))); + let tasks = Arc::new(crate::tasks::TaskRegistry::new(logs)); + let broadcaster = Arc::new(crate::events::Broadcaster::new()); + let setup = crate::setup::SetupOrchestrator::new( + repo_dir.clone(), + repo_dir.clone(), + config.clone(), + tasks.clone(), + broadcaster.clone(), + ); + AppState { + token: Arc::from("test-token"), + boot_id: Arc::from("test-boot"), + sandbox_manager: crate::sandbox::SandboxManager::new(app_root.clone()), + app_root, + repo_dir, + mode: crate::state::ApiMode::Strict, + config, + tasks, + broadcaster, + shutdown: Arc::new(crate::shutdown::ShutdownCoordinator::new()), + setup, + } + } + + #[test] + fn clamp_timeout_defaults_and_ceilings() { + assert_eq!(clamp_timeout(None, false), DEFAULT_TIMEOUT_MS); + assert_eq!(clamp_timeout(Some(0), false), DEFAULT_TIMEOUT_MS); + assert_eq!(clamp_timeout(Some(-5), false), DEFAULT_TIMEOUT_MS); + assert_eq!(clamp_timeout(Some(999_999), false), AWAIT_CEILING_MS); + assert_eq!( + clamp_timeout(Some(999_999_999), true), + BACKGROUND_CEILING_MS + ); + assert_eq!(clamp_timeout(Some(1_000), true), 1_000); + } + + #[test] + fn classify_status_matches_task_manager_finalize() { + assert_eq!(classify_status(true, 0), TaskStatus::Timeout); + assert_eq!(classify_status(false, 0), TaskStatus::Exited); + assert_eq!(classify_status(false, -1), TaskStatus::Failed); + assert_eq!(classify_status(false, 137), TaskStatus::Killed); + assert_eq!(classify_status(false, 1), TaskStatus::Exited); + } + + #[test] + fn utf8_chunk_decoder_carries_split_multibyte_char() { + let mut decoder = Utf8ChunkDecoder::new(); + // "é" = 0xC3 0xA9 in UTF-8 — split across two chunks. + let mut out = decoder.push(&[b'a', 0xC3]); + out.push_str(&decoder.push(&[0xA9, b'b'])); + assert_eq!(out, "aéb"); + } + + #[tokio::test] + async fn run_await_captures_stdout_and_exit_code() { + let state = test_state(); + let result = run_await(&state, "echo hi", Path::new("/tmp"), None, 5_000) + .await + .unwrap(); + assert_eq!(result.stdout.trim(), "hi"); + assert_eq!(result.exit_code, 0); + assert!(!result.timed_out); + } + + #[tokio::test] + async fn run_await_times_out_and_reports_exit_code_minus_one() { + let state = test_state(); + let result = run_await(&state, "sleep 30", Path::new("/tmp"), None, 200) + .await + .unwrap(); + assert_eq!(result.exit_code, -1); + assert!(result.timed_out); + } + + #[cfg(unix)] + #[tokio::test] + async fn leader_exit_keeps_background_descendant_running_and_enumerable_until_shutdown_reaps_it( + ) { + let state = test_state(); + let pid_file = state.app_root.join("detached-same-group.pid"); + let response = spawn_background( + &state, + format!( + "sleep 30 >/dev/null 2>&1 & echo $! > {}", + pid_file.display() + ), + state.repo_dir.clone(), + None, + BACKGROUND_CEILING_MS, + ) + .await + .expect("spawn background fixture"); + let task_id = response.0["taskId"] + .as_str() + .expect("background response task id") + .to_string(); + let descendant = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Ok(raw) = tokio::fs::read_to_string(&pid_file).await { + if let Ok(pid) = raw.trim().parse::() { + break pid; + } + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("shell wrote background descendant pid"); + + // The shell leader and both output pipes have already exited/closed, + // but the same-PG descendant is still the task. Never publish End yet. + tokio::time::sleep(Duration::from_millis(150)).await; + let summary = state.tasks.get(&task_id).expect("task remains owned"); + assert_eq!(summary.status, TaskStatus::Running); + assert!(state.tasks.list(None).iter().any(|task| task.id == task_id)); + + let shutdown = state + .tasks + .kill_all_and_wait(Duration::from_millis(500), Duration::from_secs(2)) + .await; + assert_eq!(shutdown.initially_running, 1); + assert!(shutdown.remaining.is_empty()); + assert!( + state + .tasks + .get(&task_id) + .expect("terminal task retained") + .status + .is_terminal(), + "terminal transition must follow the whole-group reap" + ); + assert!(!process_alive(descendant)); + } + + #[cfg(unix)] + #[tokio::test] + async fn aborted_await_request_is_hidden_and_reaps_its_process() { + let state = test_state(); + let pid_file = state.app_root.join("await-owner.pid"); + let command = format!("echo $$ > {}; sleep 30", pid_file.display()); + let owner_state = state.clone(); + let request = tokio::spawn(async move { + run_await(&owner_state, &command, Path::new("/tmp"), None, 120_000).await + }); + + let pid = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Ok(raw) = tokio::fs::read_to_string(&pid_file).await { + if let Ok(pid) = raw.trim().parse::() { + break pid; + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("bash child wrote its pid"); + + assert!( + state.tasks.list(None).is_empty(), + "await-mode ownership entries are internal and must not change /tasks" + ); + assert!( + state.tasks.next_id() == "task1", + "internal ownership must not consume the public task id sequence" + ); + request.abort(); + let _ = request.await; + + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let alive = std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + if !alive { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("aborted await request reaped its child"); + } + + #[cfg(unix)] + fn process_alive(pid: u32) -> bool { + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) + } +} diff --git a/apps/native/crates/local-api/src/routes/config.rs b/apps/native/crates/local-api/src/routes/config.rs new file mode 100644 index 0000000000..8c5cede4eb --- /dev/null +++ b/apps/native/crates/local-api/src/routes/config.rs @@ -0,0 +1,135 @@ +//! `GET/POST/PUT /_sandbox/config` — byte-parity target: +//! `daemon/routes/config.ts` + `daemon/config-store/`, oracle +//! `daemon.e2e.test.ts` (`config` describe block, minus `auth.rotateToken`'s +//! length/type validation — unreachable here, see below). +//! +//! `auth.rotateToken`: this endpoint has no token-rotation setter wired (see +//! the native local-API contract — one token per +//! process lifetime, no rotation endpoint). Byte-parity with the TS +//! `validateAuthPatch`'s branch order: ANY `auth.rotateToken` (regardless of +//! its own type/length) hits daemon's "!setter" branch first and gets +//! rejected `400 {"error":"auth.rotateToken not supported on this +//! endpoint"}` before the merge ever runs — the length/type checks the TS +//! source has below that branch are dead code on an endpoint with no +//! setter, so they're intentionally not ported. +//! +//! `POST`/`PUT` share a handler (`update`), matching the TS source treating +//! both as "deep-merge into current". + +use axum::body::Bytes; +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::{json, Value}; + +use crate::error::ApiError; +use crate::state::AppState; + +pub async fn read(State(state): State) -> Json { + let snap = state.config.snapshot(); + Json(json!({ + "bootId": state.boot_id.as_ref(), + "config": snap.config, + "envKeys": snap.env_keys, + "orchestrator": state.setup.orchestrator_json(), + "ready": snap.ready, + "repoDir": state.repo_dir.to_string_lossy(), + })) +} + +pub async fn update(State(state): State, body: Bytes) -> Response { + let raw: Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(e) => return ApiError::bad_request(format!("bad body: {e}")).into_response(), + }; + let Some(obj) = raw.as_object() else { + return ApiError::bad_request("payload must be an object").into_response(); + }; + + if let Some(auth) = obj.get("auth") { + if let Some(err) = reject_auth_patch(auth) { + return err.into_response(); + } + } + + let mut patch = raw.clone(); + if let Value::Object(m) = &mut patch { + m.remove("auth"); + } + + match state.config.patch(patch) { + Ok(outcome) => { + if outcome.transition != "no-op" { + // Hand the transition to the setup pipeline (KEPT — see + // `crate::setup`'s module doc). This is the REAL `"lifecycle"` + // source now: `SetupOrchestrator` emits its own + // `{state:{phase:...}}`-shaped events as clone/install/start + // progress, matching `daemon/events/types.ts::LifecycleState` + // byte-for-byte (replacing this handler's earlier synthetic + // `{phase:"configured",transition}` placeholder, which had no + // oracle test pinning it and didn't match the real wire + // shape). Fire-and-forget: the pipeline runs on + // `SetupOrchestrator`'s own background worker, this request + // returns immediately (byte-parity with the daemon's + // `store.subscribe` -> `orchestrator.handle` handoff, which + // likewise doesn't block the config PUT/POST response). + state.setup.handle_transition(outcome.transition); + } + Json(json!({ + "bootId": state.boot_id.as_ref(), + "transition": outcome.transition, + "config": outcome.config, + })) + .into_response() + } + Err(e) => e.into_response(), + } +} + +/// `None` when there's nothing to reject (no `rotateToken` key, or an +/// object/array `auth` value without one — byte-parity with +/// `validateAuthPatch` returning `null`). +fn reject_auth_patch(auth: &Value) -> Option { + // TS: `typeof auth !== "object" || auth === null`. `typeof x === "object"` + // is true for both JS objects AND arrays (and, confusingly, `null` -- + // which the second clause excludes), so an array `auth` value is + // "object-like" on the TS side and falls through to the rotateToken + // check below (which then finds nothing and no-ops). + let object_like = matches!(auth, Value::Object(_) | Value::Array(_)); + if !object_like { + return Some(ApiError::bad_request("auth must be an object")); + } + let rotate_token = auth.get("rotateToken").filter(|v| !v.is_null()); + if rotate_token.is_some() { + return Some(ApiError::bad_request( + "auth.rotateToken not supported on this endpoint", + )); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reject_auth_patch_allows_object_without_rotate_token() { + assert!(reject_auth_patch(&json!({})).is_none()); + } + + #[test] + fn reject_auth_patch_rejects_non_object() { + let err = reject_auth_patch(&json!("nope")).expect("rejected"); + assert_eq!(err.status, axum::http::StatusCode::BAD_REQUEST); + } + + #[test] + fn reject_auth_patch_rejects_rotate_token_presence() { + let err = reject_auth_patch(&json!({"rotateToken": "x"})).expect("rejected"); + assert_eq!(err.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!( + err.body["error"], + "auth.rotateToken not supported on this endpoint" + ); + } +} diff --git a/apps/native/crates/local-api/src/routes/dispatch.rs b/apps/native/crates/local-api/src/routes/dispatch.rs new file mode 100644 index 0000000000..078b52b93c --- /dev/null +++ b/apps/native/crates/local-api/src/routes/dispatch.rs @@ -0,0 +1,1229 @@ +//! `POST /_sandbox/dispatch` + `DELETE /_sandbox/runs/:id` — byte-parity +//! target: `daemon/routes/dispatch.ts`, oracle `daemon.dispatch.e2e.test.ts`. +//! Full gate/event detail: +//! the native local-API contract. +//! +//! Phase 2 scope (this file): the Phase 1 pre-stream gates (byte-parity in +//! ORDER and BODY with the daemon) are UNCHANGED; this phase adds the real +//! streaming run — resolve via `crates/harness`, spawn, translate its +//! ndjson to SSE `ui-message-chunk`/`error`/`done` frames, persist to the +//! local thread store, and wire cancel to actually kill the process. The +//! bearer check itself is NOT done in this file — both routes live under +//! the `/_sandbox` sub-router, which `router.rs`'s shared `guard` +//! middleware already gates (401 before this handler ever runs), unlike +//! the daemon's `dispatch.ts` which checks the bearer in-handler. Same +//! observable behavior, different layer. +//! +//! Gate order (`dispatch`), each short-circuiting before the next: +//! 1. (401 — enforced by the shared router guard, not here) +//! 2. bad_json — body isn't valid JSON +//! 3. missing_harness_id — `harnessId` isn't a string +//! 4. missing_run_id — `runId` isn't a string +//! 5. bad_input — `input` fails the harness-stream-input shape (see +//! `validate_harness_input` below) +//! 6. tombstoned (410) — `runId` is within its 60s post-cancel window +//! 7. unknown_harness (400) — `harnessId` isn't `"claude-code"`/`"codex"`, +//! OR it IS one of those but `harness::run::build_argv` can't resolve +//! a runnable binary for it (missing CLI, bad `LOCAL_API_CLAUDE_BIN`/ +//! `LOCAL_API_CODEX_BIN` override — see `crates/harness/src/run.rs`'s +//! module doc for why "CLI unavailable" is folded into this SAME gate +//! rather than a separate status: both mean "this run cannot start," +//! decided BEFORE any process is spawned or SSE header is written). +//! +//! `validate_harness_input` is a Phase 1 APPROXIMATION of +//! `packages/sandbox/dispatch/schemas.ts::harnessStreamInputSchema` (a Zod +//! schema): it checks presence/type of every REQUIRED top-level and +//! first-level-nested field, which is enough to byte-match the one +//! contract-pinned assertion (`error:"bad_input"` on a malformed/empty +//! envelope — the exact Zod `detail` message text is NOT pinned by either +//! e2e suite, only the `error` field is). It does NOT enforce `.strict()` +//! (rejecting unknown extra keys), the `workspace` discriminated union's +//! full nuance, or the optional `models.fast/smart/image/deepResearch` +//! sub-shapes — unchanged from Phase 1, still flagged as a follow-up. +//! +//! The offload path (`messagesRef`) is dropped (§C) — `input.messages` is +//! always sent inline, so this file never looks for `messagesRef`. +//! +//! ## Thread-store persistence (Phase 2 addition — corrects a Phase 1 doc note) +//! +//! the native module-ownership contract's Dispatch section (written in +//! Phase 1) says dispatch "does NOT write to the threads store itself... +//! exactly like the daemon's does today." That's true for the TS DAEMON, +//! which has a separate cluster-side consumer of its SSE stream that owns +//! the write. Desktop `local-api` has no such consumer — the bundled +//! frontend (Phase 3) IS the SSE consumer, over the SAME localhost +//! connection dispatch answers directly. So THIS file persists on its own +//! behalf: the user message is written before streaming begins (via +//! `crate::routes::threads::ThreadsDb::create_thread_with_id` + +//! `create_run` + `create_message_with_id`, ALL idempotent-by-id so a +//! retried/re-polled dispatch for the same `runId` never duplicates +//! anything — see those methods' own doc comments), the assistant message +//! is assembled via `harness::parts::PartsAccumulator` as chunks land and +//! written once the stream ends, and the run's terminal status +//! (`completed`/`failed`/`cancelled`) is written last. `Run.id` equals +//! this route's own `runId`, matching the contract's Threads-store +//! section. +//! +//! Exercised end-to-end (dispatch-then-read-thread, against the stub +//! harness) by `apps/native/e2e/dispatch-stream.e2e.test.ts`'s "thread +//! persistence" case, plus a real-CLI run recorded in +//! the native harness smoke procedure; `crates/harness`'s own unit +//! tests (`parts.rs`, `run.rs`) and `routes/threads/db.rs`'s idempotency +//! unit tests cover the pieces in isolation. +//! +//! ## Client disconnect == cancel (round-1 verifier fix) +//! +//! Byte-parity gap found and closed: the daemon's byte-parity target +//! (`packages/sandbox/daemon/routes/dispatch.ts`) wires its +//! `ReadableStream`'s `cancel()` callback — fired when the SSE consumer +//! disconnects, e.g. a browser tab closes or the app navigates away mid +//! run — straight to `ctrl.abort()`, which reaches the real harness's +//! `AbortSignal` and kills its spawned process. The dispatch task below +//! does the SAME thing (`client_gone`, tracked from every `tx.send` +//! attempt, starting with the leading SSE comment): the instant the SSE +//! response channel's receiver is gone, `handle.cancel()` is called and no +//! further `ui-message-chunk`/`error` frames are attempted (draining +//! continues only to accumulate whatever streamed before the disconnect +//! and to learn the session id). Without this, a disconnected client would +//! leave its harness process running — or, once its stdout pipe's OS +//! buffer fills with nobody draining it, permanently blocked on a write — +//! with `active_runs` already cleared for that `run_id`, so not even a +//! later `DELETE` could reach it, and the `Run` row would sit at +//! `status:"running"` forever. `resolve_terminal_status` (below) gives +//! this implicit cancel the SAME terminal-status priority as an explicit +//! `DELETE`-driven tombstone: "cancelled" beats a crash that races in +//! right as the disconnect-triggered kill lands. +//! +//! Send-failure detection alone only fires the NEXT time this task +//! attempts a `tx.send` — fine for a harness that's actively streaming, +//! but a harness that goes quiet (a long silent tool call, or the +//! pathological `SCENARIO:hang` fixture) would never give it that +//! opportunity, so `DISPATCH_HEARTBEAT` (below) forces one periodically. +//! Empirically verified (real spawn + `kill -9` on the client mid- +//! `SCENARIO:hang`, no `DELETE` ever sent): without the heartbeat, the +//! spawned process and `status:"running"` row both survived indefinitely; +//! with it, both resolve to gone/`"cancelled"` within one heartbeat tick. + +use std::collections::HashMap; +use std::convert::Infallible; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use axum::body::{Body, Bytes}; +use axum::extract::{Path, State}; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use bytes::Bytes as BodyBytes; +use futures::{future::join_all, stream}; +use serde_json::{json, Map, Value}; +use tokio::sync::{mpsc, Notify}; +use tokio::time::interval; + +use crate::error::ApiError; +use crate::routes::threads; +use crate::state::AppState; + +/// Byte-parity with `TOMBSTONE_MS` in `daemon/routes/dispatch.ts`. +const TOMBSTONE: Duration = Duration::from_secs(60); + +/// How often the dispatch SSE task probes for a vanished client during a +/// harness that's gone quiet (no chunks to relay). Same cadence as +/// `routes/events.rs`'s `HEARTBEAT_INTERVAL`, reused here for consistency +/// rather than inventing a second magic number. WHY THIS EXISTS: the +/// `client_gone` detection on the main event loop (see the task's own doc +/// comment above) only fires the NEXT time this task attempts a `tx.send` +/// — for a harness that's actively streaming, that's essentially +/// immediate, but for one that goes silent for a while (a long tool call +/// with no interim output, or the pathological `SCENARIO:hang` fixture), +/// nothing would ever attempt another send, so a vanished client would +/// never be discovered and its harness process would run — or sit +/// blocked on a full stdout pipe — forever. Verified empirically: WITHOUT +/// this heartbeat, killing the SSE client mid-`SCENARIO:hang` left the +/// spawned stub process alive and the `Run` row stuck at +/// `status:"running"` indefinitely, even with the send-failure-triggered +/// cancel in place. This tick forces a write attempt on a cadence, which +/// is how a otherwise write-only HTTP/1.1 response stream discovers a +/// vanished reader without one — the daemon's TS equivalent gets this +/// for free from `ReadableStream`'s `cancel()` callback firing on its own +/// the instant the consumer disconnects; axum/hyper has no analogous +/// push notification reachable from a handler, so this is the standard +/// SSE stand-in (matches this crate's OWN `/_sandbox/events` precedent). +const DISPATCH_HEARTBEAT: Duration = Duration::from_secs(15); + +static TOMBSTONES: OnceLock>> = OnceLock::new(); + +fn tombstones() -> &'static Mutex> { + TOMBSTONES.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn tombstones_lock() -> std::sync::MutexGuard<'static, HashMap> { + match tombstones().lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + } +} + +/// `true` (and consumes the entry, mirroring the daemon's opportunistic +/// cleanup of an expired tombstone) if `run_id` was cancelled within the +/// last `TOMBSTONE` window. +fn is_tombstoned(run_id: &str) -> bool { + let mut guard = tombstones_lock(); + match guard.get(run_id) { + Some(expiry) if *expiry > Instant::now() => true, + Some(_) => { + guard.remove(run_id); + false + } + None => false, + } +} + +/// The one place a finished run's terminal `Run.status` is decided. +/// Extracted to a pure function so the priority order (cancelled beats +/// failed beats completed) is independently unit-testable without a real +/// harness process. `tombstoned` is an explicit `DELETE +/// /_sandbox/runs/:id`; `client_gone` is an SSE consumer that vanished +/// (mid-stream or before the first byte) — see the dispatch task's own +/// comment for why the two are treated identically here. +fn resolve_terminal_status( + tombstoned: bool, + client_gone: bool, + error_message: Option<&str>, +) -> (&'static str, Option<&str>) { + if tombstoned || client_gone { + ("cancelled", None) + } else if let Some(message) = error_message { + ("failed", Some(message)) + } else { + ("completed", None) + } +} + +/// Cancellation and completion fence for one legacy dispatch. The entry is +/// installed while shutdown admission is held, before the process begins to +/// spawn. That closes both sides of the race: shutdown either rejects the +/// request, or snapshots an entry whose cancellation request is remembered and +/// applied as soon as the harness handle exists. +struct ActiveDispatch { + cancel_requested: AtomicBool, + cancel_handle: Mutex>, + stopped: AtomicBool, + durably_finalized: AtomicBool, + stopped_notify: Notify, +} + +impl ActiveDispatch { + fn new() -> Arc { + Arc::new(Self { + cancel_requested: AtomicBool::new(false), + cancel_handle: Mutex::new(None), + stopped: AtomicBool::new(false), + durably_finalized: AtomicBool::new(false), + stopped_notify: Notify::new(), + }) + } + + fn install(&self, handle: harness::run::CancelHandle) -> bool { + match self.cancel_handle.lock() { + Ok(mut slot) => *slot = Some(handle), + Err(poisoned) => *poisoned.into_inner() = Some(handle), + } + self.cancel_requested.load(Ordering::SeqCst) + } + + async fn cancel(&self) { + self.cancel_requested.store(true, Ordering::SeqCst); + let handle = match self.cancel_handle.lock() { + Ok(slot) => slot.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + }; + if let Some(handle) = handle { + handle.cancel().await; + } + } + + fn mark_stopped(&self) { + if !self.stopped.swap(true, Ordering::SeqCst) { + self.stopped_notify.notify_waiters(); + } + } + + async fn wait_stopped(&self) { + loop { + let notified = self.stopped_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self.stopped.load(Ordering::SeqCst) { + return; + } + notified.await; + } + } +} + +/// In-flight runs, keyed by `runId` — mirrors the TS daemon's module-scoped +/// `activeRuns: Map`, with an additional completion +/// fence so graceful shutdown can prove each side-effecting CLI was reaped +/// before git publish starts. +static ACTIVE_RUNS: OnceLock>>> = OnceLock::new(); + +fn active_runs() -> &'static Mutex>> { + ACTIVE_RUNS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn active_runs_lock() -> std::sync::MutexGuard<'static, HashMap>> { + match active_runs().lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + } +} + +fn finish_active_run(run_id: &str, active: &Arc, durably_finalized: bool) { + if durably_finalized { + active.durably_finalized.store(true, Ordering::SeqCst); + } + let mut runs = active_runs_lock(); + if durably_finalized + && runs + .get(run_id) + .is_some_and(|current| Arc::ptr_eq(current, active)) + { + runs.remove(run_id); + } + drop(runs); + active.mark_stopped(); +} + +/// Makes the ordering contract executable: graceful shutdown may observe an +/// [`ActiveDispatch`] as stopped only after the caller's synchronous durable +/// finalization has returned. Keeping the fence in one helper prevents a +/// future response-writing refactor from accidentally moving `mark_stopped` +/// back in front of SQLite. +fn finish_active_run_after( + run_id: &str, + active: &Arc, + durable_finalize: impl FnOnce() -> bool, +) { + let durably_finalized = durable_finalize(); + // Retain a failed terminal fence in ACTIVE_RUNS. The process is stopped, + // so waiters may wake, but shutdown must still observe the failed durable + // boundary and skip publish. A restart can then recover the SQLite row; + // silently forgetting this entry would let publish race an indeterminate + // transcript/run state. + finish_active_run(run_id, active, durably_finalized); +} + +fn try_send_terminal_frames(tx: &mpsc::Sender, error_message: Option) { + if let Some(message) = error_message { + let _ = tx.try_send(sse_data(&json!({ + "type": "error", + "code": "harness_crashed", + "message": message, + }))); + } + let _ = tx.try_send(sse_data(&json!({"type": "done"}))); +} + +/// Direct legacy-dispatch reap phase used by `ServerHandle::shutdown` after +/// listener/admission closure and before repository publish. Kept separate +/// from the generic hook wrapper so the top-level shutdown pipeline can impose +/// one exact phase order without spending an earlier hook's deadline budget. +pub(crate) async fn shutdown_all(budget: Duration) -> bool { + let runs: Vec> = active_runs_lock().values().cloned().collect(); + stop_active_dispatches(runs, budget).await +} + +async fn stop_active_dispatches(runs: Vec>, budget: Duration) -> bool { + let processes_stopped = tokio::time::timeout(budget, async { + join_all(runs.iter().map(|run| run.cancel())).await; + join_all(runs.iter().map(|run| run.wait_stopped())).await; + }) + .await + .is_ok(); + if !processes_stopped { + tracing::error!( + run_count = runs.len(), + "timed out waiting for legacy dispatch harnesses during shutdown" + ); + } + let durably_finalized = runs + .iter() + .all(|run| run.durably_finalized.load(Ordering::SeqCst)); + if processes_stopped && !durably_finalized { + tracing::error!( + run_count = runs.len(), + "legacy dispatch stopped without a durable terminal state" + ); + } + processes_stopped && durably_finalized +} + +/// SSE headers pinned by the contract's Dispatch Lifecycle section — +/// DIFFERENT from `routes/events.rs`'s `/_sandbox/events` headers +/// (`no-store` here, not `no-cache`; no `X-Accel-Buffering`/ +/// `Content-Encoding`, since those aren't pinned for this route). +fn sse_headers() -> [(header::HeaderName, &'static str); 3] { + [ + (header::CONTENT_TYPE, "text/event-stream"), + (header::CACHE_CONTROL, "no-store"), + (header::CONNECTION, "keep-alive"), + ] +} + +/// `data: \n\n` — dispatch's framing is DATA-ONLY (no `event:` +/// line), unlike `routes/events.rs`'s named-event frames. See the +/// contract's SSE framing section. +fn sse_data(value: &Value) -> BodyBytes { + BodyBytes::from(format!( + "data: {}\n\n", + serde_json::to_string(value).unwrap_or_else(|_| "null".to_string()) + )) +} + +pub async fn dispatch(State(state): State, body: Bytes) -> Response { + let parsed: Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(_) => return ApiError::bad_request("bad_json").into_response(), + }; + + let harness_id_raw = match parsed.get("harnessId").and_then(Value::as_str) { + Some(s) => s.to_string(), + None => return ApiError::bad_request("missing_harness_id").into_response(), + }; + let run_id = match parsed.get("runId").and_then(Value::as_str) { + Some(s) => s.to_string(), + None => return ApiError::bad_request("missing_run_id").into_response(), + }; + + let input = parsed.get("input").cloned().unwrap_or(Value::Null); + if let Err(detail) = validate_harness_input(&input) { + return ApiError::bad_input(detail).into_response(); + } + + if is_tombstoned(&run_id) { + return ApiError::gone("tombstoned").into_response(); + } + + // Gate 7a: is `harnessId` one of the two ids desktop v1 supports? + let Some(harness_id) = harness::HarnessId::from_wire_id(&harness_id_raw) else { + return ApiError::bad_request_with_detail( + "unknown_harness", + format!("unknown harnessId: {harness_id_raw:?}"), + ) + .into_response(); + }; + + let spec = build_run_spec(harness_id, &state, &input).await; + + // Gate 7b: can we actually resolve+build a runnable argv for it? See + // this file's module doc for why binary-unavailability shares the + // `unknown_harness` gate rather than getting its own status. + let argv = match harness::run::build_argv(&spec) { + Ok(argv) => argv, + Err(err) => { + return ApiError::bad_request_with_detail("unknown_harness", err.to_string()) + .into_response(); + } + }; + + // Sandbox resolution above may legitimately clone/install for seconds; do + // not make shutdown wait on it. Once that asynchronous preparation is + // complete, admission covers the short synchronous persistence + active + // reservation below. Shutdown can therefore make a linear decision: this + // run is rejected, or its cancellation fence is visible to the snapshot. + let Some(shutdown_admission) = state.shutdown.admit_work().await else { + return ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "application is shutting down", + ) + .into_response(); + }; + + // Every gate passed — persist the thread/run/user-message BEFORE + // opening the SSE response (still side-effect-free from the CALLER's + // point of view: a storage failure here is a genuine local-api bug, + // `ApiError::internal`, not a different dispatch outcome). See this + // file's module doc's "Thread-store persistence" section. + let thread_id = input + .get("threadId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let db = match threads::shared_db(&state) { + Ok(db) => db, + Err(err) => return err.into_response(), + }; + if let Err(err) = db.create_thread_with_id(&thread_id, "") { + return threads::db_err(err).into_response(); + } + if let Err(err) = db.create_run(&run_id, &thread_id, harness_id.wire_id()) { + return threads::db_err(err).into_response(); + } + let user_parts = input + .get("userMessage") + .and_then(|m| m.get("parts")) + .cloned() + .unwrap_or_else(|| json!([{"type": "text", "text": spec.prompt}])); + if let Err(err) = db.create_message_with_id( + &format!("run-{run_id}-user"), + &thread_id, + "user", + &user_parts, + ) { + return threads::db_err(err).into_response(); + } + + let active = ActiveDispatch::new(); + { + let mut runs = active_runs_lock(); + if runs.contains_key(&run_id) { + return ApiError::conflict("run is already active").into_response(); + } + runs.insert(run_id.clone(), active.clone()); + } + drop(shutdown_admission); + + let mut handle = harness::run::start_with_child_lifetime_lock( + argv, + &spec, + state.tasks.child_lifetime_lock_path().to_path_buf(), + ) + .await; + if active.install(handle.cancel_handle()) { + // Shutdown/cancel won while the harness drive task was being created. + // The remembered request is applied before a single event is consumed. + handle.cancel().await; + } + + let (tx, out_rx) = mpsc::channel::(64); + tokio::spawn(async move { + // Leading SSE comment so a slow-to-connect consumer can + // distinguish "accepted, streaming" from "never got a response" — + // byte-parity with the daemon's `DISPATCH_ACCEPTED_SSE_COMMENT`. + // + // `client_gone` tracks byte-parity with the daemon's + // `ReadableStream` `cancel()` callback (`ctrl.abort()` in + // `packages/sandbox/daemon/routes/dispatch.ts`, which reaches the + // real harness's `AbortSignal` and kills its spawned process): an + // SSE consumer that vanishes — mid-stream, OR before even the + // first byte, e.g. a browser tab closed the instant a fetch was + // issued — must abort the harness run the SAME way an explicit + // `DELETE /_sandbox/runs/:id` does. Without this, a disconnected + // client leaves its harness process running (or, once its stdout + // pipe's OS buffer fills with nobody draining it, BLOCKED + // indefinitely on a write no one will ever read) with no way left + // to reach it — `active_runs` is cleared for this `run_id` below, + // so a later cancel request couldn't find it either, and the run + // row would otherwise sit at `status:"running"` forever. + let mut client_gone = tx + .send(BodyBytes::from_static(b": dispatch accepted\n\n")) + .await + .is_err(); + if client_gone { + handle.cancel().await; + } + + let mut accumulator = harness::parts::PartsAccumulator::new(); + let mut error_message: Option = None; + // See `DISPATCH_HEARTBEAT`'s doc comment: a harness that goes + // quiet (no chunks to relay) would otherwise give this task no + // opportunity to attempt a `tx.send` and so no way to ever notice + // a vanished client — this tick forces that opportunity on a + // cadence, same pattern as `routes/events.rs`. + let mut heartbeat = interval(DISPATCH_HEARTBEAT); + heartbeat.tick().await; // first tick fires immediately — consume it + loop { + tokio::select! { + event = handle.recv() => { + match event { + Some(harness::run::RunEvent::SessionId { .. }) => { + // The daemon-parity dispatch surface has no durable + // thread queue. Its accumulator reads the same id + // from `RunHandle` at terminal completion. + } + Some(harness::run::RunEvent::Chunk(chunk)) => { + accumulator.feed(&chunk); + if !client_gone { + let frame = + sse_data(&json!({"type": "ui-message-chunk", "chunk": chunk})); + if tx.send(frame).await.is_err() { + client_gone = true; + handle.cancel().await; + } + } + } + Some(harness::run::RunEvent::FatalError { message }) => { + error_message = Some(message.clone()); + // Treat the fatal frame as terminal even if a buggy + // CLI writes it and then hangs. The run must be + // cancelled and fully reaped before its active slot + // disappears; otherwise a retry can overlap the old + // side-effecting process group for the TERM grace + // period. + handle.cancel().await; + while handle.recv().await.is_some() {} + break; + } + None => break, + } + } + _ = heartbeat.tick(), if !client_gone => { + // Nothing to relay right now — proactively probe + // whether the client is still there instead of + // waiting for the next real event, which for a quiet + // harness might be a long time (or, `SCENARIO:hang`, + // never). + if tx + .send(BodyBytes::from_static(b": keep-alive\n\n")) + .await + .is_err() + { + client_gone = true; + handle.cancel().await; + } + } + } + } + // Terminal status: an explicit cancel (DELETE /_sandbox/runs/:id) + // always writes a tombstone (see `cancel_run` below); an implicit + // client-disconnect cancel (`client_gone`, above) doesn't write + // one but means the exact same thing for status purposes — both + // resolve to "cancelled", taking priority over a crash that might + // race in right as/after the disconnect is detected (the crash + // reflects our own teardown, not a genuine failure the — already + // gone — caller needs to know about). + let session_id = handle.session_id().await; + let assistant_parts = + accumulator.finish(session_id.as_deref().map(|sid| (harness_id.wire_id(), sid))); + let (status, error) = resolve_terminal_status( + is_tombstoned(&run_id), + client_gone, + error_message.as_deref(), + ); + finish_active_run_after(&run_id, &active, || { + let mut durably_finalized = true; + if !assistant_parts.is_empty() { + if let Err(err) = db.create_message_with_id( + &format!("run-{run_id}-assistant"), + &thread_id, + "assistant", + &Value::Array(assistant_parts), + ) { + tracing::error!( + run_id, + error = %err, + "failed to durably persist legacy dispatch assistant message" + ); + durably_finalized = false; + } + } + if let Err(err) = db.set_run_terminal_status(&run_id, status, error) { + tracing::error!( + run_id, + error = %err, + "failed to durably finalize legacy dispatch run status" + ); + durably_finalized = false; + } + durably_finalized + }); + + // `recv() == None` above is the process-reap fence, but shutdown does + // not observe this dispatch as stopped until `finish_active_run_after` + // has attempted both durable writes. In particular, never put the + // bounded SSE channel send in front of SQLite finalization: a slow or + // vanished response consumer is allowed to lose the final `done` + // frame, but it must never leave a successfully-reaped run stuck at + // `status:"running"` or hide its assistant message from the next boot. + if !client_gone { + // Terminal response delivery is deliberately non-blocking. A + // half-open consumer can saturate this bounded channel forever; + // process reap and durable finalization above must never depend on + // it. Healthy consumers continuously drain and still receive the + // pinned `error` -> `done` order. + try_send_terminal_frames(&tx, error_message); + } + }); + + let body_stream = stream::unfold(out_rx, |mut rx| async move { + rx.recv() + .await + .map(|frame| (Ok::<_, Infallible>(frame), rx)) + }); + + let mut response = Response::new(Body::from_stream(body_stream)); + *response.status_mut() = StatusCode::OK; + for (name, value) in sse_headers() { + response.headers_mut().insert(name, value.parse().unwrap()); + } + response +} + +/// Builds the [`harness::run::RunSpec`] from a validated `input` envelope. +/// +/// `cwd` used to ALWAYS be `state.repo_dir` — desktop's `workspace.cwd` +/// symbolic contract (`null` vs `"/repo"`) collapsed to one physical +/// directory because nothing drove a per-branch workdir yet. Per +/// the native Git-sandbox contract, a `workspace.cwd: "/repo"` run +/// now resolves to its OWN per-`(virtualMcpId, branch)` sandbox workdir via +/// [`crate::sandbox::SandboxManager::ensure`] — see [`resolve_dispatch_cwd`]. +/// `workspace.cwd: null` (the non-git-backed, "just an already-checked-out +/// folder" case) is UNCHANGED: still `state.repo_dir`. +async fn build_run_spec( + harness_id: harness::HarnessId, + state: &AppState, + input: &Value, +) -> harness::run::RunSpec { + let user_message = input.get("userMessage").cloned().unwrap_or(Value::Null); + let prompt = harness::run::extract_user_text(&user_message); + let resume_session_id = input + .pointer("/harness/sessionId") + .and_then(Value::as_str) + .map(str::to_string); + let model_id = input + .pointer("/models/thinking/id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let tool_approval = harness::run::ToolApproval::from_wire( + input + .get("toolApprovalLevel") + .and_then(Value::as_str) + .unwrap_or("auto"), + ); + let plan_mode = input.get("mode").and_then(Value::as_str) == Some("plan"); + let cwd = resolve_dispatch_cwd(state, input).await; + + harness::run::RunSpec { + // Daemon-parity route: no org-filesystem block and no agent MCP (the + // desktop chat path in `intercept/decopilot.rs` injects both). + append_system_prompt: None, + mcp: None, + harness: harness_id, + cwd: Some(cwd), + prompt, + resume_session_id, + model_id, + tool_approval, + plan_mode, + } +} + +/// `state.repo_dir` for a non-git-backed run, else a per-handle +/// [`crate::sandbox::Sandbox::workdir`] — ensured (cloned/checked-out) FIRST +/// via [`crate::sandbox::SandboxManager::ensure`]. A git-backed run whose +/// sandbox can't be ensured (bad repo/branch, offline, ...) still resolves to +/// its OWN `/sandboxes//repo`, never the shared +/// `state.repo_dir` — see [`crate::sandbox::SandboxManager::workdir_for`] for +/// why sharing that directory across threads is unsafe. The dispatch is not +/// failed outright: this route's gate order/error envelope is contract-pinned +/// (see this file's module doc) and a clone failure is already independently +/// observable via the sandbox's own `SetupOrchestrator` lifecycle +/// (`clone-failed`), so widening the gate list here would cost byte-parity for +/// no real gain. +async fn resolve_dispatch_cwd(state: &AppState, input: &Value) -> PathBuf { + let Some(git_cfg) = git_sandbox_config_from_workspace(input) else { + return state.repo_dir.clone(); + }; + match state.sandbox_manager.ensure(&git_cfg).await { + Ok(sandbox) => sandbox.workdir.clone(), + Err(err) => { + let workdir = state.sandbox_manager.workdir_for(&git_cfg); + tracing::warn!( + error = %err, + workdir = %workdir.display(), + "git sandbox ensure failed for /_sandbox/dispatch; staying in the sandbox workdir" + ); + let _ = tokio::fs::create_dir_all(&workdir).await; + workdir + } + } +} + +/// `None` for `workspace.cwd: null` (not git-backed) or a `workspace` shape +/// [`validate_harness_input`] already rejected upstream of this call. +/// `virtualMcpId` is `input.agent.id` (validated present/string by +/// [`validate_harness_input`]'s `agent.id` check). `cloneUrl`: prefers +/// `workspace.repo.cloneUrl` when present — a local-api-only field over the +/// real `harnessStreamInputSchema` shape (tolerated: this file's own module +/// doc notes `.strict()` isn't enforced), used by this crate's e2e suite to +/// point at a `file://` fixture repo — else derived from `owner`/`name` as +/// a plain `https://github.com//.git` (cloned via the user's +/// own ambient git auth, never a minted token — see `setup/clone.rs`'s +/// `base_argv` doc comment for why). +fn git_sandbox_config_from_workspace(input: &Value) -> Option { + let workspace = input.get("workspace")?; + if workspace.get("cwd").and_then(Value::as_str) != Some("/repo") { + return None; + } + let repo = workspace.get("repo")?; + let clone_url = repo + .get("cloneUrl") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + let owner = repo.get("owner").and_then(Value::as_str)?; + let name = repo.get("name").and_then(Value::as_str)?; + Some(format!("https://github.com/{owner}/{name}.git")) + })?; + let virtual_mcp_id = input + .pointer("/agent/id") + .and_then(Value::as_str)? + .to_string(); + let branch = workspace + .get("branch") + .and_then(Value::as_str) + .map(str::to_string); + Some(crate::sandbox::GitSandboxConfig { + virtual_mcp_id, + clone_url, + branch, + ..Default::default() + }) +} + +pub async fn cancel_run(State(_state): State, Path(run_id): Path) -> StatusCode { + // Idempotent by construction: unconditionally (re-)writes the + // tombstone and returns 204 whether or not `run_id` was ever an active + // dispatch — byte-parity with `handleCancelRequest` in + // `daemon/routes/dispatch.ts`. + // Scoped so the `MutexGuard` (a `!Send` type) is UNAMBIGUOUSLY + // dropped before the `.await` below — an explicit `drop(guard)` call + // at the end of this block is not always enough for the compiler's + // future-`Send` analysis to prove the guard doesn't span the await; + // a real block scope is. + { + let mut guard = tombstones_lock(); + let now = Instant::now(); + guard.insert(run_id.clone(), now + TOMBSTONE); + // Opportunistic sweep so this map can't grow unbounded over a + // long process lifetime (a desktop app can run for days). + guard.retain(|_, expiry| *expiry > now); + } + + // Phase 2: actually reach the live harness process (if any) and kill + // its process group — the tombstone above only prevents a FUTURE + // dispatch of this runId; this is what stops the CURRENTLY running + // one. Best-effort: `run_id` not being in the map just means the run + // already finished (or never started), exactly like the TS daemon's + // `activeRuns.get(runId)` returning `undefined`. + let active = active_runs_lock().get(&run_id).cloned(); + if let Some(active) = active { + active.cancel().await; + } + + StatusCode::NO_CONTENT +} + +/// See this file's module doc for exactly what this does and doesn't +/// check. Returns `Err(detail)` (a human-readable summary of every +/// missing/mistyped field, NOT a byte-parity Zod message) on failure. +fn validate_harness_input(input: &Value) -> Result<(), String> { + let obj = match input.as_object() { + Some(o) => o, + None => return Err("input must be an object".to_string()), + }; + + let mut missing: Vec<&'static str> = Vec::new(); + let is_str = |o: &Map, k: &str| matches!(o.get(k), Some(Value::String(_))); + let is_obj = |o: &Map, k: &str| matches!(o.get(k), Some(Value::Object(_))); + + if !is_str(obj, "threadId") { + missing.push("threadId"); + } + if !is_obj(obj, "userMessage") { + missing.push("userMessage"); + } + match obj.get("harness") { + Some(Value::Object(h)) => { + if let Some(sid) = h.get("sessionId") { + if !sid.is_string() { + missing.push("harness.sessionId"); + } + } + } + _ => missing.push("harness"), + } + validate_workspace(obj, &mut missing); + validate_models(obj, &mut missing); + validate_mcp(obj, &mut missing); + + let mode_ok = matches!( + obj.get("mode").and_then(Value::as_str), + Some("default" | "plan" | "web-search" | "gen-image") + ); + if !mode_ok { + missing.push("mode"); + } + if !matches!(obj.get("temperature"), Some(Value::Number(_))) { + missing.push("temperature"); + } + let tal_ok = matches!( + obj.get("toolApprovalLevel").and_then(Value::as_str), + Some("auto" | "readonly") + ); + if !tal_ok { + missing.push("toolApprovalLevel"); + } + match obj.get("user") { + Some(Value::Object(u)) => { + if !is_str(u, "id") { + missing.push("user.id"); + } + if !is_str(u, "email") { + missing.push("user.email"); + } + } + _ => missing.push("user"), + } + if !is_str(obj, "organizationId") { + missing.push("organizationId"); + } + match obj.get("agent") { + Some(Value::Object(a)) => { + if !is_str(a, "id") { + missing.push("agent.id"); + } + } + _ => missing.push("agent"), + } + + if missing.is_empty() { + Ok(()) + } else { + Err(format!( + "invalid harness input: missing/invalid field(s): {}", + missing.join(", ") + )) + } +} + +fn validate_workspace(obj: &Map, missing: &mut Vec<&'static str>) { + let Some(Value::Object(w)) = obj.get("workspace") else { + missing.push("workspace"); + return; + }; + match w.get("cwd") { + Some(Value::Null) => {} + Some(Value::String(s)) if s == "/repo" => { + match w.get("repo") { + Some(Value::Object(r)) => { + if !matches!(r.get("owner"), Some(Value::String(_))) { + missing.push("workspace.repo.owner"); + } + if !matches!(r.get("name"), Some(Value::String(_))) { + missing.push("workspace.repo.name"); + } + if !matches!(r.get("connectedGithub"), Some(Value::Bool(_))) { + missing.push("workspace.repo.connectedGithub"); + } + } + _ => missing.push("workspace.repo"), + } + if !matches!(w.get("branch"), Some(Value::String(_)) | Some(Value::Null)) { + missing.push("workspace.branch"); + } + } + _ => missing.push("workspace.cwd"), + } +} + +fn validate_models(obj: &Map, missing: &mut Vec<&'static str>) { + let Some(Value::Object(m)) = obj.get("models") else { + missing.push("models"); + return; + }; + match m.get("thinking") { + Some(Value::Object(t)) => { + if !matches!(t.get("id"), Some(Value::String(_))) { + missing.push("models.thinking.id"); + } + if !matches!(t.get("title"), Some(Value::String(_))) { + missing.push("models.thinking.title"); + } + if !matches!(t.get("credentialId"), Some(Value::String(_))) { + missing.push("models.thinking.credentialId"); + } + } + _ => missing.push("models.thinking"), + } +} + +fn validate_mcp(obj: &Map, missing: &mut Vec<&'static str>) { + let Some(Value::Object(m)) = obj.get("mcp") else { + missing.push("mcp"); + return; + }; + if !matches!(m.get("url"), Some(Value::String(_))) { + missing.push("mcp.url"); + } + if !matches!(m.get("headers"), Some(Value::Object(_))) { + missing.push("mcp.headers"); + } + if !matches!(m.get("expiresAt"), Some(Value::Number(_))) { + missing.push("mcp.expiresAt"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn valid_input() -> Value { + json!({ + "threadId": "t1", + "userMessage": {"text": "hi"}, + "harness": {}, + "workspace": {"cwd": null}, + "models": { + "thinking": {"id": "m1", "title": "Model", "credentialId": "c1"}, + }, + "mcp": {"url": "https://example.com", "headers": {}, "expiresAt": 1}, + "mode": "default", + "temperature": 0.5, + "toolApprovalLevel": "auto", + "user": {"id": "u1", "email": "u@example.com"}, + "organizationId": "org1", + "agent": {"id": "a1"}, + }) + } + + #[test] + fn empty_object_is_rejected() { + assert!(validate_harness_input(&json!({})).is_err()); + } + + #[test] + fn non_object_is_rejected() { + assert!(validate_harness_input(&Value::Null).is_err()); + assert!(validate_harness_input(&json!("nope")).is_err()); + } + + #[test] + fn a_fully_populated_minimal_input_is_accepted() { + assert!(validate_harness_input(&valid_input()).is_ok()); + } + + #[test] + fn workspace_repo_cwd_requires_repo_fields() { + let mut v = valid_input(); + v["workspace"] = json!({"cwd": "/repo", "branch": null}); + let err = validate_harness_input(&v).unwrap_err(); + assert!(err.contains("workspace.repo")); + } + + #[test] + fn workspace_repo_cwd_with_full_repo_is_accepted() { + let mut v = valid_input(); + v["workspace"] = json!({ + "cwd": "/repo", + "repo": {"owner": "o", "name": "n", "connectedGithub": true}, + "branch": "main", + }); + assert!(validate_harness_input(&v).is_ok()); + } + + // -- git_sandbox_config_from_workspace ----------------------------------- + + #[test] + fn null_cwd_is_not_git_backed() { + let v = valid_input(); + assert!(git_sandbox_config_from_workspace(&v).is_none()); + } + + #[test] + fn repo_cwd_derives_a_github_clone_url_from_owner_and_name() { + let mut v = valid_input(); + v["workspace"] = json!({ + "cwd": "/repo", + "repo": {"owner": "acme", "name": "widgets", "connectedGithub": true}, + "branch": "feature-x", + }); + let cfg = git_sandbox_config_from_workspace(&v).expect("git-backed"); + assert_eq!(cfg.virtual_mcp_id, "a1"); + assert_eq!(cfg.clone_url, "https://github.com/acme/widgets.git"); + assert_eq!(cfg.branch.as_deref(), Some("feature-x")); + } + + #[test] + fn repo_cwd_prefers_an_explicit_clone_url_over_owner_name() { + // Local-api-only escape hatch (see this function's doc comment) — + // used by this crate's own e2e suite to point at a `file://` fixture + // repo, since a real GitHub owner/name pair can't reach one. + let mut v = valid_input(); + v["workspace"] = json!({ + "cwd": "/repo", + "repo": { + "owner": "acme", + "name": "widgets", + "connectedGithub": false, + "cloneUrl": "/tmp/fixture-repo.git", + }, + "branch": "main", + }); + let cfg = git_sandbox_config_from_workspace(&v).expect("git-backed"); + assert_eq!(cfg.clone_url, "/tmp/fixture-repo.git"); + } + + #[test] + fn repo_cwd_with_null_branch_omits_branch() { + let mut v = valid_input(); + v["workspace"] = json!({ + "cwd": "/repo", + "repo": {"owner": "acme", "name": "widgets", "connectedGithub": true}, + "branch": null, + }); + let cfg = git_sandbox_config_from_workspace(&v).expect("git-backed"); + assert!(cfg.branch.is_none()); + } + + #[test] + fn bad_mode_is_rejected() { + let mut v = valid_input(); + v["mode"] = json!("not-a-mode"); + assert!(validate_harness_input(&v).is_err()); + } + + #[test] + fn tombstone_marks_run_as_gone_then_expires() { + let run_id = "test-run-tombstone-marks"; + assert!(!is_tombstoned(run_id)); + tombstones_lock().insert( + run_id.to_string(), + Instant::now() + Duration::from_millis(50), + ); + assert!(is_tombstoned(run_id)); + std::thread::sleep(Duration::from_millis(80)); + assert!(!is_tombstoned(run_id)); + } + + // -- resolve_terminal_status: priority order ----------------------- + + #[test] + fn clean_finish_with_no_tombstone_or_disconnect_is_completed() { + assert_eq!( + resolve_terminal_status(false, false, None), + ("completed", None) + ); + } + + #[test] + fn a_fatal_error_with_no_tombstone_or_disconnect_is_failed() { + assert_eq!( + resolve_terminal_status(false, false, Some("boom")), + ("failed", Some("boom")) + ); + } + + #[test] + fn explicit_tombstone_is_cancelled_even_with_an_error_message() { + // A DELETE /_sandbox/runs/:id landing right as the harness also + // crashed (e.g. from the SIGTERM cancel() sent) must still read as + // an intentional cancel, not a failure. + assert_eq!( + resolve_terminal_status(true, false, Some("killed")), + ("cancelled", None) + ); + } + + #[test] + fn client_disconnect_is_cancelled_even_with_an_error_message() { + // Same priority for the OTHER cancel path (SSE consumer vanished + // without ever calling DELETE) — see the dispatch task's + // `client_gone` doc comment: byte-parity with the daemon's + // ReadableStream `cancel()` -> `ctrl.abort()`. + assert_eq!( + resolve_terminal_status(false, true, Some("exited with code 143")), + ("cancelled", None) + ); + } + + #[test] + fn both_tombstoned_and_client_gone_is_still_just_cancelled() { + assert_eq!( + resolve_terminal_status(true, true, Some("whatever")), + ("cancelled", None) + ); + } + + #[test] + fn active_dispatch_is_stopped_only_after_durable_finalization_returns() { + let run_id = "dispatch-finalization-order"; + let active = ActiveDispatch::new(); + active_runs_lock().insert(run_id.to_string(), active.clone()); + let persisted = Arc::new(AtomicBool::new(false)); + let inside = persisted.clone(); + + finish_active_run_after(run_id, &active, || { + assert!( + !active.stopped.load(Ordering::SeqCst), + "shutdown fence released before durable writes" + ); + inside.store(true, Ordering::SeqCst); + true + }); + + assert!(persisted.load(Ordering::SeqCst)); + assert!(active.stopped.load(Ordering::SeqCst)); + assert!(active.durably_finalized.load(Ordering::SeqCst)); + assert!(!active_runs_lock().contains_key(run_id)); + } + + #[tokio::test] + async fn terminal_persistence_failure_remains_visible_to_the_publish_gate() { + let run_id = "dispatch-finalization-failed"; + let active = ActiveDispatch::new(); + active_runs_lock().insert(run_id.to_string(), active.clone()); + + finish_active_run_after(run_id, &active, || false); + + assert!(active.stopped.load(Ordering::SeqCst)); + assert!(!active.durably_finalized.load(Ordering::SeqCst)); + assert!(active_runs_lock().contains_key(run_id)); + assert!( + !stop_active_dispatches(vec![active.clone()], Duration::from_millis(5)).await, + "a reaped harness with an indeterminate SQLite terminal state must block publish" + ); + active_runs_lock().remove(run_id); + } + + #[test] + fn saturated_response_channel_cannot_block_terminal_cleanup() { + let (tx, mut rx) = mpsc::channel(1); + tx.try_send(BodyBytes::from_static(b"occupied")) + .expect("prefill response channel"); + + // This is intentionally synchronous. A former `.send(...).await` in + // the fatal path could suspend forever here and prevent process reap + + // SQLite finalization from ever reaching their fence. + try_send_terminal_frames(&tx, Some("boom".to_string())); + assert_eq!(rx.try_recv().unwrap(), BodyBytes::from_static(b"occupied")); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn healthy_terminal_channel_preserves_error_then_done_order() { + let (tx, mut rx) = mpsc::channel(2); + try_send_terminal_frames(&tx, Some("boom".to_string())); + let error = String::from_utf8(rx.try_recv().unwrap().to_vec()).unwrap(); + let done = String::from_utf8(rx.try_recv().unwrap().to_vec()).unwrap(); + assert!(error.contains("\"type\":\"error\"")); + assert!(done.contains("\"type\":\"done\"")); + } + + #[tokio::test] + async fn direct_shutdown_reports_whether_all_dispatches_stopped() { + assert!(stop_active_dispatches(Vec::new(), Duration::from_millis(1)).await); + + let never_stopped = ActiveDispatch::new(); + assert!( + !stop_active_dispatches(vec![never_stopped.clone()], Duration::from_millis(5)).await, + "publish gate must see a timed-out active dispatch as unsafe" + ); + never_stopped.mark_stopped(); + } +} diff --git a/apps/native/crates/local-api/src/routes/events.rs b/apps/native/crates/local-api/src/routes/events.rs new file mode 100644 index 0000000000..50be31118c --- /dev/null +++ b/apps/native/crates/local-api/src/routes/events.rs @@ -0,0 +1,543 @@ +//! `GET /_sandbox/events` — SSE. Byte-parity target: +//! `daemon/routes/events-stream.ts` + `daemon/events/sse.ts`, oracle +//! `daemon.e2e.test.ts` (SSE smoke) + `daemon.sse-shapes.e2e.test.ts` +//! (per-event-type wire assertions) + `apps/native/e2e/sse.e2e.test.ts`. +//! +//! Frame format: `event: \ndata: \n\n` +//! (the native local-API contract). Headers: +//! `Content-Type: text/event-stream`, `Cache-Control: no-cache`, +//! `Connection: keep-alive`, `X-Accel-Buffering: no`, +//! `Content-Encoding: identity`. +//! +//! Unlike the daemon, this route sits BEHIND the `/_sandbox` guard (bearer + +//! Origin allowlist, wired in `router.rs`) — the contract doc deliberately +//! tightens the daemon's no-auth `/_sandbox/events` for local-api (see +//! the native local-API contract); no auth handling lives in +//! this file itself. +//! +//! ## Ownership boundary vs. the native module-ownership contract +//! +//! The `events` family here is scoped to the SSE hub itself: subscribing to +//! `state.broadcaster`, connect-time framing/headers, and the content of the +//! `status` snapshot frame. `"file-changed"`/`"reload"` emission is +//! explicitly assigned to the **fs**/**setup** families in +//! the native module-ownership contract's "Emits" table — this file does not run a +//! filesystem watcher and does not call `broadcaster.emit()` for those names +//! itself. Whatever any family `emit()`s is forwarded here verbatim by name. +//! +//! `"lifecycle"`, `"branch"`, and `"scripts"` connect-time snapshot frames +//! ARE sent (a Phase-1-doc correction: `crate::setup::SetupOrchestrator` +//! carries the real pipeline lifecycle/discovered-scripts state). Branch +//! metadata is recomputed from the resolved target's repository for every +//! handshake/sandbox switch. It must not come from process-global state: +//! several sandbox branches coexist in one app process and their worktrees +//! survive a backend restart. `"branch"`/`"scripts"` are conditionally +//! omitted exactly like the daemon's own `sse.ts` ("if (scripts) ...", "if +//! (branch) ...") when the resolved target has no repository/scripts. + +use std::convert::Infallible; +use std::time::Duration; + +use axum::body::Body; +use axum::extract::{Query, State}; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use bytes::Bytes; +use futures::stream; +use serde::Deserialize; +use tokio::sync::{broadcast, mpsc}; +use tokio::time::interval; + +use crate::events::snapshot; +use crate::state::AppState; +use crate::tasks::TaskStatus; + +/// Optional `?handle=` — routes the SSE stream at a SPECIFIC +/// per-handle sandbox's orchestrator quadruple instead of the active/global +/// one. The query is used because a native `EventSource` (which some frontends +/// use) can't set the `x-decocms-sandbox-handle` header. A known durable handle +/// is metadata-adopted without starting processes, so retained logs can replay +/// immediately after a backend restart; an unknown explicit handle is a +/// truthful 404 and never falls through to global. An absent/empty handle +/// resolves active -> global through [`AppState::resolve_sandbox_target`]. +/// `serde` ignores any other query params (e.g. a cache-buster). +#[derive(Deserialize)] +pub struct EventsQuery { + pub(crate) handle: Option, +} + +/// Byte-parity value with `MAX_SSE_CLIENTS` (`daemon/constants.ts`). Not +/// pinned by either e2e suite for local-api, but cheap to carry forward so a +/// runaway reconnect loop can't unbounded-grow the broadcaster's client set. +const MAX_SSE_CLIENTS: usize = 100; + +/// Byte-parity with `HEARTBEAT_INTERVAL_MS` (`daemon/events/sse.ts`). +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15); + +/// Generous enough that a burst of connect-time snapshot frames plus a live +/// event never blocks the forwarding task on a slow consumer for long; the +/// consumer draining this channel is the HTTP response body writer. +const OUT_CHANNEL_CAPACITY: usize = 64; + +/// Connect-time (and active-switch-time) frame set for a resolved target: +/// lifecycle/tasks/status/branch/scripts snapshots plus one `log` replay +/// frame per known source — see the inline comments, which moved here +/// verbatim when headerless streams learned to FOLLOW the active sandbox +/// (the same frames are now also emitted mid-stream on every switch). +async fn initial_frames(target: &crate::sandbox::SandboxTarget) -> Vec { + let running = target.tasks.list(Some(&[TaskStatus::Running])); + let mut initial = vec![ + snapshot::frame( + "lifecycle", + &snapshot::lifecycle(target.setup.lifecycle_snapshot()), + ), + snapshot::frame("tasks", &snapshot::active_tasks(&running)), + snapshot::frame("status", &snapshot::status()), + ]; + if let Some(meta) = crate::routes::git::branch_snapshot(&target.repo_dir).await { + initial.push(snapshot::frame( + "branch", + &serde_json::json!({ "meta": meta }), + )); + } + if let Some(scripts) = target.setup.discovered_scripts() { + initial.push(snapshot::frame( + "scripts", + &serde_json::json!({ "scripts": scripts }), + )); + } + + // Replay each known log SOURCE's combined transcript as ONE `log` frame. + // The broadcaster is LIVE-only (no per-subscriber backlog), and a + // terminal almost always opens AFTER the dev server already printed its + // startup output — so without this replay the pane is blank (a settled + // dev server emits no new live lines). + // + // Sources are enumerated from `LogStore`'s stable `"app/"` files, + // NOT from `TaskRegistry`: after a backend restart the files survive but + // the task registry is intentionally empty. The on-disk catalog therefore + // makes old setup/dev output discoverable without restoring a RAM replay + // buffer. `data`-only frames match exactly what live append emits, so the + // frontend appends replay and live chunks identically. + let logs = target.tasks.logs(); + for source in logs.app_sources().await { + let data = logs + .tail_read( + &crate::log_store::app_key(&source), + crate::log_store::DEFAULT_TAIL_BYTES, + ) + .await; + if !data.is_empty() { + initial.push(snapshot::frame( + "log", + &serde_json::json!({ "source": source, "data": data }), + )); + } + } + + initial +} + +pub async fn events(State(state): State, Query(q): Query) -> Response { + // Resolve WHICH orchestrator quadruple this stream observes: a strict, + // metadata-only-adopted per-handle sandbox (`?handle=`), the active sandbox + // (headerless), or the process-global one. Every per-target read below + // (`broadcaster`, `tasks`, `setup`, `repo_dir`) uses this, including the + // connect-time branch snapshot. + let explicit_handle = q.handle.filter(|handle| !handle.is_empty()); + let target = match explicit_handle.as_deref() { + Some(handle) => match state.sandbox_manager.adopt(handle).await { + Ok(Some(sandbox)) => crate::sandbox::SandboxTarget::from_sandbox(&sandbox), + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + format!("unknown sandbox handle: {handle}"), + ) + .into_response(); + } + Err(error) => { + tracing::error!(handle, %error, "failed to adopt sandbox for event replay"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to resolve sandbox event stream", + ) + .into_response(); + } + }, + None => state.resolve_sandbox_target(None), + }; + + if target.broadcaster.subscriber_count() >= MAX_SSE_CLIENTS { + return (StatusCode::TOO_MANY_REQUESTS, "Too many connections").into_response(); + } + + // Subscribe BEFORE building the connect-time snapshot so a live event + // emitted while the snapshot is being assembled is never lost (a small + // improvement over the daemon's own `sse.ts`, which registers its + // controller only after enqueuing every snapshot frame — harmless either + // way since no oracle test pins exact frame ordering/count here, but + // strictly safer). + let mut rx = target.broadcaster.subscribe(); + // Headerless streams follow the active sandbox. Explicit streams follow + // one durable handle's process generations, so Stop -> Resume remains + // observable even if another chat becomes active in between. + let mut active_rx = state.sandbox_manager.watch_active(); + let mut generation_rx = match explicit_handle.as_deref() { + Some(handle) => state.sandbox_manager.watch_generation(handle), + None => tokio::sync::watch::channel(None).1, + }; + + let initial = initial_frames(&target).await; + + let (tx, out_rx) = mpsc::channel::(OUT_CHANNEL_CAPACITY); + + // The heartbeat re-reads the CURRENT resolved target's lifecycle, so move + // its `setup` Arc into the forwarding task (re-assigned on active-switch). + let mut heartbeat_setup = target.setup.clone(); + let mut current_broadcaster = target.broadcaster.clone(); + let task_state = state.clone(); + tokio::spawn(async move { + for frame in initial { + if tx.send(frame).await.is_err() { + return; + } + } + + let mut heartbeat = interval(HEARTBEAT_INTERVAL); + heartbeat.tick().await; // first tick fires immediately — consume it + + loop { + tokio::select! { + event = rx.recv() => { + match event { + Ok(ev) => { + let frame = snapshot::frame(&ev.name, &ev.data); + if tx.send(frame).await.is_err() { + return; + } + } + // A slow consumer fell behind the broadcast channel's + // ring buffer — per `events/broadcaster.rs`'s module + // doc, resync by continuing to read forward rather + // than treating this as a fatal stream error. + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return, + } + } + _ = heartbeat.tick() => { + let frame = snapshot::frame( + "lifecycle", + &snapshot::lifecycle(heartbeat_setup.lifecycle_snapshot()), + ); + if tx.send(frame).await.is_err() { + return; + } + } + changed = active_rx.changed(), if explicit_handle.is_none() => { + if changed.is_err() { + // Manager dropped (shutdown) — keep serving the + // current subscription until the client goes away. + continue; + } + let now_active = active_rx.borrow_and_update().clone(); + let new_target = task_state.resolve_sandbox_target(now_active.as_deref()); + if std::sync::Arc::ptr_eq(&new_target.broadcaster, ¤t_broadcaster) { + continue; + } + current_broadcaster = new_target.broadcaster.clone(); + rx = new_target.broadcaster.subscribe(); + heartbeat_setup = new_target.setup.clone(); + for frame in initial_frames(&new_target).await { + if tx.send(frame).await.is_err() { + return; + } + } + } + changed = generation_rx.changed(), if explicit_handle.is_some() => { + if changed.is_err() { + continue; + } + let Some(sandbox) = generation_rx.borrow_and_update().clone() else { + // Stop publishes `None`. Keep the old subscription long + // enough to deliver its final idle snapshot; Resume will + // publish the replacement Arc on this same watch. + continue; + }; + let new_target = crate::sandbox::SandboxTarget::from_sandbox(&sandbox); + if std::sync::Arc::ptr_eq(&new_target.broadcaster, ¤t_broadcaster) { + continue; + } + current_broadcaster = new_target.broadcaster.clone(); + rx = new_target.broadcaster.subscribe(); + heartbeat_setup = new_target.setup.clone(); + for frame in initial_frames(&new_target).await { + if tx.send(frame).await.is_err() { + return; + } + } + } + } + } + }); + + let body_stream = stream::unfold(out_rx, |mut rx| async move { + rx.recv() + .await + .map(|frame| (Ok::<_, Infallible>(frame), rx)) + }); + + match Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/event-stream") + .header(header::CACHE_CONTROL, "no-cache") + .header(header::CONNECTION, "keep-alive") + .header("X-Accel-Buffering", "no") + .header(header::CONTENT_ENCODING, "identity") + .body(Body::from_stream(body_stream)) + { + Ok(res) => res, + Err(err) => { + tracing::error!(%err, "failed to build SSE response"); + crate::error::ApiError::internal("failed to build SSE response").into_response() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::StreamExt; + use std::path::Path; + use std::process::Command; + use std::sync::Arc; + + fn git(dir: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git failed to spawn"); + assert!( + output.status.success(), + "git {args:?} failed in {dir:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn init_repo(dir: &Path, branch: &str) { + std::fs::create_dir_all(dir).unwrap(); + git(dir, &["init", "-q", "-b", branch]); + git(dir, &["config", "user.name", "Test User"]); + git(dir, &["config", "user.email", "test@example.com"]); + std::fs::write(dir.join("README.md"), format!("{branch}\n")).unwrap(); + git(dir, &["add", "."]); + git(dir, &["commit", "-q", "-m", "initial"]); + } + + fn target_for_repo(root: &Path, repo_dir: &Path, name: &str) -> crate::sandbox::SandboxTarget { + let logs = Arc::new(crate::log_store::LogStore::new( + root.join(format!("{name}-logs")), + )); + let tasks = Arc::new(crate::tasks::TaskRegistry::new(logs)); + let config = Arc::new(crate::config::ConfigStore::new()); + let broadcaster = Arc::new(crate::events::Broadcaster::new()); + let setup = crate::setup::SetupOrchestrator::new( + repo_dir.to_path_buf(), + repo_dir.to_path_buf(), + config.clone(), + tasks.clone(), + broadcaster.clone(), + ); + crate::sandbox::SandboxTarget { + setup, + tasks, + broadcaster, + config, + repo_dir: repo_dir.to_path_buf(), + } + } + + async fn branch_frame(target: &crate::sandbox::SandboxTarget) -> String { + initial_frames(target) + .await + .into_iter() + .map(|frame| String::from_utf8(frame.to_vec()).unwrap()) + .find(|frame| frame.starts_with("event: branch\n")) + .expect("resolved repository has a branch snapshot") + } + + fn state_with_manager( + app_root: &std::path::Path, + manager: Arc, + ) -> AppState { + let repo_dir = app_root.join("global-repo"); + let logs = Arc::new(crate::log_store::LogStore::new( + app_root.join("global-logs"), + )); + let tasks = Arc::new(crate::tasks::TaskRegistry::new(logs)); + let config = Arc::new(crate::config::ConfigStore::new()); + let broadcaster = Arc::new(crate::events::Broadcaster::new()); + let setup = crate::setup::SetupOrchestrator::new( + repo_dir.clone(), + repo_dir.clone(), + config.clone(), + tasks.clone(), + broadcaster.clone(), + ); + AppState { + token: "test-token".into(), + boot_id: "test-boot".into(), + app_root: app_root.to_path_buf(), + repo_dir, + mode: crate::state::ApiMode::Strict, + config, + tasks, + broadcaster, + shutdown: Arc::new(crate::shutdown::ShutdownCoordinator::new()), + setup, + sandbox_manager: manager, + } + } + + #[tokio::test] + async fn initial_frames_replay_preexisting_files_with_an_empty_task_registry() { + let dir = tempfile::tempdir().unwrap(); + let repo_dir = dir.path().join("repo"); + let logs_root = dir.path().join("logs"); + tokio::fs::create_dir_all(logs_root.join("app")) + .await + .unwrap(); + tokio::fs::write(logs_root.join("app/setup"), "clone then install\n") + .await + .unwrap(); + tokio::fs::write(logs_root.join("app/dev"), "server ready\n") + .await + .unwrap(); + + let logs = Arc::new(crate::log_store::LogStore::new(logs_root)); + let tasks = Arc::new(crate::tasks::TaskRegistry::new(logs)); + assert!(tasks.list(None).is_empty(), "fixture models a fresh boot"); + let config = Arc::new(crate::config::ConfigStore::new()); + let broadcaster = Arc::new(crate::events::Broadcaster::new()); + let setup = crate::setup::SetupOrchestrator::new( + repo_dir.clone(), + repo_dir.clone(), + config.clone(), + tasks.clone(), + broadcaster.clone(), + ); + let target = crate::sandbox::SandboxTarget { + setup, + tasks, + broadcaster, + config, + repo_dir, + }; + + let replay: Vec = initial_frames(&target) + .await + .into_iter() + .map(|frame| String::from_utf8(frame.to_vec()).unwrap()) + .filter(|frame| frame.starts_with("event: log\n")) + .collect(); + + assert_eq!(replay.len(), 2); + assert!(replay[0].contains(r#""source":"dev""#)); + assert!(replay[0].contains(r#""data":"server ready\n""#)); + assert!(replay[1].contains(r#""source":"setup""#)); + assert!(replay[1].contains(r#""data":"clone then install\n""#)); + } + + #[tokio::test] + async fn explicit_stream_adopts_durable_metadata_and_replays_without_starting() { + let root = tempfile::tempdir().unwrap(); + let config = crate::sandbox::GitSandboxConfig { + virtual_mcp_id: "vmcp-replay-after-restart".to_string(), + clone_url: "https://example.invalid/repo.git".to_string(), + branch: Some("feature/replay".to_string()), + ..Default::default() + }; + let handle = crate::sandbox::SandboxManager::compute_handle( + &config.virtual_mcp_id, + config.branch.as_deref().unwrap(), + ); + let sandbox_root = root.path().join("sandboxes").join(&handle); + init_repo(&sandbox_root.join("repo"), "feature/replay"); + tokio::fs::create_dir_all(sandbox_root.join("logs/app")) + .await + .unwrap(); + tokio::fs::write( + sandbox_root.join("logs/app/setup"), + "retained before restart\n", + ) + .await + .unwrap(); + crate::sandbox::persist::write_sidecar(root.path(), &handle, &config); + + // Opening a fresh manager imports the legacy sidecar into SQLite but + // deliberately leaves its live object cache empty. + let manager = crate::sandbox::SandboxManager::new(root.path().to_path_buf()); + assert!(manager.get(&handle).is_none()); + let state = state_with_manager(root.path(), manager.clone()); + + let response = events( + State(state), + Query(EventsQuery { + handle: Some(handle.clone()), + }), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let adopted = manager.get(&handle).expect("metadata was adopted"); + assert!( + adopted.tasks.list(None).is_empty(), + "observing retained logs must not spawn setup/dev tasks" + ); + + let mut body = response.into_body().into_data_stream(); + let (branch, replay) = tokio::time::timeout(Duration::from_secs(1), async { + let mut branch = None; + let mut replay = None; + loop { + let chunk = body + .next() + .await + .expect("SSE remains open") + .expect("SSE body chunk"); + let frame = String::from_utf8(chunk.to_vec()).unwrap(); + if frame.starts_with("event: branch\n") { + branch = Some(frame); + } else if frame.starts_with("event: log\n") { + replay = Some(frame); + } + if let (Some(branch), Some(replay)) = (branch.clone(), replay.clone()) { + break (branch, replay); + } + } + }) + .await + .expect("branch and retained log replay arrived"); + assert!(branch.contains(r#""branch":"feature/replay""#)); + assert!(replay.contains(r#""source":"setup""#)); + assert!(replay.contains(r#""data":"retained before restart\n""#)); + } + + #[tokio::test] + async fn initial_branch_snapshot_is_scoped_to_each_resolved_target() { + let root = tempfile::tempdir().unwrap(); + let main_repo = root.path().join("main-repo"); + let feature_repo = root.path().join("feature-repo"); + init_repo(&main_repo, "main"); + init_repo(&feature_repo, "feature/two"); + + let main = target_for_repo(root.path(), &main_repo, "main"); + let feature = target_for_repo(root.path(), &feature_repo, "feature"); + + let main_frame = branch_frame(&main).await; + let feature_frame = branch_frame(&feature).await; + assert!(main_frame.contains(r#""branch":"main""#)); + assert!(!main_frame.contains(r#""branch":"feature/two""#)); + assert!(feature_frame.contains(r#""branch":"feature/two""#)); + assert!(!feature_frame.contains(r#""branch":"main""#)); + } +} diff --git a/apps/native/crates/local-api/src/routes/fs.rs b/apps/native/crates/local-api/src/routes/fs.rs new file mode 100644 index 0000000000..91fbfb4751 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/fs.rs @@ -0,0 +1,1503 @@ +//! Filesystem routes — `POST /_sandbox/{read,write,unlink,mkdir,rename, +//! edit,grep,glob,write_from_url,upload_to_url,tools/sync}` (grouped here +//! because the daemon's own `fsH` dispatch table does the same, see +//! `entry.ts`). +//! +//! Byte-parity target: `packages/sandbox/daemon/routes/fs.ts` + +//! `paths.ts::safePath` + `routes/tools.ts` + `tools-catalog.ts`. Oracle: +//! `daemon.e2e.test.ts`'s `fs` describe block + `daemon.tools.e2e.test.ts`. +//! +//! Pure logic lives in submodules (each with co-located `#[cfg(test)]` +//! unit tests) so it's diffable against its TS counterpart and testable +//! without spinning up the HTTP server: +//! - `fs::safe_path` — `paths.ts::safePath` / `resolveReadPath` +//! - `fs::glob_logic` — the glob-handler helper functions in `fs.ts` +//! - `fs::git_exclude` — `git-exclude.ts::ensureGitExclude` +//! - `fs::tools_catalog` — `tools-catalog.ts` (catalog file shape, prune) +//! - `fs::mcp_client` — a minimal hand-rolled MCP Streamable HTTP client +//! (no MCP SDK crate exists in this workspace — see that module's doc) + +mod git_exclude; +mod glob_logic; +mod mcp_client; +mod safe_path; +mod tools_catalog; +mod tools_transaction; + +use std::collections::HashSet; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::OnceLock; +use std::time::Duration; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use futures_util::FutureExt; +use futures_util::StreamExt; +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::process::Command; +use tokio::sync::{oneshot, Mutex}; + +use crate::error::ApiError; +use crate::mutation::{MutationCancellation, MutationOwnerError}; +use crate::process_group::ProcessGroupChild; +use crate::state::AppState; +use crate::tasks::{ + now_ms, KillHandle, KillSignal, ProcessController, TaskEntry, TaskStatus, TaskSummary, +}; + +use glob_logic::{ + collect_empty_directories, repo_relative_prefix, resolve_glob_result_limit, scan_glob, + walk_repo_within_max_depth, GlobScanState, +}; +use safe_path::{resolve_read_path, safe_path}; + +/// Cap on bytes returned for image responses. ~5MB matches Anthropic's +/// vision input ceiling and keeps tool result payloads bounded. +const MAX_IMAGE_BYTES: u64 = 5 * 1024 * 1024; +/// Cap on bytes for write_from_url / upload_to_url. +const MAX_TRANSFER_BYTES: u64 = 500 * 1024 * 1024; +/// Wall-clock cap for fetches in write_from_url / upload_to_url. +const TRANSFER_DEADLINE: Duration = Duration::from_secs(5 * 60); + +static TOOLS_CATALOG_COMMIT_LOCK: OnceLock> = OnceLock::new(); + +fn shutting_down() -> ApiError { + ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "application is shutting down", + ) +} + +async fn run_commit_owned(state: &AppState, operation: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> Fut + Send + 'static, + Fut: Future> + Send + 'static, +{ + let mutations = state.shutdown.mutations(); + match mutations.run_commit(operation).await { + Ok(result) => result, + Err(error) => Err(map_owner_error(error)), + } +} + +fn map_owner_error(error: MutationOwnerError) -> ApiError { + match error { + MutationOwnerError::ShuttingDown => shutting_down(), + MutationOwnerError::Panicked | MutationOwnerError::Stopped => { + tracing::error!(?error, "filesystem mutation owner failed"); + ApiError::internal("filesystem mutation owner failed") + } + } +} + +async fn new_stage_dir(app_root: &Path) -> Result { + let dir = app_root + .join(".decocms") + .join("mutations") + .join(uuid::Uuid::new_v4().to_string()); + tokio::fs::create_dir_all(&dir) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + } + Ok(dir) +} + +pub(crate) async fn recover_mutation_stages( + mutation_root: &Path, + repo_dir: &Path, +) -> std::io::Result<()> { + tools_transaction::recover_mutation_stages( + mutation_root, + &repo_dir.join(tools_catalog::CATALOG_DIR), + ) + .await +} + +async fn remove_staged_path(path: &Path) { + let metadata = match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + Err(_) => return, + }; + if metadata.is_dir() { + let _ = tokio::fs::remove_dir_all(path).await; + } else { + let _ = tokio::fs::remove_file(path).await; + } +} + +async fn stage_bytes(app_root: &Path, bytes: &[u8]) -> Result<(PathBuf, PathBuf), ApiError> { + let stage_dir = new_stage_dir(app_root).await?; + let staged = stage_dir.join("payload"); + if let Err(error) = tokio::fs::write(&staged, bytes).await { + remove_staged_path(&stage_dir).await; + return Err(ApiError::internal(error.to_string())); + } + Ok((stage_dir, staged)) +} + +async fn preserve_existing_permissions(staged: &Path, target: &Path) -> Result<(), ApiError> { + let Ok(metadata) = tokio::fs::metadata(target).await else { + return Ok(()); + }; + tokio::fs::set_permissions(staged, metadata.permissions()) + .await + .map_err(|error| ApiError::internal(error.to_string())) +} + +async fn commit_staged_file_owned( + state: &AppState, + staged: PathBuf, + target: PathBuf, +) -> Result<(), ApiError> { + run_commit_owned(state, move || async move { + preserve_existing_permissions(&staged, &target).await?; + if let Some(parent) = target.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + } + tokio::fs::rename(staged, target) + .await + .map_err(|error| ApiError::internal(error.to_string())) + }) + .await +} + +fn parse_body(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Ok(T::default()); + } + serde_json::from_slice(bytes) + .map_err(|e| ApiError::bad_request(format!("Failed to parse body: {e}"))) +} + +// --- read -------------------------------------------------------------------- + +#[derive(Deserialize, Default)] +struct ReadBody { + path: Option, + offset: Option, + limit: Option, + full: Option, +} + +/// Magic-byte sniffer for the image types Claude vision accepts. Returns +/// `None` for everything else — this doesn't try to be clever about +/// arbitrary binary formats. +fn sniff_image_media_type(probe: &[u8]) -> Option<&'static str> { + if probe.len() >= 3 && probe[0..3] == [0xff, 0xd8, 0xff] { + return Some("image/jpeg"); + } + if probe.len() >= 8 && probe[0..8] == [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] { + return Some("image/png"); + } + if probe.len() >= 6 && probe[0..4] == [0x47, 0x49, 0x46, 0x38] { + return Some("image/gif"); + } + if probe.len() >= 12 + && probe[0..4] == [0x52, 0x49, 0x46, 0x46] + && probe[8..12] == [0x57, 0x45, 0x42, 0x50] + { + return Some("image/webp"); + } + None +} + +const BASE64_ALPHABET: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Standard (padded) base64 — no `base64` crate in this workspace's +/// dependency table (see `mcp_client.rs`'s doc comment for the "shared +/// Cargo.toml, don't add deps" constraint); this is a self-contained ~15 +/// line encoder, unit-tested against known vectors below. +fn base64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b0 = chunk[0]; + let b1 = *chunk.get(1).unwrap_or(&0); + let b2 = *chunk.get(2).unwrap_or(&0); + out.push(BASE64_ALPHABET[(b0 >> 2) as usize] as char); + out.push(BASE64_ALPHABET[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); + out.push(if chunk.len() > 1 { + BASE64_ALPHABET[(((b1 & 0x0f) << 2) | (b2 >> 6)) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + BASE64_ALPHABET[(b2 & 0x3f) as usize] as char + } else { + '=' + }); + } + out +} + +pub async fn read(State(state): State, body: Bytes) -> Response { + let body: ReadBody = match parse_body(&body) { + Ok(b) => b, + Err(e) => return e.into_response(), + }; + let user_path = body.path.unwrap_or_default(); + let Some(file_path) = resolve_read_path(&state.app_root, &state.repo_dir, &user_path) else { + return ApiError::bad_request("Path escapes project root").into_response(); + }; + let meta = match tokio::fs::metadata(&file_path).await { + Ok(m) => m, + Err(_) => { + return ApiError::bad_request(format!("File not found: {user_path}")).into_response() + } + }; + if meta.is_dir() { + return ApiError::bad_request("Path is a directory").into_response(); + } + let data = match tokio::fs::read(&file_path).await { + Ok(d) => d, + Err(e) => return ApiError::internal(e.to_string()).into_response(), + }; + let probe = &data[..data.len().min(8192)]; + + if let Some(media_type) = sniff_image_media_type(probe) { + if data.len() as u64 > MAX_IMAGE_BYTES { + return ApiError::bad_request(format!( + "Image too large ({} bytes; cap is {MAX_IMAGE_BYTES})", + data.len() + )) + .into_response(); + } + return Json(json!({ + "kind": "image", + "mediaType": media_type, + "size": data.len(), + "base64": base64_encode(&data), + })) + .into_response(); + } + + if probe.contains(&0u8) { + return ApiError::bad_request( + "File appears to be binary and is not a supported image format (jpeg/png/gif/webp).", + ) + .into_response(); + } + + let text = String::from_utf8_lossy(&data); + let lines: Vec<&str> = text.split('\n').collect(); + let full = body.full.unwrap_or(false); + let offset: usize = if full { + 1 + } else { + body.offset.unwrap_or(1).max(1) as usize + }; + let limit: usize = if full { + lines.len() + } else { + body.limit.unwrap_or(2000).max(0) as usize + }; + let start = offset.saturating_sub(1).min(lines.len()); + let end = start.saturating_add(limit).min(lines.len()); + let slice = &lines[start..end]; + let numbered = slice + .iter() + .enumerate() + .map(|(i, l)| format!("{}\t{l}", offset + i)) + .collect::>() + .join("\n"); + Json(json!({ "kind": "text", "content": numbered, "lineCount": lines.len() })).into_response() +} + +// --- write ------------------------------------------------------------------- + +#[derive(Deserialize, Default)] +struct WriteBody { + path: Option, + content: Option, +} + +pub async fn write(State(state): State, body: Bytes) -> Response { + let body: WriteBody = match parse_body(&body) { + Ok(b) => b, + Err(e) => return e.into_response(), + }; + let Some(content) = body.content else { + return ApiError::bad_request("content is required").into_response(); + }; + let user_path = body.path.unwrap_or_default(); + let Some(file_path) = safe_path(&state.app_root, &state.repo_dir, &user_path) else { + return ApiError::bad_request("Path escapes app root").into_response(); + }; + let (stage_dir, staged) = match stage_bytes(&state.app_root, content.as_bytes()).await { + Ok(staged) => staged, + Err(error) => return error.into_response(), + }; + if let Err(error) = commit_staged_file_owned(&state, staged, file_path).await { + remove_staged_path(&stage_dir).await; + return error.into_response(); + } + remove_staged_path(&stage_dir).await; + state + .broadcaster + .emit("file-changed", json!({ "path": user_path })); + Json(json!({ "ok": true, "bytesWritten": content.len() })).into_response() +} + +// --- unlink ------------------------------------------------------------------ + +#[derive(Deserialize, Default)] +struct UnlinkBody { + path: Option, + recursive: Option, +} + +/// Paths that must not be deleted via the sandbox unlink API. +fn assert_unlink_allowed(normalized: &str, recursive: bool) -> Option<&'static str> { + if normalized.is_empty() || normalized.contains("..") { + return Some("Invalid path"); + } + if recursive && normalized == "." { + return Some("Refusing to recursively delete the repository root"); + } + let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect(); + if segments.contains(&".git") { + return Some("Refusing to delete .git"); + } + None +} + +pub async fn unlink(State(state): State, body: Bytes) -> Response { + let body: UnlinkBody = match parse_body(&body) { + Ok(b) => b, + Err(e) => return e.into_response(), + }; + let raw_path = match body.path.as_deref() { + Some(p) if !p.is_empty() => p.to_string(), + _ => return ApiError::bad_request("path is required").into_response(), + }; + let recursive = body.recursive.unwrap_or(false); + let normalized = raw_path.replace('\\', "/"); + if let Some(msg) = assert_unlink_allowed(&normalized, recursive) { + return ApiError::bad_request(msg).into_response(); + } + let Some(file_path) = safe_path(&state.app_root, &state.repo_dir, &raw_path) else { + return ApiError::bad_request("Path escapes app root").into_response(); + }; + let trash_dir = if recursive { + match new_stage_dir(&state.app_root).await { + Ok(dir) => Some(dir), + Err(error) => return error.into_response(), + } + } else { + None + }; + let trash_for_commit = trash_dir.clone(); + let (existed, trashed) = match run_commit_owned(&state, move || async move { + match tokio::fs::symlink_metadata(&file_path).await { + Ok(meta) => { + if meta.is_dir() && !recursive { + return Err(ApiError::bad_request( + "Refusing to unlink directory without recursive: true", + )); + } + if recursive { + let trash_path = trash_for_commit.unwrap().join("deleted"); + tokio::fs::rename(&file_path, &trash_path) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + Ok((true, Some(trash_path))) + } else { + tokio::fs::remove_file(&file_path) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + Ok((true, None)) + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok((false, None)), + Err(error) => Err(ApiError::internal(error.to_string())), + } + }) + .await + { + Ok(result) => result, + Err(error) => { + if let Some(trash_dir) = &trash_dir { + remove_staged_path(trash_dir).await; + } + return error.into_response(); + } + }; + if let Some(trashed) = &trashed { + remove_staged_path(trashed).await; + } + if let Some(trash_dir) = &trash_dir { + remove_staged_path(trash_dir).await; + } + if existed { + state + .broadcaster + .emit("file-changed", json!({ "path": raw_path })); + } + Json(json!({ "ok": true, "existed": existed })).into_response() +} + +// --- mkdir ------------------------------------------------------------------- + +#[derive(Deserialize, Default)] +struct MkdirBody { + path: Option, +} + +pub async fn mkdir(State(state): State, body: Bytes) -> Response { + let body: MkdirBody = match parse_body(&body) { + Ok(b) => b, + Err(e) => return e.into_response(), + }; + let raw_path = match body.path.as_deref() { + Some(p) if !p.is_empty() => p.to_string(), + _ => return ApiError::bad_request("path is required").into_response(), + }; + let normalized = raw_path.replace('\\', "/"); + if normalized.is_empty() || normalized.contains("..") { + return ApiError::bad_request("Invalid path").into_response(); + } + let Some(dir_path) = safe_path(&state.app_root, &state.repo_dir, &raw_path) else { + return ApiError::bad_request("Path escapes app root").into_response(); + }; + if let Err(error) = run_commit_owned(&state, move || async move { + tokio::fs::create_dir_all(&dir_path) + .await + .map_err(|error| ApiError::internal(error.to_string())) + }) + .await + { + return error.into_response(); + } + state + .broadcaster + .emit("file-changed", json!({ "path": raw_path })); + Json(json!({ "ok": true })).into_response() +} + +// --- rename ------------------------------------------------------------------ + +#[derive(Deserialize, Default)] +struct RenameBody { + from: Option, + to: Option, +} + +pub async fn rename(State(state): State, body: Bytes) -> Response { + let body: RenameBody = match parse_body(&body) { + Ok(b) => b, + Err(e) => return e.into_response(), + }; + let raw_from = match body.from.as_deref() { + Some(p) if !p.is_empty() => p.to_string(), + _ => return ApiError::bad_request("from is required").into_response(), + }; + let raw_to = match body.to.as_deref() { + Some(p) if !p.is_empty() => p.to_string(), + _ => return ApiError::bad_request("to is required").into_response(), + }; + let from_normalized = raw_from.replace('\\', "/"); + let to_normalized = raw_to.replace('\\', "/"); + if from_normalized.contains("..") || to_normalized.contains("..") { + return ApiError::bad_request("Invalid path").into_response(); + } + let Some(from_path) = safe_path(&state.app_root, &state.repo_dir, &raw_from) else { + return ApiError::bad_request("Path escapes app root").into_response(); + }; + let Some(to_path) = safe_path(&state.app_root, &state.repo_dir, &raw_to) else { + return ApiError::bad_request("Path escapes app root").into_response(); + }; + let from_label = raw_from.clone(); + let to_label = raw_to.clone(); + if let Err(error) = run_commit_owned(&state, move || async move { + if tokio::fs::metadata(&from_path).await.is_err() { + return Err(ApiError::bad_request(format!( + "Path not found: {from_label}" + ))); + } + match tokio::fs::metadata(&to_path).await { + Ok(_) => { + return Err(ApiError::bad_request(format!( + "Path already exists: {to_label}" + ))) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(ApiError::internal(error.to_string())), + } + if let Some(parent) = to_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + } + tokio::fs::rename(&from_path, &to_path) + .await + .map_err(|error| ApiError::internal(error.to_string())) + }) + .await + { + return error.into_response(); + } + state + .broadcaster + .emit("file-changed", json!({ "path": raw_to })); + Json(json!({ "ok": true })).into_response() +} + +// --- edit -------------------------------------------------------------------- + +#[derive(Deserialize, Default)] +struct EditBody { + path: Option, + old_string: Option, + new_string: Option, + replace_all: Option, +} + +pub async fn edit(State(state): State, body: Bytes) -> Response { + let body: EditBody = match parse_body(&body) { + Ok(b) => b, + Err(e) => return e.into_response(), + }; + let user_path = body.path.clone().unwrap_or_default(); + let Some(file_path) = safe_path(&state.app_root, &state.repo_dir, &user_path) else { + return ApiError::bad_request("Path escapes app root").into_response(); + }; + let old_string = match body.old_string.as_deref() { + Some(s) if !s.is_empty() => s.to_string(), + _ => return ApiError::bad_request("old_string is required").into_response(), + }; + let Some(new_string) = body.new_string else { + return ApiError::bad_request("new_string is required").into_response(); + }; + if old_string == new_string { + return ApiError::bad_request("old_string and new_string must differ").into_response(); + } + let content = match tokio::fs::read_to_string(&file_path).await { + Ok(c) => c, + Err(_) => { + return ApiError::bad_request(format!("File not found: {user_path}")).into_response() + } + }; + let replace_all = body.replace_all.unwrap_or(false); + let count = content.matches(old_string.as_str()).count(); + if count == 0 { + return ApiError::bad_request("old_string not found in file").into_response(); + } + if !replace_all && count > 1 { + return ApiError::bad_request(format!( + "old_string found {count} times. Use replace_all or provide more context to make it unique." + )) + .into_response(); + } + let updated = if replace_all { + content.replace(old_string.as_str(), &new_string) + } else { + content.replacen(old_string.as_str(), &new_string, 1) + }; + let (stage_dir, staged) = match stage_bytes(&state.app_root, updated.as_bytes()).await { + Ok(staged) => staged, + Err(error) => return error.into_response(), + }; + if let Err(error) = commit_staged_file_owned(&state, staged, file_path).await { + remove_staged_path(&stage_dir).await; + return error.into_response(); + } + remove_staged_path(&stage_dir).await; + state + .broadcaster + .emit("file-changed", json!({ "path": user_path })); + Json(json!({ + "ok": true, + "replacements": if replace_all { count } else { 1 }, + "content": updated, + })) + .into_response() +} + +// --- grep -------------------------------------------------------------------- + +struct GrepCancelOnDrop { + kill: Option, +} + +impl GrepCancelOnDrop { + fn new(kill: KillHandle) -> Self { + Self { kill: Some(kill) } + } + + fn disarm(&mut self) { + self.kill = None; + } +} + +impl Drop for GrepCancelOnDrop { + fn drop(&mut self) { + if let Some(kill) = self.kill.take() { + // `rg` has no graceful-shutdown protocol. Use KILL on request + // cancellation so an aborted HTTP future cannot leave an internal + // search alive; app shutdown still uses the registry's bounded + // TERM -> KILL policy. + let _ = kill(KillSignal::Kill); + } + } +} + +#[cfg(unix)] +fn rg_exit_code(status: std::process::ExitStatus) -> i32 { + use std::os::unix::process::ExitStatusExt; + status + .signal() + .map(|signal| 128 + signal) + .unwrap_or_else(|| status.code().unwrap_or(1)) +} + +#[cfg(not(unix))] +fn rg_exit_code(status: std::process::ExitStatus) -> i32 { + status.code().unwrap_or(1) +} + +async fn drive_owned_rg( + child: &mut ProcessGroupChild, + controller: ProcessController, +) -> std::io::Result { + let mut stdout = child + .take_stdout() + .ok_or_else(|| std::io::Error::other("spawn error: missing grep stdout pipe"))?; + let mut stderr = child + .take_stderr() + .ok_or_else(|| std::io::Error::other("spawn error: missing grep stderr pipe"))?; + let mut stdout_bytes = Vec::new(); + let mut stderr_bytes = Vec::new(); + let mut stdout_open = true; + let mut stderr_open = true; + let mut exit_status = None; + let mut exited = false; + let mut observed_signal = None; + let mut stdout_chunk = [0u8; 8192]; + let mut stderr_chunk = [0u8; 8192]; + + loop { + if exited && !stdout_open && !stderr_open { + break; + } + tokio::select! { + read = stdout.read(&mut stdout_chunk), if stdout_open => { + match read { + Ok(0) | Err(_) => stdout_open = false, + Ok(n) => stdout_bytes.extend_from_slice(&stdout_chunk[..n]), + } + } + read = stderr.read(&mut stderr_chunk), if stderr_open => { + match read { + Ok(0) | Err(_) => stderr_open = false, + Ok(n) => stderr_bytes.extend_from_slice(&stderr_chunk[..n]), + } + } + status = child.wait(), if !exited && !stdout_open && !stderr_open => { + exited = true; + exit_status = Some(status?); + } + signal = controller.wait_for_change(observed_signal), if !exited => { + observed_signal = Some(signal); + child.signal(signal).await; + } + } + } + + Ok(std::process::Output { + status: exit_status.ok_or_else(|| std::io::Error::other("grep exited without status"))?, + stdout: stdout_bytes, + stderr: stderr_bytes, + }) +} + +async fn run_owned_rg(state: &AppState, args: &[String]) -> Result { + let Some(admission) = state.shutdown.admit_work().await else { + return Err(ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "application is shutting down", + )); + }; + + let mut command = Command::new("rg"); + command + .args(args) + .current_dir(&state.repo_dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = + ProcessGroupChild::spawn(&mut command, state.tasks.child_lifetime_lock_path()) + .await + .map_err(|error| { + ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + format!( + "grep unavailable: {error}. Install ripgrep (\"brew install ripgrep\") or use bash + grep." + ), + ) + })?; + // Grep was never a task-list surface; keep it fully invisible, including + // not advancing the public background-task id counter. + let id = format!("internal-grep-{}", uuid::Uuid::new_v4()); + let controller = ProcessController::new(); + let kill_handle = controller.kill_handle(); + state.tasks.insert(TaskEntry::new_internal( + TaskSummary { + id: id.clone(), + command: format!("rg {}", args.join(" ")), + status: TaskStatus::Running, + exit_code: None, + started_at: now_ms(), + finished_at: None, + timed_out: false, + truncated: false, + log_name: None, + intentional: None, + }, + Some(kill_handle.clone()), + )); + + let tasks = state.tasks.clone(); + let owner_id = id.clone(); + let (result_tx, result_rx) = oneshot::channel(); + tokio::spawn(async move { + let driven = std::panic::AssertUnwindSafe(drive_owned_rg(&mut child, controller)) + .catch_unwind() + .await; + let output = match driven { + Ok(output) => output, + Err(_) => Err(std::io::Error::other("grep process owner panicked")), + }; + if output.is_err() { + child + .kill_and_reap(Duration::from_secs(2), "grep process-owner error cleanup") + .await; + } + let (status, exit_code) = match &output { + Ok(output) => { + let exit_code = rg_exit_code(output.status); + let status = if exit_code > 128 { + TaskStatus::Killed + } else { + TaskStatus::Exited + }; + (status, exit_code) + } + Err(_) => (TaskStatus::Failed, -1), + }; + tasks.finalize(&owner_id, status, exit_code, false); + let _ = tasks.remove(&owner_id).await; + let _ = result_tx.send(output); + }); + drop(admission); + + let mut cancel_on_drop = GrepCancelOnDrop::new(kill_handle); + let output = result_rx + .await + .map_err(|_| ApiError::internal("grep process owner stopped unexpectedly"))? + .map_err(|error| ApiError::internal(format!("rg failed: {error}")))?; + cancel_on_drop.disarm(); + Ok(output) +} + +#[derive(Deserialize, Default)] +struct GrepBody { + pattern: Option, + path: Option, + output_mode: Option, + ignore_case: Option, + context: Option, + glob: Option, + limit: Option, +} + +pub async fn grep(State(state): State, body: Bytes) -> Response { + let body: GrepBody = match parse_body(&body) { + Ok(b) => b, + Err(e) => return e.into_response(), + }; + let Some(pattern) = body.pattern.filter(|p| !p.is_empty()) else { + return ApiError::bad_request("pattern is required").into_response(); + }; + let search_path: PathBuf = match &body.path { + Some(p) => match safe_path(&state.app_root, &state.repo_dir, p) { + Some(sp) => sp, + None => return ApiError::bad_request("Path escapes app root").into_response(), + }, + None => state.repo_dir.clone(), + }; + + let mode = body.output_mode.as_deref().unwrap_or("files"); + let mut args: Vec = Vec::new(); + match mode { + "files" => args.push("--files-with-matches".to_string()), + "count" => args.push("--count".to_string()), + _ => args.push("--line-number".to_string()), + } + if body.ignore_case.unwrap_or(false) { + args.push("-i".to_string()); + } + if let Some(ctx) = body.context { + if mode == "content" { + args.push("-C".to_string()); + args.push(ctx.to_string()); + } + } + if let Some(g) = &body.glob { + args.push("--glob".to_string()); + args.push(g.clone()); + } + args.push("--".to_string()); + args.push(pattern); + args.push(search_path.to_string_lossy().to_string()); + + let limit = body.limit.unwrap_or(250).max(0) as usize; + let output = match run_owned_rg(&state, &args).await { + Ok(output) => output, + Err(error) => return error.into_response(), + }; + if let Some(code) = output.status.code() { + if code > 1 { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let msg = if stderr.is_empty() { + format!("rg failed with code {code}") + } else { + stderr + }; + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, msg).into_response(); + } + } + let stdout = String::from_utf8_lossy(&output.stdout); + let mut lines_out: Vec<&str> = Vec::new(); + for line in stdout.split('\n') { + if lines_out.len() >= limit { + break; + } + if !line.is_empty() { + lines_out.push(line); + } + } + let results = lines_out.join("\n"); + Json(json!({ "results": results, "matchCount": lines_out.len() })).into_response() +} + +// --- glob -------------------------------------------------------------------- + +#[derive(Deserialize, Default)] +struct GlobBody { + pattern: Option, + path: Option, + limit: Option, + #[serde(rename = "maxDepth")] + max_depth: Option, +} + +pub async fn glob(State(state): State, body: Bytes) -> Response { + let body: GlobBody = match parse_body(&body) { + Ok(b) => b, + Err(e) => return e.into_response(), + }; + let Some(pattern) = body.pattern.filter(|p| !p.is_empty()) else { + return ApiError::bad_request("pattern is required").into_response(); + }; + let search_path: PathBuf = match &body.path { + Some(p) => match safe_path(&state.app_root, &state.repo_dir, p) { + Some(sp) => sp, + None => return ApiError::bad_request("Path escapes app root").into_response(), + }, + None => state.repo_dir.clone(), + }; + let result_limit = resolve_glob_result_limit(body.limit); + let max_depth = body.max_depth.map(|d| (d.max(1.0).floor()) as usize); + + let (file_paths, directory_paths, truncated) = if let Some(md) = max_depth { + if pattern == "**/*" { + let mut scan_state = GlobScanState { + file_paths: Vec::new(), + directory_paths: HashSet::new(), + result_limit, + }; + let prefix = repo_relative_prefix(&search_path, &state.repo_dir); + let truncated = walk_repo_within_max_depth(&search_path, &prefix, md, &mut scan_state); + (scan_state.file_paths, scan_state.directory_paths, truncated) + } else { + match scan_glob( + &search_path, + &state.repo_dir, + &pattern, + Some(md), + result_limit, + ) { + Ok(m) => (m.file_paths, m.directory_paths, m.truncated), + Err(e) => return ApiError::internal(e.to_string()).into_response(), + } + } + } else { + match scan_glob(&search_path, &state.repo_dir, &pattern, None, result_limit) { + Ok(m) => (m.file_paths, m.directory_paths, m.truncated), + Err(e) => return ApiError::internal(e.to_string()).into_response(), + } + }; + + let dirs_vec: Vec = directory_paths.into_iter().collect(); + let empty_dirs = collect_empty_directories(&file_paths, &dirs_vec); + let mut out = json!({ "files": file_paths, "directories": empty_dirs }); + if truncated { + out["truncated"] = json!(true); + } + Json(out).into_response() +} + +// --- write_from_url / upload_to_url ------------------------------------------ + +#[derive(Deserialize, Default)] +struct WriteFromUrlBody { + path: Option, + url: Option, +} + +fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(TRANSFER_DEADLINE) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +pub async fn write_from_url(State(state): State, body: Bytes) -> Response { + let body: WriteFromUrlBody = match parse_body(&body) { + Ok(b) => b, + Err(e) => return e.into_response(), + }; + let Some(url) = body.url.filter(|u| !u.is_empty()) else { + return ApiError::bad_request("url is required").into_response(); + }; + let user_path = body.path.clone().unwrap_or_default(); + let Some(file_path) = safe_path(&state.app_root, &state.repo_dir, &user_path) else { + return ApiError::bad_request("Path escapes app root").into_response(); + }; + + let mutations = state.shutdown.mutations(); + let result = mutations + .run_owned(move |cancellation| async move { + write_from_url_owned(state, user_path, file_path, url, cancellation).await + }) + .await; + match result { + Ok(Ok(value)) => Json(value).into_response(), + Ok(Err(error)) => error.into_response(), + Err(error) => map_owner_error(error).into_response(), + } +} + +async fn write_from_url_owned( + state: AppState, + user_path: String, + file_path: PathBuf, + url: String, + mut cancellation: MutationCancellation, +) -> Result { + let stage_dir = new_stage_dir(&state.app_root).await?; + let staged = stage_dir.join("download"); + let prepared = async { + let client = http_client(); + let request = tokio::time::timeout(TRANSFER_DEADLINE, client.get(&url).send()); + let resp = tokio::select! { + _ = cancellation.cancelled() => return Err(shutting_down()), + result = request => match result { + Ok(Ok(response)) => response, + Ok(Err(error)) => return Err(ApiError::bad_gateway(format!("fetch failed: {error}"))), + Err(_) => return Err(ApiError::bad_gateway(format!( + "fetch deadline exceeded ({}ms)", + TRANSFER_DEADLINE.as_millis() + ))), + } + }; + if !resp.status().is_success() { + return Err(ApiError::bad_gateway(format!( + "upstream returned HTTP {}", + resp.status() + ))); + } + if let Some(len) = resp.content_length() { + if len > MAX_TRANSFER_BYTES { + return Err(ApiError::payload_too_large(format!( + "Payload too large ({len} > {MAX_TRANSFER_BYTES})" + ))); + } + } + + let mut out_file = tokio::fs::File::create(&staged) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + let mut written: u64 = 0; + let mut stream = resp.bytes_stream(); + loop { + let next = tokio::select! { + _ = cancellation.cancelled() => return Err(shutting_down()), + result = tokio::time::timeout(TRANSFER_DEADLINE, stream.next()) => result, + }; + let chunk = match next { + Ok(Some(Ok(chunk))) => chunk, + Ok(Some(Err(error))) => { + return Err(ApiError::bad_gateway(format!("stream failed: {error}"))) + } + Ok(None) => break, + Err(_) => { + return Err(ApiError::bad_gateway(format!( + "fetch deadline exceeded ({}ms)", + TRANSFER_DEADLINE.as_millis() + ))) + } + }; + written += chunk.len() as u64; + if written > MAX_TRANSFER_BYTES { + return Err(ApiError::bad_gateway(format!( + "Stream exceeded {MAX_TRANSFER_BYTES} bytes" + ))); + } + out_file + .write_all(&chunk) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + } + out_file + .flush() + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + drop(out_file); + Ok(written) + } + .await; + + let written = match prepared { + Ok(written) => written, + Err(error) => { + remove_staged_path(&stage_dir).await; + return Err(error); + } + }; + if let Err(error) = commit_staged_file_owned(&state, staged, file_path).await { + remove_staged_path(&stage_dir).await; + return Err(error); + } + remove_staged_path(&stage_dir).await; + Ok(json!({ "ok": true, "path": user_path, "size": written })) +} + +#[derive(Deserialize, Default)] +struct UploadToUrlBody { + path: Option, + url: Option, + #[serde(rename = "contentType")] + content_type: Option, +} + +pub async fn upload_to_url(State(state): State, body: Bytes) -> Response { + let body: UploadToUrlBody = match parse_body(&body) { + Ok(b) => b, + Err(e) => return e.into_response(), + }; + let Some(url) = body.url.filter(|u| !u.is_empty()) else { + return ApiError::bad_request("url is required").into_response(); + }; + let user_path = body.path.clone().unwrap_or_default(); + let Some(file_path) = resolve_read_path(&state.app_root, &state.repo_dir, &user_path) else { + return ApiError::bad_request("Path escapes project root").into_response(); + }; + let meta = match tokio::fs::metadata(&file_path).await { + Ok(m) => m, + Err(_) => { + return ApiError::bad_request(format!("File not found: {user_path}")).into_response() + } + }; + if meta.is_dir() { + return ApiError::bad_request("Path is a directory").into_response(); + } + if meta.len() > MAX_TRANSFER_BYTES { + return ApiError::payload_too_large(format!( + "File too large ({} > {MAX_TRANSFER_BYTES})", + meta.len() + )) + .into_response(); + } + + let data = match tokio::fs::read(&file_path).await { + Ok(d) => d, + Err(e) => return ApiError::internal(e.to_string()).into_response(), + }; + let client = http_client(); + let mut req = client + .put(&url) + .header("Content-Length", data.len().to_string()) + .body(data.clone()); + if let Some(ct) = &body.content_type { + req = req.header("Content-Type", ct.clone()); + } + let resp = match tokio::time::timeout(TRANSFER_DEADLINE, req.send()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => return ApiError::bad_gateway(format!("upload failed: {e}")).into_response(), + Err(_) => { + return ApiError::bad_gateway(format!( + "upload deadline exceeded ({}ms)", + TRANSFER_DEADLINE.as_millis() + )) + .into_response() + } + }; + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + let snippet: String = text.chars().take(500).collect(); + return ApiError::bad_gateway(format!("upstream returned HTTP {status}: {snippet}")) + .into_response(); + } + Json(json!({ "ok": true, "size": data.len() })).into_response() +} + +// --- tools/sync ---------------------------------------------------------------- + +#[derive(Deserialize, Default)] +struct ToolsSyncBody { + url: Option, + headers: Option, + #[serde(rename = "expiresAt")] + expires_at: Option, +} + +fn endpoint_content(endpoint: &tools_catalog::McpEndpoint) -> Result { + let mut body = serde_json::Map::new(); + body.insert("url".to_string(), Value::String(endpoint.url.clone())); + body.insert("headers".to_string(), endpoint.headers.clone()); + if let Some(expires_at) = endpoint.expires_at { + if expires_at != 0.0 { + body.insert( + "expiresAt".to_string(), + serde_json::Number::from_f64(expires_at) + .map(Value::Number) + .unwrap_or(Value::Null), + ); + } + } + serde_json::to_string_pretty(&Value::Object(body)) + .map(|content| format!("{content}\n")) + .map_err(|error| ApiError::internal(error.to_string())) +} + +async fn write_private_endpoint(path: &Path, content: &str) -> Result<(), ApiError> { + let parent = path + .parent() + .ok_or_else(|| ApiError::internal("tools endpoint path has no parent"))?; + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + let temp = parent.join(format!( + ".{}.tmp-{}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("endpoint"), + uuid::Uuid::new_v4() + )); + let mut options = tokio::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + let mut file = options + .open(&temp) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + let result = async { + file.write_all(content.as_bytes()).await?; + file.sync_all().await?; + drop(file); + tokio::fs::rename(&temp, path).await?; + #[cfg(unix)] + tokio::fs::File::open(parent).await?.sync_all().await?; + Ok::<(), std::io::Error>(()) + } + .await; + if let Err(error) = result { + let _ = tokio::fs::remove_file(&temp).await; + return Err(ApiError::internal(error.to_string())); + } + Ok(()) +} + +/// Publishes the endpoint descriptor before attempting MCP discovery. This is +/// an independent, short commit: `typegen call` needs only this credential and +/// must keep working when `tools/list` is temporarily unavailable. The later +/// whole-directory catalog swap includes the same endpoint again on success. +async fn commit_endpoint_file(state: &AppState, content: String) -> Result<(), ApiError> { + let Some(catalog_dir) = safe_path(&state.app_root, &state.repo_dir, tools_catalog::CATALOG_DIR) + else { + return Err(ApiError::internal("tools catalog path escapes app root")); + }; + let endpoint = catalog_dir.join(tools_catalog::ENDPOINT_FILENAME); + let repo_dir = state.repo_dir.clone(); + let mutation_root = state.app_root.join(".decocms").join("mutations"); + run_commit_owned(state, move || async move { + let commit_lock = TOOLS_CATALOG_COMMIT_LOCK.get_or_init(|| Mutex::new(())); + let _serialized = commit_lock.lock().await; + tokio::fs::create_dir_all(&catalog_dir) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + git_exclude::ensure_git_exclude(&repo_dir, &format!("/{}/", tools_catalog::CATALOG_DIR)) + .await; + // Finish any prior catalog directory transaction before mutating a + // file inside its target. Unrelated long mutation stages are retained. + let endpoint_stage_sentinel = mutation_root.join(".endpoint-update"); + tools_transaction::recover_catalog_transactions( + &mutation_root, + &catalog_dir, + &endpoint_stage_sentinel, + ) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + write_private_endpoint(&endpoint, &content).await + }) + .await +} + +async fn prepare_catalog_dir( + path: &Path, + endpoint_content: &str, + tools: &[tools_catalog::CatalogTool], + cancellation: &MutationCancellation, +) -> Result, ApiError> { + tokio::fs::create_dir_all(path) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + write_private_endpoint( + &path.join(tools_catalog::ENDPOINT_FILENAME), + endpoint_content, + ) + .await?; + + let files = tools_catalog::tool_catalog_files(tools); + for file in files { + if cancellation.is_cancelled() { + return Err(shutting_down()); + } + let mut options = tokio::fs::OpenOptions::new(); + options.write(true).create_new(true); + let mut output = options + .open(path.join(file.filename)) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + output + .write_all(file.content.as_bytes()) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + output + .sync_all() + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + } + Ok(tools.iter().map(|tool| tool.name.clone()).collect()) +} + +async fn commit_catalog_dir( + state: &AppState, + stage_dir: &Path, + staged_catalog: &Path, +) -> Result<(), ApiError> { + let Some(target) = safe_path(&state.app_root, &state.repo_dir, tools_catalog::CATALOG_DIR) + else { + remove_staged_path(stage_dir).await; + return Err(ApiError::internal("tools catalog path escapes app root")); + }; + let stage_dir = stage_dir.to_path_buf(); + let staged_catalog = staged_catalog.to_path_buf(); + let repo_dir = state.repo_dir.clone(); + let mutation_root = state.app_root.join(".decocms").join("mutations"); + run_commit_owned(state, move || async move { + let commit_lock = TOOLS_CATALOG_COMMIT_LOCK.get_or_init(|| Mutex::new(())); + let _serialized = commit_lock.lock().await; + if let Some(parent) = target.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + } + git_exclude::ensure_git_exclude(&repo_dir, &format!("/{}/", tools_catalog::CATALOG_DIR)) + .await; + tools_transaction::recover_catalog_transactions(&mutation_root, &target, &stage_dir) + .await + .map_err(|error| ApiError::internal(error.to_string()))?; + tools_transaction::install(&stage_dir, &staged_catalog, &target) + .await + .map_err(|error| ApiError::internal(error.to_string())) + }) + .await +} + +/// Success-envelope parity target: `daemon/routes/tools.ts` +/// (`makeToolsSyncHandler`). The endpoint descriptor commits before discovery, +/// so it remains callable even if `tools/list` fails; a successful listing then +/// replaces endpoint + catalog together as one recoverable directory swap. +/// The catalog lives under `/.deco/tools/` — distinct from local-api's +/// own `/.decocms/` (see the threads store doc). +pub async fn tools_sync(State(state): State, body: Bytes) -> Response { + let body: ToolsSyncBody = match parse_body(&body) { + Ok(b) => b, + Err(e) => return e.into_response(), + }; + let Some(url) = body + .url + .filter(|u| !u.is_empty() && reqwest::Url::parse(u).is_ok()) + else { + return ApiError::bad_request( + "body must be { url: string (valid URL), headers: Record }", + ) + .into_response(); + }; + let headers = match body.headers { + Some(v @ Value::Object(_)) => v, + _ => { + return ApiError::bad_request( + "body must be { url: string (valid URL), headers: Record }", + ) + .into_response() + } + }; + + let endpoint = tools_catalog::McpEndpoint { + url: url.clone(), + headers: headers.clone(), + expires_at: body.expires_at, + }; + let mutations = state.shutdown.mutations(); + let result = mutations + .run_owned(move |mut cancellation| async move { + let endpoint_content = endpoint_content(&endpoint)?; + commit_endpoint_file(&state, endpoint_content.clone()).await?; + let fetch = mcp_client::fetch_tool_catalog(&url, &headers); + let tools = tokio::select! { + _ = cancellation.cancelled() => { + return Err(shutting_down()); + } + result = fetch => match result { + Ok(tools) => tools, + Err(error) => { + return Err(ApiError::bad_gateway(error.to_string())); + } + } + }; + + let stage_dir = new_stage_dir(&state.app_root).await?; + let staged_catalog = stage_dir.join("catalog"); + let tool_names = match prepare_catalog_dir( + &staged_catalog, + &endpoint_content, + &tools, + &cancellation, + ) + .await + { + Ok(tool_names) => tool_names, + Err(error) => { + remove_staged_path(&stage_dir).await; + return Err(error); + } + }; + commit_catalog_dir(&state, &stage_dir, &staged_catalog).await?; + Ok(json!({ "count": tool_names.len(), "tools": tool_names })) + }) + .await; + match result { + Ok(Ok(value)) => Json(value).into_response(), + Ok(Err(error)) => error.into_response(), + Err(error) => map_owner_error(error).into_response(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_encode_matches_known_vectors() { + assert_eq!(base64_encode(b""), ""); + assert_eq!(base64_encode(b"f"), "Zg=="); + assert_eq!(base64_encode(b"fo"), "Zm8="); + assert_eq!(base64_encode(b"foo"), "Zm9v"); + assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy"); + } + + #[test] + fn sniff_image_media_type_detects_known_formats() { + assert_eq!( + sniff_image_media_type(&[0xff, 0xd8, 0xff, 0xe0]), + Some("image/jpeg") + ); + assert_eq!( + sniff_image_media_type(&[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Some("image/png") + ); + assert_eq!(sniff_image_media_type(b"GIF89a"), Some("image/gif")); + assert_eq!(sniff_image_media_type(b"not-an-image"), None); + } + + #[test] + fn assert_unlink_allowed_rejects_escaping_and_git() { + assert!(assert_unlink_allowed("../x", false).is_some()); + assert!(assert_unlink_allowed("", false).is_some()); + assert!(assert_unlink_allowed(".", true).is_some()); + assert!(assert_unlink_allowed(".git/HEAD", false).is_some()); + assert!(assert_unlink_allowed("a/.git", true).is_some()); + assert!(assert_unlink_allowed("a/b.txt", false).is_none()); + } + + #[cfg(unix)] + #[tokio::test] + async fn owned_grep_driver_reaps_on_request_cancellation_signal() { + let mut command = Command::new("sh"); + command + .args(["-c", "sleep 30"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let lock_dir = tempfile::tempdir().expect("lifetime lock tempdir"); + let mut child = + ProcessGroupChild::spawn(&mut command, &lock_dir.path().join("child-lifetime.lock")) + .await + .expect("spawn grep-owner fixture"); + let pid = child.id().expect("fixture pid"); + let controller = ProcessController::new(); + let owner_controller = controller.clone(); + let owner = tokio::spawn(async move { drive_owned_rg(&mut child, owner_controller).await }); + + assert!((controller.kill_handle())(KillSignal::Kill)); + let output = tokio::time::timeout(Duration::from_secs(5), owner) + .await + .expect("owned grep process exited") + .expect("owner task did not panic") + .expect("owner returned exit status"); + assert_eq!(rg_exit_code(output.status), 137); + let alive = std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + assert!(!alive, "cancelled grep process survived its owner"); + } +} diff --git a/apps/native/crates/local-api/src/routes/fs/git_exclude.rs b/apps/native/crates/local-api/src/routes/fs/git_exclude.rs new file mode 100644 index 0000000000..1ee2df5db5 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/fs/git_exclude.rs @@ -0,0 +1,84 @@ +//! Port of `packages/sandbox/daemon/git-exclude.ts::ensureGitExclude`. +//! Registers a line in `/.git/info/exclude` so the git family's +//! shutdown-publish hook (`git add -A`) never commits local-api-managed +//! paths (the tool catalog, its endpoint credential file) onto the user's +//! branch. Best-effort: an unwritable `.git` never blocks the caller. + +use std::path::Path; + +use tokio::fs; + +pub async fn ensure_git_exclude(repo_dir: &Path, line: &str) { + let git_dir = repo_dir.join(".git"); + let is_git_dir = fs::metadata(&git_dir) + .await + .map(|m| m.is_dir()) + .unwrap_or(false); + if !is_git_dir { + return; + } + let info_dir = git_dir.join("info"); + let exclude_path = info_dir.join("exclude"); + match fs::read_to_string(&exclude_path).await { + Ok(existing) => { + if !existing.lines().any(|l| l == line) { + let appended = if existing.ends_with('\n') || existing.is_empty() { + format!("{existing}{line}\n") + } else { + format!("{existing}\n{line}\n") + }; + let _ = fs::write(&exclude_path, appended).await; + } + } + Err(_) => { + // Template-less clones (libgit2/JGit, bare templates) lack + // info/exclude. + if fs::create_dir_all(&info_dir).await.is_ok() { + let _ = fs::write(&exclude_path, format!("{line}\n")).await; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn creates_exclude_file_when_missing() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".git")).await.unwrap(); + ensure_git_exclude(dir.path(), "/.deco/tools/").await; + let content = fs::read_to_string(dir.path().join(".git/info/exclude")) + .await + .unwrap(); + assert!(content.contains("/.deco/tools/")); + } + + #[tokio::test] + async fn is_a_no_op_without_a_git_dir() { + let dir = tempdir().unwrap(); + ensure_git_exclude(dir.path(), "/.deco/tools/").await; + assert!(!dir.path().join(".git").exists()); + } + + #[tokio::test] + async fn does_not_duplicate_an_existing_line() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".git/info")) + .await + .unwrap(); + fs::write( + dir.path().join(".git/info/exclude"), + "/.deco/tools/\n/other/\n", + ) + .await + .unwrap(); + ensure_git_exclude(dir.path(), "/.deco/tools/").await; + let content = fs::read_to_string(dir.path().join(".git/info/exclude")) + .await + .unwrap(); + assert_eq!(content.matches("/.deco/tools/").count(), 1); + } +} diff --git a/apps/native/crates/local-api/src/routes/fs/glob_logic.rs b/apps/native/crates/local-api/src/routes/fs/glob_logic.rs new file mode 100644 index 0000000000..9757bd66b2 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/fs/glob_logic.rs @@ -0,0 +1,517 @@ +//! Port of the pure helper functions from +//! `packages/sandbox/daemon/routes/fs.ts`'s glob section (everything from +//! `GLOB_EXCLUDE_DIRS` down through `collectEmptyDirectories`), plus a +//! from-scratch directory walk that reproduces `Bun.Glob(...).scan({ +//! onlyFiles:false, followSymlinks:false, dot:true })`'s observable output +//! (there's no Bun.Glob equivalent in the `globset`/`walkdir` combo this +//! crate has, so the walk itself is new code — the *shape* of every +//! function below, including names, mirrors `fs.ts` 1:1 so the two stay +//! easy to diff against each other). + +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +use globset::GlobBuilder; + +/// Skipped during a glob walk — node_modules/.git noise drowns out useful +/// matches and isn't what an agent ever wants to glob/grep through. +pub fn glob_exclude_dirs() -> &'static HashSet<&'static str> { + static DIRS: std::sync::OnceLock> = std::sync::OnceLock::new(); + DIRS.get_or_init(|| { + [ + "node_modules", + ".git", + ".next", + "dist", + "build", + ".turbo", + ".cache", + ".ssh", + ".aws", + ".gnupg", + ] + .into_iter() + .collect() + }) +} + +/// Default cap for agent glob calls. +pub const GLOB_RESULT_LIMIT: usize = 1000; +/// Hard ceiling when callers pass an explicit `limit` (file explorer). +pub const GLOB_MAX_RESULT_LIMIT: usize = 10_000; + +pub fn path_segment_depth(rel_path: &str) -> usize { + rel_path.split('/').filter(|s| !s.is_empty()).count() +} + +/// Register repo-relative ancestor directories up to `max_depth`. +pub fn register_glob_ancestor_directories( + repo_relative_path: &str, + max_depth: usize, + directory_paths: &mut HashSet, + is_file: bool, +) { + let parts: Vec<&str> = repo_relative_path + .split('/') + .filter(|s| !s.is_empty()) + .collect(); + let dir_parts: &[&str] = if is_file && !parts.is_empty() { + &parts[..parts.len() - 1] + } else { + &parts[..] + }; + let take = dir_parts.len().min(max_depth); + for i in 1..=take { + directory_paths.insert(dir_parts[..i].join("/")); + } +} + +pub fn resolve_glob_result_limit(limit: Option) -> usize { + match limit { + None => GLOB_RESULT_LIMIT, + Some(n) if !n.is_finite() || n < 1.0 => GLOB_RESULT_LIMIT, + Some(n) => (n.floor() as usize).min(GLOB_MAX_RESULT_LIMIT), + } +} + +/// Seed value for [`walk_repo_within_max_depth`]'s `repo_rel_dir` — the +/// repo-relative path of `search_path` itself. Mirrors +/// `repoRelativePrefix` in `fs.ts` for the common case (`search_path` +/// nested under `repo_dir`, which is all `safe_path` ever produces); falls +/// back to an empty prefix for the rare sibling-of-repo case (not +/// exercised by any test — `walkRepoWithinMaxDepth` is only reachable via +/// the `pattern === "**/*"` fast path, and a caller-supplied `path` that +/// escapes `repo_dir` but stays inside the workspace is an edge case the +/// TS implementation itself doesn't special-case either). +pub fn repo_relative_prefix(search_path: &Path, repo_dir: &Path) -> String { + if search_path == repo_dir { + return String::new(); + } + let repo_str = repo_dir.to_string_lossy().replace('\\', "/"); + let search_str = search_path.to_string_lossy().replace('\\', "/"); + let prefix = format!("{repo_str}/"); + search_str + .strip_prefix(prefix.as_str()) + .map(str::to_string) + .unwrap_or_default() +} + +fn join_repo_relative(prefix: &str, name: &str) -> String { + if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}/{name}") + } +} + +/// Paths whose any segment is in [`glob_exclude_dirs`] are skipped. +pub fn is_glob_path_allowed(rel: &str) -> bool { + !rel.split('/').any(|seg| glob_exclude_dirs().contains(seg)) +} + +pub struct GlobScanState { + pub file_paths: Vec, + pub directory_paths: HashSet, + pub result_limit: usize, +} + +/// Depth-bounded directory walk — avoids scanning the full tree for +/// `max_depth`. Mirrors `walkRepoWithinMaxDepth` in `fs.ts`. Returns +/// `true` when the walk hit `state.result_limit` (truncated). +pub fn walk_repo_within_max_depth( + abs_dir: &Path, + repo_rel_dir: &str, + max_depth: usize, + state: &mut GlobScanState, +) -> bool { + let entries = match fs::read_dir(abs_dir) { + Ok(e) => e, + Err(_) => return false, + }; + // Stable iteration order keeps output deterministic (and tests sane) — + // `read_dir` order is filesystem-dependent otherwise. + let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect(); + sorted.sort_by_key(|e| e.file_name()); + + for entry in sorted { + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + let child_rel = join_repo_relative(repo_rel_dir, &name); + if !is_glob_path_allowed(&child_rel) { + continue; + } + let file_type = match entry.file_type() { + Ok(t) => t, + Err(_) => continue, + }; + if file_type.is_symlink() { + continue; + } + + let depth = path_segment_depth(&child_rel); + + if file_type.is_dir() { + if depth > max_depth { + register_glob_ancestor_directories( + &child_rel, + max_depth, + &mut state.directory_paths, + false, + ); + continue; + } + state.directory_paths.insert(child_rel.clone()); + if depth < max_depth { + let child_abs = abs_dir.join(&*name); + if walk_repo_within_max_depth(&child_abs, &child_rel, max_depth, state) { + return true; + } + } + } else if file_type.is_file() { + if depth > max_depth { + register_glob_ancestor_directories( + &child_rel, + max_depth, + &mut state.directory_paths, + true, + ); + continue; + } + state.file_paths.push(child_rel); + if state.file_paths.len() >= state.result_limit { + return true; + } + } + } + false +} + +/// Repo-relative paths are a wire contract (agents/tools glob+grep over +/// them) that must always come back POSIX-style (forward slashes). Mirrors +/// `toRepoRelativePath` in `fs.ts`. +pub fn to_repo_relative_path(abs: &str, search_path: &str, repo_dir: &str) -> String { + let normalized_abs = abs.replace('\\', "/"); + let normalized_repo_dir = repo_dir.replace('\\', "/"); + let normalized_search_path = search_path.replace('\\', "/"); + let repo_prefix = format!("{normalized_repo_dir}/"); + if let Some(stripped) = normalized_abs.strip_prefix(&repo_prefix) { + return stripped.to_string(); + } + let search_prefix = format!("{normalized_search_path}/"); + if let Some(stripped) = normalized_abs.strip_prefix(&search_prefix) { + return stripped.to_string(); + } + normalized_abs +} + +/// Empty directories have no nested files and no nested directories in the +/// scan. Mirrors `collectEmptyDirectories` in `fs.ts`. +pub fn collect_empty_directories(file_paths: &[String], directory_paths: &[String]) -> Vec { + let dir_set: HashSet<&str> = directory_paths.iter().map(|s| s.as_str()).collect(); + let mut empty = Vec::new(); + for dir in directory_paths { + if dir.is_empty() { + continue; + } + let prefix = format!("{dir}/"); + let has_nested_file = file_paths.iter().any(|f| f.starts_with(&prefix)); + let has_nested_dir = dir_set + .iter() + .any(|other| *other != dir.as_str() && other.starts_with(&prefix)); + if !has_nested_file && !has_nested_dir { + empty.push(dir.clone()); + } + } + empty +} + +/// Result of a general (non-`**/*`-fast-path) glob scan. +pub struct GlobMatch { + pub file_paths: Vec, + pub directory_paths: HashSet, + pub truncated: bool, +} + +/// Reproduces the `Bun.Glob(pattern).scan({ cwd: searchPath, onlyFiles: +/// false, followSymlinks: false, dot: true })` branch of `makeGlobHandler`: +/// walk `search_path` (pruning [`glob_exclude_dirs`] early — output- +/// equivalent to filtering post-hoc via [`is_glob_path_allowed`], since +/// nothing under an excluded dir could ever survive that filter), matching +/// each entry's repo-relative path against `pattern`. +pub fn scan_glob( + search_path: &Path, + repo_dir: &Path, + pattern: &str, + max_depth: Option, + result_limit: usize, +) -> Result { + // `literal_separator(true)`: a single `*`/`?` must not cross a `/` + // (globset's default is permissive here — the opposite of the + // Bun.Glob/picomatch semantics `fs.ts` relies on, where `*.txt` only + // matches top-level files and `**` is required to cross directories). + let matcher = GlobBuilder::new(pattern) + .literal_separator(true) + .build()? + .compile_matcher(); + let mut file_paths = Vec::new(); + let mut directory_paths: HashSet = HashSet::new(); + let mut truncated = false; + + let mut stack: Vec<(std::path::PathBuf, String)> = + vec![(search_path.to_path_buf(), String::new())]; + // Deterministic order. + while let Some((abs_dir, rel_dir)) = stack.pop() { + let entries = match fs::read_dir(&abs_dir) { + Ok(e) => e, + Err(_) => continue, + }; + let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect(); + sorted.sort_by_key(|e| e.file_name()); + // Push in reverse so pop() visits in ascending order. + for entry in sorted.into_iter().rev() { + let file_name = entry.file_name(); + let name = file_name.to_string_lossy().to_string(); + let scan_rel = join_repo_relative(&rel_dir, &name); + if !is_glob_path_allowed(&scan_rel) { + continue; + } + let file_type = match entry.file_type() { + Ok(t) => t, + Err(_) => continue, + }; + if file_type.is_symlink() { + continue; + } + let abs = abs_dir.join(&name); + let matched = matcher.is_match(&scan_rel); + + if matched { + let rel_path = to_repo_relative_path( + &abs.to_string_lossy(), + &search_path.to_string_lossy(), + &repo_dir.to_string_lossy(), + ); + let depth = path_segment_depth(&rel_path); + if let Some(max) = max_depth { + if depth > max { + register_glob_ancestor_directories( + &rel_path, + max, + &mut directory_paths, + file_type.is_file(), + ); + continue; + } + } + if file_type.is_dir() { + directory_paths.insert(rel_path); + } else if file_type.is_file() { + file_paths.push(rel_path); + if file_paths.len() >= result_limit { + truncated = true; + return Ok(GlobMatch { + file_paths, + directory_paths, + truncated, + }); + } + } + } + + if file_type.is_dir() { + stack.push((abs, scan_rel)); + } + } + } + + Ok(GlobMatch { + file_paths, + directory_paths, + truncated, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{create_dir_all, write}; + use tempfile::tempdir; + + #[test] + fn path_segment_depth_counts_segments() { + assert_eq!(path_segment_depth("a/b/c.ts"), 3); + assert_eq!(path_segment_depth("README.md"), 1); + } + + #[test] + fn register_glob_ancestor_directories_adds_ancestors_up_to_max_depth() { + let mut dirs = HashSet::new(); + register_glob_ancestor_directories("a/b/c/d.ts", 3, &mut dirs, true); + let mut v: Vec<_> = dirs.into_iter().collect(); + v.sort(); + assert_eq!(v, vec!["a", "a/b", "a/b/c"]); + } + + #[test] + fn register_glob_ancestor_directories_handles_directory_paths() { + let mut dirs = HashSet::new(); + register_glob_ancestor_directories("a/b/c/d", 3, &mut dirs, false); + let mut v: Vec<_> = dirs.into_iter().collect(); + v.sort(); + assert_eq!(v, vec!["a", "a/b", "a/b/c"]); + } + + #[test] + fn to_repo_relative_path_strips_repo_dir_prefix_posix() { + assert_eq!( + to_repo_relative_path("/repo/needle.txt", "/repo", "/repo"), + "needle.txt" + ); + } + + #[test] + fn to_repo_relative_path_strips_windows_style_prefix() { + assert_eq!( + to_repo_relative_path( + "C:\\Users\\runner\\repo\\needle.txt", + "C:\\Users\\runner\\repo", + "C:\\Users\\runner\\repo", + ), + "needle.txt" + ); + } + + #[test] + fn to_repo_relative_path_falls_back_to_search_path_prefix() { + assert_eq!( + to_repo_relative_path( + "C:\\Users\\runner\\app\\dev\\index.ts", + "C:\\Users\\runner\\app\\dev", + "C:\\Users\\runner\\app\\repo", + ), + "index.ts" + ); + } + + #[test] + fn to_repo_relative_path_normalizes_nested_windows_subdirectories() { + assert_eq!( + to_repo_relative_path( + "C:\\Users\\runner\\repo\\.deco\\tools\\ARCHIVE_EMAIL.json", + "C:\\Users\\runner\\repo", + "C:\\Users\\runner\\repo", + ), + ".deco/tools/ARCHIVE_EMAIL.json" + ); + } + + #[test] + fn collect_empty_directories_ignores_parents_of_nested_directories() { + let dirs = vec!["src".to_string(), "src/components".to_string()]; + assert_eq!( + collect_empty_directories(&[], &dirs), + vec!["src/components".to_string()] + ); + } + + #[test] + fn resolve_glob_result_limit_caps_at_max() { + assert_eq!(resolve_glob_result_limit(None), GLOB_RESULT_LIMIT); + assert_eq!( + resolve_glob_result_limit(Some((GLOB_MAX_RESULT_LIMIT + 999) as f64)), + GLOB_MAX_RESULT_LIMIT + ); + } + + #[test] + fn scan_glob_finds_top_level_txt_files() { + let dir = tempdir().unwrap(); + write(dir.path().join("x.txt"), "").unwrap(); + write(dir.path().join("y.rs"), "").unwrap(); + let result = scan_glob(dir.path(), dir.path(), "*.txt", None, GLOB_RESULT_LIMIT).unwrap(); + assert!(result.file_paths.contains(&"x.txt".to_string())); + assert!(!result.file_paths.contains(&"y.rs".to_string())); + assert!(result.directory_paths.is_empty()); + } + + #[test] + fn scan_glob_includes_dot_directories_such_as_deco() { + let dir = tempdir().unwrap(); + create_dir_all(dir.path().join(".deco/blocks")).unwrap(); + write(dir.path().join(".deco/blocks/foo.json"), "{}").unwrap(); + let result = scan_glob(dir.path(), dir.path(), "**/*", None, GLOB_RESULT_LIMIT).unwrap(); + assert!(result + .file_paths + .contains(&".deco/blocks/foo.json".to_string())); + } + + #[test] + fn scan_glob_shows_dotfiles_but_excludes_glob_exclude_dirs() { + let dir = tempdir().unwrap(); + write(dir.path().join(".env"), "SECRET=1").unwrap(); + create_dir_all(dir.path().join(".deco/blocks")).unwrap(); + write(dir.path().join(".deco/blocks/foo.json"), "{}").unwrap(); + create_dir_all(dir.path().join(".git")).unwrap(); + write(dir.path().join(".git/config"), "").unwrap(); + let result = scan_glob(dir.path(), dir.path(), "**/*", None, GLOB_RESULT_LIMIT).unwrap(); + assert!(result.file_paths.contains(&".env".to_string())); + assert!(result + .file_paths + .contains(&".deco/blocks/foo.json".to_string())); + assert!(!result.file_paths.contains(&".git/config".to_string())); + } + + #[test] + fn scan_glob_includes_empty_directories() { + let dir = tempdir().unwrap(); + create_dir_all(dir.path().join("tavano-folder")).unwrap(); + let result = scan_glob(dir.path(), dir.path(), "**/*", None, GLOB_RESULT_LIMIT).unwrap(); + assert!(result.directory_paths.contains("tavano-folder")); + } + + #[test] + fn scan_glob_includes_gitkeep_folder_markers_as_files() { + let dir = tempdir().unwrap(); + create_dir_all(dir.path().join("empty-dir")).unwrap(); + write(dir.path().join("empty-dir/.gitkeep"), "").unwrap(); + let result = scan_glob(dir.path(), dir.path(), "**/*", None, GLOB_RESULT_LIMIT).unwrap(); + assert!(result + .file_paths + .contains(&"empty-dir/.gitkeep".to_string())); + } + + #[test] + fn scan_glob_explicit_limit_truncates_results() { + let dir = tempdir().unwrap(); + for i in 0..7 { + write(dir.path().join(format!("file-{i}.txt")), "").unwrap(); + } + let result = scan_glob(dir.path(), dir.path(), "*.txt", None, 5).unwrap(); + assert_eq!(result.file_paths.len(), 5); + assert!(result.truncated); + } + + #[test] + fn walk_repo_within_max_depth_skips_deeper_files_but_registers_ancestors() { + let dir = tempdir().unwrap(); + create_dir_all(dir.path().join("a/b/c/d")).unwrap(); + write(dir.path().join("a/b/c/d/deep.txt"), "x").unwrap(); + write(dir.path().join("a/b/shallow.txt"), "y").unwrap(); + let mut state = GlobScanState { + file_paths: Vec::new(), + directory_paths: HashSet::new(), + result_limit: GLOB_RESULT_LIMIT, + }; + walk_repo_within_max_depth(dir.path(), "", 3, &mut state); + assert!(state.file_paths.contains(&"a/b/shallow.txt".to_string())); + assert!(!state.file_paths.contains(&"a/b/c/d/deep.txt".to_string())); + assert!(state.directory_paths.contains("a/b/c")); + } + + #[test] + fn repo_relative_prefix_matches_search_path_equal_to_repo_dir() { + let dir = tempdir().unwrap(); + assert_eq!(repo_relative_prefix(dir.path(), dir.path()), ""); + } +} diff --git a/apps/native/crates/local-api/src/routes/fs/mcp_client.rs b/apps/native/crates/local-api/src/routes/fs/mcp_client.rs new file mode 100644 index 0000000000..168f40ca10 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/fs/mcp_client.rs @@ -0,0 +1,320 @@ +//! A minimal MCP Streamable HTTP client — just enough to run the +//! `initialize` -> `notifications/initialized` -> `tools/list` (paginated) +//! sequence that `packages/sandbox/daemon/tools-catalog.ts::fetchToolCatalog` +//! runs via `@modelcontextprotocol/sdk`'s `StreamableHTTPClientTransport` / +//! `Client`. There's no MCP SDK crate in this workspace's dependency table +//! (`apps/native/Cargo.toml` is a shared file — adding one is an interface +//! request, not a local edit), so this hand-rolls the wire protocol: +//! JSON-RPC 2.0 over HTTP POST, `mcp-session-id` header propagation once +//! the server hands one out, and the two response shapes the SDK's +//! `WebStandardStreamableHTTPServerTransport` can return (a direct +//! `application/json` body, or one `text/event-stream` frame carrying the +//! same JSON-RPC envelope — see that file's `handlePostRequest`/`send`). +//! +//! Deliberately NOT a general MCP client: no resumable-stream reconnection, +//! no auth-provider/OAuth flow (the endpoint's bearer already lives in +//! `headers`, forwarded verbatim), no batched requests. Those exist in the +//! SDK because the client is used for the whole shape of the protocol; we +//! only ever need one page-turning read-only call. + +use std::time::Duration; + +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT, CONTENT_TYPE}; +use serde_json::{json, Map, Value}; + +use super::tools_catalog::CatalogTool; + +/// `LATEST_PROTOCOL_VERSION` in `@modelcontextprotocol/sdk`'s `types.ts`. +const PROTOCOL_VERSION: &str = "2025-11-25"; +const CLIENT_NAME: &str = "local-api"; +const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Defensive cap on `tools/list` pagination — a misbehaving or malicious +/// endpoint returning an unbounded `nextCursor` chain must not hang this +/// request (which runs synchronously inside the `tools/sync` handler) +/// forever. +const MAX_PAGES: usize = 10_000; + +#[derive(Debug)] +pub enum McpFetchError { + /// Couldn't even talk to the endpoint (DNS/connect/timeout/TLS). Maps + /// to the daemon's 502 "unreachable" response. + Unreachable(String), + /// Reached the endpoint but it didn't speak MCP correctly (bad + /// JSON-RPC envelope, error response, unexpected status). Also a 502 + /// on the daemon side — `fetchToolCatalog` doesn't distinguish these + /// either, it just throws and the route handler maps any throw to 502. + Protocol(String), +} + +impl std::fmt::Display for McpFetchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + McpFetchError::Unreachable(m) | McpFetchError::Protocol(m) => write!(f, "{m}"), + } + } +} + +fn build_headers(extra: &Value) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert( + ACCEPT, + HeaderValue::from_static("application/json, text/event-stream"), + ); + if let Value::Object(map) = extra { + for (k, v) in map { + let value_str = match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + let name = HeaderName::from_bytes(k.as_bytes()) + .map_err(|e| McpFetchError::Protocol(format!("invalid header name {k:?}: {e}")))?; + let value = HeaderValue::from_str(&value_str).map_err(|e| { + McpFetchError::Protocol(format!("invalid header value for {k:?}: {e}")) + })?; + headers.insert(name, value); + } + } + Ok(headers) +} + +/// Scans an SSE-framed body for the JSON-RPC message whose `id` matches +/// `want_id` — the `send()` implementation in +/// `WebStandardStreamableHTTPServerTransport` writes exactly one such +/// frame per request before closing the stream (`event: message\n[id: +/// ...\n]data: \n\n`), so we don't need general SSE reconnection — +/// just parse every `data:` block in the (already-fully-buffered) response +/// text and return the one that matches. +fn parse_sse_json_rpc(body: &str, want_id: i64) -> Result { + let mut last: Option = None; + for frame in body.split("\n\n") { + let mut data_lines = Vec::new(); + for line in frame.lines() { + if let Some(d) = line.strip_prefix("data:") { + data_lines.push(d.strip_prefix(' ').unwrap_or(d)); + } + } + if data_lines.is_empty() { + continue; + } + let data = data_lines.join("\n"); + if data.is_empty() { + continue; // priming/keep-alive event — no payload + } + let Ok(parsed) = serde_json::from_str::(&data) else { + continue; + }; + if parsed.get("id").and_then(Value::as_i64) == Some(want_id) { + return Ok(parsed); + } + last = Some(parsed); + } + last.ok_or_else(|| { + McpFetchError::Protocol("no JSON-RPC message found in SSE response".to_string()) + }) +} + +struct Session { + client: reqwest::Client, + url: String, + base_headers: Value, + session_id: Option, + next_id: i64, +} + +impl Session { + fn new(url: String, headers: Value) -> Self { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .connect_timeout(Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Self { + client, + url, + base_headers: headers, + session_id: None, + next_id: 1, + } + } + + async fn post(&mut self, body: &Value) -> Result { + let mut headers = build_headers(&self.base_headers)?; + if let Some(sid) = &self.session_id { + let value = HeaderValue::from_str(sid) + .map_err(|e| McpFetchError::Protocol(format!("invalid session id: {e}")))?; + headers.insert(HeaderName::from_static("mcp-session-id"), value); + } + let payload = serde_json::to_vec(body) + .map_err(|e| McpFetchError::Protocol(format!("failed to encode request: {e}")))?; + let resp = self + .client + .post(&self.url) + .headers(headers) + .body(payload) + .send() + .await + .map_err(|e| McpFetchError::Unreachable(e.to_string()))?; + if let Some(sid) = resp + .headers() + .get("mcp-session-id") + .and_then(|v| v.to_str().ok()) + { + self.session_id = Some(sid.to_string()); + } + Ok(resp) + } + + /// A JSON-RPC request (carries `id`); returns the `result` value or an + /// `McpFetchError` derived from an `error` envelope / transport + /// failure. + async fn request(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id += 1; + let body = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }); + let resp = self.post(&body).await?; + let status = resp.status(); + let content_type = resp + .headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let text = resp + .text() + .await + .map_err(|e| McpFetchError::Unreachable(e.to_string()))?; + if !status.is_success() { + let snippet: String = text.chars().take(500).collect(); + return Err(McpFetchError::Protocol(format!( + "{method} failed: HTTP {status}: {snippet}" + ))); + } + let message = if content_type.contains("text/event-stream") { + parse_sse_json_rpc(&text, id)? + } else { + serde_json::from_str::(&text).map_err(|e| { + McpFetchError::Protocol(format!("invalid JSON-RPC response to {method}: {e}")) + })? + }; + if let Some(err) = message.get("error") { + return Err(McpFetchError::Protocol(format!( + "{method} returned an error: {err}" + ))); + } + message + .get("result") + .cloned() + .ok_or_else(|| McpFetchError::Protocol(format!("{method} response missing result"))) + } + + /// A JSON-RPC notification (no `id`); the server responds `202` with + /// no body. + async fn notify(&mut self, method: &str) -> Result<(), McpFetchError> { + let body = json!({ "jsonrpc": "2.0", "method": method }); + let resp = self.post(&body).await?; + let status = resp.status(); + let _ = resp.bytes().await; + if !status.is_success() { + return Err(McpFetchError::Protocol(format!( + "notification {method} failed: HTTP {status}" + ))); + } + Ok(()) + } +} + +/// Connect to a Virtual MCP endpoint and list its tools, following the +/// pagination cursor so large orgs aren't silently truncated to the first +/// page — byte-parity target: `tools-catalog.ts::fetchToolCatalog`. +pub async fn fetch_tool_catalog( + url: &str, + headers: &Value, +) -> Result, McpFetchError> { + let mut session = Session::new(url.to_string(), headers.clone()); + session + .request( + "initialize", + json!({ + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": CLIENT_NAME, "version": CLIENT_VERSION }, + }), + ) + .await?; + session.notify("notifications/initialized").await?; + + let mut tools = Vec::new(); + let mut cursor: Option = None; + for _ in 0..MAX_PAGES { + let mut params = Map::new(); + if let Some(c) = &cursor { + params.insert("cursor".to_string(), Value::String(c.clone())); + } + let result = session.request("tools/list", Value::Object(params)).await?; + let page_tools = result + .get("tools") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + for t in page_tools { + let Some(name) = t.get("name").and_then(Value::as_str) else { + continue; + }; + tools.push(CatalogTool { + name: name.to_string(), + description: t + .get("description") + .and_then(Value::as_str) + .map(str::to_string), + input_schema: t.get("inputSchema").cloned(), + output_schema: t.get("outputSchema").cloned(), + }); + } + cursor = result + .get("nextCursor") + .and_then(Value::as_str) + .map(str::to_string); + if cursor.is_none() { + break; + } + } + Ok(tools) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_sse_json_rpc_extracts_matching_data_frame() { + let body = "event: message\nid: 1\ndata: {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"tools\":[]}}\n\n"; + let msg = parse_sse_json_rpc(body, 2).unwrap(); + assert_eq!(msg["result"]["tools"], json!([])); + } + + #[test] + fn parse_sse_json_rpc_skips_priming_events() { + let body = "id: p1\ndata: \n\nevent: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n\n"; + let msg = parse_sse_json_rpc(body, 1).unwrap(); + assert_eq!(msg["id"], 1); + } + + #[test] + fn parse_sse_json_rpc_errors_on_no_data() { + assert!(parse_sse_json_rpc("event: message\n\n", 1).is_err()); + } + + #[test] + fn build_headers_rejects_non_object_extra() { + let headers = build_headers(&json!(null)).unwrap(); + // Base headers still present; no panic on a non-object `extra`. + assert!(headers.get(CONTENT_TYPE).is_some()); + } + + #[test] + fn build_headers_forwards_string_values() { + let headers = build_headers(&json!({"Authorization": "Bearer k"})).unwrap(); + assert_eq!(headers.get("authorization").unwrap(), "Bearer k"); + } +} diff --git a/apps/native/crates/local-api/src/routes/fs/safe_path.rs b/apps/native/crates/local-api/src/routes/fs/safe_path.rs new file mode 100644 index 0000000000..d8532dcf08 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/fs/safe_path.rs @@ -0,0 +1,172 @@ +//! Port of `packages/sandbox/daemon/paths.ts::safePath` — pure, no +//! filesystem access (deliberately: it must NOT resolve symlinks, since +//! `state.app_root`/`state.repo_dir` themselves are often symlinked +//! temp-dir paths on macOS, e.g. `/var/folders/...` -> `/private/var/folders/...`. +//! Canonicalizing here would make a legitimate in-workspace path look like +//! it escapes `app_root` whenever the two disagree on which side of the +//! symlink they're expressed. `path.resolve`/`path.relative` in Node don't +//! touch the filesystem either — this mirrors that lexical-only contract. + +use std::path::{Component, Path, PathBuf}; + +/// Lexically resolves `user_path` against `base_dir` (mirrors +/// `path.resolve(baseDir, userPath)`): an absolute `user_path` wins +/// outright; a relative one is joined onto `base_dir`. The result is then +/// lexically normalized (`.`/`..` collapsed) without touching the +/// filesystem. +fn resolve_against(base_dir: &Path, user_path: &str) -> PathBuf { + let candidate = if Path::new(user_path).is_absolute() { + PathBuf::from(user_path) + } else { + base_dir.join(user_path) + }; + normalize_lexical(&candidate) +} + +/// Collapses `.`/`..` components without consulting the filesystem (no +/// `canonicalize`). `..` past the root is a no-op, matching +/// `path.resolve('/', '..') === '/'`. +fn normalize_lexical(p: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for comp in p.components() { + match comp { + Component::CurDir => {} + Component::ParentDir => { + // `pop()` is a no-op when `out` is empty or holds only a + // root/prefix component — same as Node clamping `..` at + // the filesystem root instead of erroring past it. + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + out +} + +/// Returns `Some(resolved)` when `resolved` (the lexical resolution of +/// `user_path` against `base_dir`) stays inside `workspace_root`; `None` on +/// escape. Byte-parity with `paths.ts::safePath`, including allowing +/// `resolved == workspace_root` itself (empty relative path) and siblings +/// reached via `../` that stay inside `workspace_root`. +pub fn safe_path(workspace_root: &Path, base_dir: &Path, user_path: &str) -> Option { + let resolved = resolve_against(base_dir, user_path); + let root = normalize_lexical(workspace_root); + if is_within(&root, &resolved) { + Some(resolved) + } else { + None + } +} + +/// True when `target` is `root` itself or lexically nested inside it. +fn is_within(root: &Path, target: &Path) -> bool { + let root_comps: Vec = root.components().collect(); + let target_comps: Vec = target.components().collect(); + if target_comps.len() < root_comps.len() { + return false; + } + root_comps + .iter() + .zip(target_comps.iter()) + .all(|(a, b)| a == b) +} + +/// `resolveReadPath` in `fs.ts`: absolute paths pass through untouched (OS +/// permissions already gate what the process can read); relative paths go +/// through the same clamp as everything else. +pub fn resolve_read_path( + workspace_root: &Path, + base_dir: &Path, + user_path: &str, +) -> Option { + if Path::new(user_path).is_absolute() { + Some(PathBuf::from(user_path)) + } else { + safe_path(workspace_root, base_dir, user_path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(s: &str) -> PathBuf { + PathBuf::from(s) + } + + #[test] + fn resolves_relative_paths_against_the_repo() { + let ws = p("/workspace"); + let repo = p("/workspace/app"); + assert_eq!( + safe_path(&ws, &repo, "src/index.ts"), + Some(p("/workspace/app/src/index.ts")) + ); + } + + #[test] + fn returns_the_repo_for_empty_or_dot_paths() { + let ws = p("/workspace"); + let repo = p("/workspace/app"); + assert_eq!(safe_path(&ws, &repo, ""), Some(p("/workspace/app"))); + assert_eq!(safe_path(&ws, &repo, "."), Some(p("/workspace/app"))); + } + + #[test] + fn allows_escaping_the_repo_into_workspace_siblings() { + let ws = p("/workspace"); + let repo = p("/workspace/app"); + assert_eq!( + safe_path(&ws, &repo, "../tmp/app/dev"), + Some(p("/workspace/tmp/app/dev")) + ); + } + + #[test] + fn rejects_paths_that_escape_the_workspace() { + let ws = p("/workspace"); + let repo = p("/workspace/app"); + assert_eq!(safe_path(&ws, &repo, "../../etc/passwd"), None); + assert_eq!(safe_path(&ws, &repo, "a/../../../etc"), None); + } + + #[test] + fn rejects_absolute_paths_outside_the_workspace() { + let ws = p("/workspace"); + let repo = p("/workspace/app"); + assert_eq!(safe_path(&ws, &repo, "/etc/passwd"), None); + } + + #[test] + fn allows_absolute_paths_inside_the_workspace() { + let ws = p("/workspace"); + let repo = p("/workspace/app"); + assert_eq!( + safe_path(&ws, &repo, "/workspace/app/src/x.ts"), + Some(p("/workspace/app/src/x.ts")) + ); + assert_eq!( + safe_path(&ws, &repo, "/workspace/tmp/app/dev"), + Some(p("/workspace/tmp/app/dev")) + ); + } + + #[test] + fn resolve_read_path_passes_absolute_through_unclamped() { + let ws = p("/workspace"); + let repo = p("/workspace/app"); + // Deliberately outside the workspace — resolveReadPath in the + // daemon doesn't clamp absolute paths at all. + assert_eq!( + resolve_read_path(&ws, &repo, "/etc/passwd"), + Some(p("/etc/passwd")) + ); + } + + #[test] + fn resolve_read_path_clamps_relative_paths() { + let ws = p("/workspace"); + let repo = p("/workspace/app"); + assert_eq!(resolve_read_path(&ws, &repo, "../../etc/passwd"), None); + } +} diff --git a/apps/native/crates/local-api/src/routes/fs/tools_catalog.rs b/apps/native/crates/local-api/src/routes/fs/tools_catalog.rs new file mode 100644 index 0000000000..98f0d50a14 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/fs/tools_catalog.rs @@ -0,0 +1,133 @@ +//! Port of `packages/sandbox/daemon/tools-catalog.ts` — materializes an +//! org's Virtual MCP tool catalog onto the sandbox filesystem (one JSON +//! Schema file per tool under `/.deco/tools/`) plus the pre- +//! authenticated endpoint file scripts/typegen use to call tools without +//! flags. Byte-parity target for the pure filename-sanitizing logic +//! (`toolCatalogFiles`); the fetch step lives in `mcp_client.rs`. + +use regex::Regex; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::HashSet; + +/// Where the catalog lands, relative to the repo root. +pub const CATALOG_DIR: &str = ".deco/tools"; +/// The run's pre-authenticated MCP endpoint, written next to the catalog — +/// see `tools-catalog.ts`'s doc comment for why it's a dotfile. +pub const ENDPOINT_FILENAME: &str = ".endpoint.json"; + +#[derive(Debug, Clone)] +pub struct McpEndpoint { + pub url: String, + pub headers: Value, + pub expires_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CatalogTool { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_schema: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_schema: Option, +} + +pub struct CatalogFile { + pub filename: String, + pub content: String, +} + +fn sanitize_re() -> &'static Regex { + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + RE.get_or_init(|| Regex::new(r"[^A-Za-z0-9_.\-]").unwrap()) +} + +/// One JSON Schema file per tool: `.json` holding `{ name, +/// description?, inputSchema, outputSchema? }`. Distinct tools whose names +/// sanitize to the same filename get a `-2`, `-3`… suffix so no tool is +/// silently dropped; stable for a given input order. +pub fn tool_catalog_files(tools: &[CatalogTool]) -> Vec { + let mut used: HashSet = HashSet::new(); + tools + .iter() + .map(|tool| { + let mut body = serde_json::Map::new(); + body.insert("name".to_string(), Value::String(tool.name.clone())); + if let Some(desc) = &tool.description { + if !desc.is_empty() { + body.insert("description".to_string(), Value::String(desc.clone())); + } + } + body.insert( + "inputSchema".to_string(), + tool.input_schema + .clone() + .unwrap_or_else(|| json!({"type": "object"})), + ); + if let Some(out) = &tool.output_schema { + body.insert("outputSchema".to_string(), out.clone()); + } + let sanitized = sanitize_re().replace_all(&tool.name, "_").to_string(); + let base = if sanitized.is_empty() { + "tool".to_string() + } else { + sanitized + }; + let mut filename = format!("{base}.json"); + let mut i = 2; + while used.contains(&filename) { + filename = format!("{base}-{i}.json"); + i += 1; + } + used.insert(filename.clone()); + let content = format!( + "{}\n", + serde_json::to_string_pretty(&Value::Object(body)).unwrap_or_default() + ); + CatalogFile { filename, content } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_catalog_files_sanitizes_and_dedupes_names() { + let tools = vec![ + CatalogTool { + name: "a/b".to_string(), + description: None, + input_schema: None, + output_schema: None, + }, + CatalogTool { + name: "a_b".to_string(), + description: None, + input_schema: None, + output_schema: None, + }, + ]; + let files = tool_catalog_files(&tools); + assert_eq!(files[0].filename, "a_b.json"); + assert_eq!(files[1].filename, "a_b-2.json"); + } + + #[test] + fn tool_catalog_files_defaults_input_schema_to_object() { + let tools = vec![CatalogTool { + name: "LIST_CUSTOMERS".to_string(), + description: Some("List customers".to_string()), + input_schema: None, + output_schema: None, + }]; + let files = tool_catalog_files(&tools); + let parsed: Value = serde_json::from_str(&files[0].content).unwrap(); + assert_eq!(parsed["name"], "LIST_CUSTOMERS"); + assert_eq!(parsed["description"], "List customers"); + assert_eq!(parsed["inputSchema"], json!({"type": "object"})); + } +} diff --git a/apps/native/crates/local-api/src/routes/fs/tools_transaction.rs b/apps/native/crates/local-api/src/routes/fs/tools_transaction.rs new file mode 100644 index 0000000000..b14210e227 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/fs/tools_transaction.rs @@ -0,0 +1,764 @@ +//! Crash-recoverable installation for the generated MCP tools catalog. +//! +//! `.endpoint.json` is part of the catalog, not an independently committed +//! credential. A complete replacement directory is prepared and synced before +//! the old directory moves. The short visible transition is therefore: +//! +//! ```text +//! target(old) -> previous-catalog +//! catalog(new) -> target(new) +//! ``` +//! +//! The target may be briefly absent between those two same-filesystem renames, +//! but it can never contain a new endpoint beside old tool files. Durable phase +//! markers make every crash window recoverable. Ambiguous/corrupt state fails +//! closed and preserves the only known-good backup for a later retry or manual +//! repair. + +use std::io; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use tokio::io::AsyncWriteExt; + +const TRANSACTION_FILE: &str = "tools-catalog-transaction.json"; +const PREPARED_MARKER: &str = "tools-catalog.prepared"; +const SWAPPING_MARKER: &str = "tools-catalog.swapping"; +const INSTALLED_MARKER: &str = "tools-catalog.installed"; +const STAGED_CATALOG: &str = "catalog"; +const PREVIOUS_CATALOG: &str = "previous-catalog"; +const ENDPOINT_FILE: &str = ".endpoint.json"; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct TransactionHeader { + kind: String, + version: u8, +} + +impl TransactionHeader { + fn current() -> Self { + Self { + kind: "tools-catalog".to_string(), + version: 1, + } + } + + fn validate(self) -> io::Result<()> { + if self.kind == "tools-catalog" && self.version == 1 { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "unsupported tools catalog transaction kind={:?} version={}", + self.kind, self.version + ), + )) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TransactionPhase { + Created, + Prepared, + Swapping, + Installed, +} + +/// Installs one fully prepared catalog. The caller must serialize calls and +/// hold the mutation commit permit for the duration of this function. +pub(super) async fn install( + stage_dir: &Path, + staged_catalog: &Path, + target: &Path, +) -> io::Result<()> { + let swap_result = install_inner(stage_dir, staged_catalog, target).await; + if let Err(install_error) = swap_result { + let transaction_exists = path_exists(&stage_dir.join(TRANSACTION_FILE)).await?; + let backup_exists = path_exists(&stage_dir.join(PREVIOUS_CATALOG)).await?; + if !transaction_exists && !backup_exists { + remove_path(stage_dir).await?; + return Err(install_error); + } + return match recover_transaction(stage_dir, target).await { + Ok(()) => Err(install_error), + Err(recovery_error) => Err(io::Error::other(format!( + "tools catalog install failed ({install_error}); automatic recovery also failed \ + ({recovery_error}); recovery artifacts preserved at {}", + stage_dir.display() + ))), + }; + } + + // Structural validation is not cleanup: if the installed target became + // incoherent, report failure and preserve the prior catalog for recovery. + validate_catalog_structure(target, "installed").await?; + + // Installation is already coherent and durable. Cleanup failure must not + // turn a successful update into a false retry (or encourage a second + // request); the installed journal is sufficient for startup/pre-commit + // recovery to finish garbage collection later. + if let Err(error) = finish_installed(stage_dir, target).await { + tracing::warn!( + path = %stage_dir.display(), + %error, + "tools catalog installed but transaction cleanup was deferred" + ); + } + Ok(()) +} + +async fn install_inner(stage_dir: &Path, staged_catalog: &Path, target: &Path) -> io::Result<()> { + validate_catalog_structure(staged_catalog, "staged").await?; + write_transaction_header(stage_dir).await?; + write_phase_marker(stage_dir, PREPARED_MARKER).await?; + if let Some(parent) = stage_dir.parent() { + // The transaction directory itself must survive a power loss before + // the only prior catalog is renamed into it. + sync_directory(parent).await?; + } + write_phase_marker(stage_dir, SWAPPING_MARKER).await?; + + let backup = stage_dir.join(PREVIOUS_CATALOG); + if path_exists(&backup).await? { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!( + "tools catalog backup already exists at {}", + backup.display() + ), + )); + } + + if path_exists(target).await? { + tokio::fs::rename(target, &backup).await?; + sync_rename_parents(target, &backup).await?; + } + + tokio::fs::rename(staged_catalog, target).await?; + sync_rename_parents(staged_catalog, target).await?; + write_phase_marker(stage_dir, INSTALLED_MARKER).await?; + Ok(()) +} + +/// Recovers catalog transactions and removes unrelated stale mutation stages +/// after the app-root instance lock has been acquired. +pub(super) async fn recover_mutation_stages(mutation_root: &Path, target: &Path) -> io::Result<()> { + recover_stages(mutation_root, target, None, true).await +} + +/// Finishes older catalog transactions immediately before a new catalog swap. +/// Long-running unrelated mutation stages are left alone; `current_stage` is +/// the fully prepared transaction owned by the caller and must not be touched. +pub(super) async fn recover_catalog_transactions( + mutation_root: &Path, + target: &Path, + current_stage: &Path, +) -> io::Result<()> { + recover_stages(mutation_root, target, Some(current_stage), false).await +} + +async fn recover_stages( + mutation_root: &Path, + target: &Path, + skip: Option<&Path>, + clean_unrelated: bool, +) -> io::Result<()> { + let mut entries = match tokio::fs::read_dir(mutation_root).await { + Ok(entries) => entries, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + let mut stages = Vec::::new(); + while let Some(entry) = entries.next_entry().await? { + stages.push(entry.path()); + } + stages.sort(); + + for stage in stages { + if skip.is_some_and(|skip| skip == stage.as_path()) { + continue; + } + let metadata = match tokio::fs::symlink_metadata(&stage).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(error), + }; + if !metadata.is_dir() { + if clean_unrelated { + remove_path(&stage).await?; + } + continue; + } + + let transaction_file = stage.join(TRANSACTION_FILE); + let backup = stage.join(PREVIOUS_CATALOG); + if path_exists(&transaction_file).await? { + recover_transaction(&stage, target).await.map_err(|error| { + io::Error::new( + error.kind(), + format!( + "failed to recover tools catalog transaction {}: {error}", + stage.display() + ), + ) + })?; + } else if path_exists(&backup).await? { + // Compatibility with the pre-journal implementation: its only + // recoverable evidence was `previous-catalog`. Never discard that + // sole prior catalog merely because the process restarted. + recover_legacy_backup(&stage, target).await?; + } else if clean_unrelated { + remove_path(&stage).await?; + } + } + Ok(()) +} + +async fn recover_transaction(stage_dir: &Path, target: &Path) -> io::Result<()> { + read_and_validate_header(stage_dir).await?; + let phase = read_phase(stage_dir).await?; + let staged = stage_dir.join(STAGED_CATALOG); + let backup = stage_dir.join(PREVIOUS_CATALOG); + let target_exists = path_exists(target).await?; + let staged_exists = path_exists(&staged).await?; + let backup_exists = path_exists(&backup).await?; + + match phase { + TransactionPhase::Installed => { + if target_exists { + finish_installed(stage_dir, target).await + } else if backup_exists { + // The installed target disappeared unexpectedly. The backup + // is the only known-good catalog; restore it rather than + // deleting the last copy during cleanup. + restore_backup(stage_dir, target).await + } else { + Err(invalid_layout( + stage_dir, + "installed marker exists but neither target nor backup exists", + )) + } + } + TransactionPhase::Swapping => match (target_exists, staged_exists, backup_exists) { + (true, false, _) => finish_installed(stage_dir, target).await, + (false, _, true) => restore_backup(stage_dir, target).await, + (false, true, false) => { + // There was no previous catalog. Complete the one atomic + // visibility step rather than leaving the catalog absent. + ensure_target_parent(target).await?; + tokio::fs::rename(&staged, target).await?; + sync_rename_parents(&staged, target).await?; + write_phase_marker(stage_dir, INSTALLED_MARKER).await?; + finish_installed(stage_dir, target).await + } + (true, true, false) => { + // The old target was never moved. Abort the prepared update. + remove_path(stage_dir).await + } + (true, true, true) => Err(invalid_layout( + stage_dir, + "target, staged catalog, and backup all exist", + )), + (false, false, false) => Err(invalid_layout( + stage_dir, + "all catalog copies disappeared during swap", + )), + }, + TransactionPhase::Created | TransactionPhase::Prepared => { + match (target_exists, backup_exists) { + (true, false) => remove_path(stage_dir).await, + (false, true) => restore_backup(stage_dir, target).await, + (true, true) => Err(invalid_layout( + stage_dir, + "backup exists before the swapping phase", + )), + (false, false) if staged_exists => { + // A first-ever catalog was fully prepared before the + // crash. Installing it produces a coherent catalog and + // avoids booting with no catalog at all. + ensure_target_parent(target).await?; + tokio::fs::rename(&staged, target).await?; + sync_rename_parents(&staged, target).await?; + write_phase_marker(stage_dir, INSTALLED_MARKER).await?; + finish_installed(stage_dir, target).await + } + (false, false) => remove_path(stage_dir).await, + } + } + } +} + +async fn recover_legacy_backup(stage_dir: &Path, target: &Path) -> io::Result<()> { + let backup = stage_dir.join(PREVIOUS_CATALOG); + let staged = stage_dir.join(STAGED_CATALOG); + if path_exists(target).await? { + if path_exists(&staged).await? { + return Err(invalid_layout( + stage_dir, + "legacy target, staged catalog, and backup all exist", + )); + } + // The second rename completed: target is a whole directory and is + // expected to be coherent. Verify that invariant before deleting the + // legacy transaction's only known-good catalog. + validate_catalog_structure(target, "installed").await?; + let target_parent = target.parent().ok_or_else(|| { + invalid_layout( + stage_dir, + "installed legacy target has no parent directory to sync", + ) + })?; + sync_directory(target_parent).await?; + remove_path(&backup).await?; + remove_path(stage_dir).await + } else { + restore_backup(stage_dir, target).await + } +} + +async fn restore_backup(stage_dir: &Path, target: &Path) -> io::Result<()> { + let backup = stage_dir.join(PREVIOUS_CATALOG); + if path_exists(target).await? { + return Err(invalid_layout( + stage_dir, + "refusing to overwrite an existing target while restoring backup", + )); + } + ensure_target_parent(target).await?; + tokio::fs::rename(&backup, target).await?; + sync_rename_parents(&backup, target).await?; + remove_path(stage_dir).await +} + +async fn ensure_target_parent(target: &Path) -> io::Result<()> { + if let Some(parent) = target.parent() { + tokio::fs::create_dir_all(parent).await?; + } + Ok(()) +} + +async fn finish_installed(stage_dir: &Path, target: &Path) -> io::Result<()> { + finish_installed_with_parent_sync_hook(stage_dir, target, |_| Ok(())).await +} + +/// Completes garbage collection only after the installed directory entry is +/// durable in its parent. `before_parent_sync` is a deterministic fault seam +/// for the power-loss window between target rename and parent-directory fsync. +async fn finish_installed_with_parent_sync_hook( + stage_dir: &Path, + target: &Path, + before_parent_sync: F, +) -> io::Result<()> +where + F: FnOnce(&Path) -> io::Result<()>, +{ + if !path_exists(target).await? { + return Err(invalid_layout( + stage_dir, + "cannot finish transaction without an installed target", + )); + } + // A whole-directory rename is the coherence boundary, but recovery may + // encounter a target changed outside this transaction. Never delete the + // only known-good prior catalog until the installed directory still has + // the minimum valid catalog shape. + validate_catalog_structure(target, "installed").await?; + let backup = stage_dir.join(PREVIOUS_CATALOG); + if path_exists(&backup).await? { + let target_parent = target.parent().ok_or_else(|| { + invalid_layout( + stage_dir, + "installed target has no parent directory to sync", + ) + })?; + // The target rename is not crash-durable merely because the target + // directory itself was synced. Persist its directory entry before + // unlinking the only known-good prior catalog. If this fails, return + // with both the target and backup intact so startup recovery retries + // this exact fence. + before_parent_sync(target_parent)?; + sync_directory(target_parent).await?; + remove_path(&backup).await?; + sync_directory(stage_dir).await?; + } + remove_path(stage_dir).await +} + +async fn validate_catalog_structure(catalog: &Path, label: &str) -> io::Result<()> { + let metadata = tokio::fs::symlink_metadata(catalog).await?; + if !metadata.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{label} tools catalog is not a directory"), + )); + } + let endpoint = catalog.join(ENDPOINT_FILE); + let endpoint_metadata = tokio::fs::symlink_metadata(&endpoint).await?; + if !endpoint_metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{label} tools catalog endpoint is not a regular file"), + )); + } + sync_directory(catalog).await +} + +async fn write_transaction_header(stage_dir: &Path) -> io::Result<()> { + let content = serde_json::to_vec(&TransactionHeader::current()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + write_new_durable_file(&stage_dir.join(TRANSACTION_FILE), &content).await +} + +async fn read_and_validate_header(stage_dir: &Path) -> io::Result<()> { + let content = tokio::fs::read(stage_dir.join(TRANSACTION_FILE)).await?; + let header: TransactionHeader = serde_json::from_slice(&content) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + header.validate() +} + +async fn read_phase(stage_dir: &Path) -> io::Result { + if path_exists(&stage_dir.join(INSTALLED_MARKER)).await? { + Ok(TransactionPhase::Installed) + } else if path_exists(&stage_dir.join(SWAPPING_MARKER)).await? { + Ok(TransactionPhase::Swapping) + } else if path_exists(&stage_dir.join(PREPARED_MARKER)).await? { + Ok(TransactionPhase::Prepared) + } else { + Ok(TransactionPhase::Created) + } +} + +async fn write_phase_marker(stage_dir: &Path, name: &str) -> io::Result<()> { + let path = stage_dir.join(name); + if path_exists(&path).await? { + return Ok(()); + } + write_new_durable_file(&path, b"1\n").await +} + +async fn write_new_durable_file(path: &Path, content: &[u8]) -> io::Result<()> { + let mut options = tokio::fs::OpenOptions::new(); + options.write(true).create_new(true); + let mut file = options.open(path).await?; + file.write_all(content).await?; + file.sync_all().await?; + drop(file); + if let Some(parent) = path.parent() { + sync_directory(parent).await?; + } + Ok(()) +} + +async fn sync_rename_parents(from: &Path, to: &Path) -> io::Result<()> { + // Persist the new name before persisting removal of the old one. On a + // cross-directory rename, syncing the source first creates a power-loss + // window where neither directory entry is durable. + if let Some(parent) = to.parent() { + sync_directory(parent).await?; + } + if let Some(parent) = from.parent() { + if Some(parent) != to.parent() { + sync_directory(parent).await?; + } + } + Ok(()) +} + +#[cfg(unix)] +async fn sync_directory(path: &Path) -> io::Result<()> { + tokio::fs::File::open(path).await?.sync_all().await +} + +#[cfg(not(unix))] +async fn sync_directory(_path: &Path) -> io::Result<()> { + // Windows directory handles require platform-specific flags. Atomic rename + // still preserves catalog coherence; the journal remains the recovery + // oracle, while directory-entry durability follows the filesystem policy. + Ok(()) +} + +async fn path_exists(path: &Path) -> io::Result { + match tokio::fs::symlink_metadata(path).await { + Ok(_) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +async fn remove_path(path: &Path) -> io::Result<()> { + let metadata = match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + if metadata.is_dir() { + tokio::fs::remove_dir_all(path).await + } else { + tokio::fs::remove_file(path).await + } +} + +fn invalid_layout(stage_dir: &Path, detail: &str) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "ambiguous tools catalog transaction at {}: {detail}; preserving recovery artifacts", + stage_dir.display() + ), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn write_catalog(path: &Path, endpoint: &str, tool: &str) { + tokio::fs::create_dir_all(path).await.unwrap(); + tokio::fs::write(path.join(ENDPOINT_FILE), endpoint) + .await + .unwrap(); + tokio::fs::write(path.join("tool.json"), tool) + .await + .unwrap(); + } + + async fn begin_swap(root: &Path, target: &Path) -> PathBuf { + let stage = root.join("txn"); + let staged = stage.join(STAGED_CATALOG); + tokio::fs::create_dir_all(root).await.unwrap(); + write_catalog(target, "old-endpoint", "old-tool").await; + write_catalog(&staged, "new-endpoint", "new-tool").await; + write_transaction_header(&stage).await.unwrap(); + write_phase_marker(&stage, PREPARED_MARKER).await.unwrap(); + write_phase_marker(&stage, SWAPPING_MARKER).await.unwrap(); + tokio::fs::rename(target, stage.join(PREVIOUS_CATALOG)) + .await + .unwrap(); + stage + } + + #[tokio::test] + async fn recovery_restores_only_prior_catalog_when_swap_was_interrupted() { + let root = tempfile::tempdir().unwrap(); + let mutations = root.path().join("mutations"); + let target = root.path().join("repo/.deco/tools"); + let stage = begin_swap(&mutations, &target).await; + + recover_mutation_stages(&mutations, &target).await.unwrap(); + + assert_eq!( + tokio::fs::read_to_string(target.join(ENDPOINT_FILE)) + .await + .unwrap(), + "old-endpoint" + ); + assert_eq!( + tokio::fs::read_to_string(target.join("tool.json")) + .await + .unwrap(), + "old-tool" + ); + assert!(!stage.exists()); + } + + #[tokio::test] + async fn recovery_finishes_new_catalog_after_atomic_directory_install() { + let root = tempfile::tempdir().unwrap(); + let mutations = root.path().join("mutations"); + let target = root.path().join("repo/.deco/tools"); + let stage = begin_swap(&mutations, &target).await; + tokio::fs::rename(stage.join(STAGED_CATALOG), &target) + .await + .unwrap(); + + recover_mutation_stages(&mutations, &target).await.unwrap(); + + assert_eq!( + tokio::fs::read_to_string(target.join(ENDPOINT_FILE)) + .await + .unwrap(), + "new-endpoint" + ); + assert_eq!( + tokio::fs::read_to_string(target.join("tool.json")) + .await + .unwrap(), + "new-tool" + ); + assert!(!stage.exists()); + } + + #[tokio::test] + async fn target_parent_sync_failure_retains_backup_until_recovery_retries_it() { + let root = tempfile::tempdir().unwrap(); + let mutations = root.path().join("mutations"); + let target = root.path().join("repo/.deco/tools"); + let stage = begin_swap(&mutations, &target).await; + let backup = stage.join(PREVIOUS_CATALOG); + tokio::fs::rename(stage.join(STAGED_CATALOG), &target) + .await + .unwrap(); + + let expected_parent = target.parent().unwrap().to_path_buf(); + let error = finish_installed_with_parent_sync_hook(&stage, &target, |parent| { + assert_eq!(parent, expected_parent); + assert!( + backup.exists(), + "backup was deleted before target-parent sync began" + ); + Err(io::Error::other("injected target-parent fsync failure")) + }) + .await + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::Other); + assert!( + target.exists(), + "installed catalog disappeared after fsync failure" + ); + assert!( + backup.exists(), + "known-good backup must survive target-parent fsync failure" + ); + assert!( + stage.exists(), + "recovery journal must survive fsync failure" + ); + + // This is the exact startup path after the failed durability fence. + // It must retry target.parent() fsync before deleting the backup. + recover_mutation_stages(&mutations, &target).await.unwrap(); + + assert_eq!( + tokio::fs::read_to_string(target.join(ENDPOINT_FILE)) + .await + .unwrap(), + "new-endpoint" + ); + assert!( + !stage.exists(), + "successful recovery did not finish cleanup" + ); + } + + #[tokio::test] + async fn recovery_preserves_backup_when_installed_target_is_incoherent() { + let root = tempfile::tempdir().unwrap(); + let mutations = root.path().join("mutations"); + let target = root.path().join("repo/.deco/tools"); + let stage = begin_swap(&mutations, &target).await; + tokio::fs::rename(stage.join(STAGED_CATALOG), &target) + .await + .unwrap(); + tokio::fs::remove_file(target.join(ENDPOINT_FILE)) + .await + .unwrap(); + + let error = recover_mutation_stages(&mutations, &target) + .await + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert!( + stage.join(PREVIOUS_CATALOG).exists(), + "known-good backup was deleted before target validation" + ); + assert!(stage.exists(), "recovery evidence must remain intact"); + } + + #[tokio::test] + async fn corrupt_journal_never_deletes_the_only_prior_catalog() { + let root = tempfile::tempdir().unwrap(); + let mutations = root.path().join("mutations"); + let stage = mutations.join("txn"); + let backup = stage.join(PREVIOUS_CATALOG); + let target = root.path().join("repo/.deco/tools"); + write_catalog(&backup, "only-endpoint", "only-tool").await; + tokio::fs::write(stage.join(TRANSACTION_FILE), b"not-json") + .await + .unwrap(); + + let error = recover_mutation_stages(&mutations, &target) + .await + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(backup.exists(), "the only prior catalog must be preserved"); + assert!(!target.exists()); + } + + #[tokio::test] + async fn legacy_backup_is_restored_instead_of_blindly_deleted() { + let root = tempfile::tempdir().unwrap(); + let mutations = root.path().join("mutations"); + let stage = mutations.join("legacy"); + let target = root.path().join("repo/.deco/tools"); + write_catalog( + &stage.join(PREVIOUS_CATALOG), + "legacy-endpoint", + "legacy-tool", + ) + .await; + + recover_mutation_stages(&mutations, &target).await.unwrap(); + + assert_eq!( + tokio::fs::read_to_string(target.join(ENDPOINT_FILE)) + .await + .unwrap(), + "legacy-endpoint" + ); + assert!(!stage.exists()); + } + + #[tokio::test] + async fn legacy_recovery_preserves_backup_when_target_is_incoherent() { + let root = tempfile::tempdir().unwrap(); + let mutations = root.path().join("mutations"); + let stage = mutations.join("legacy"); + let backup = stage.join(PREVIOUS_CATALOG); + let target = root.path().join("repo/.deco/tools"); + write_catalog(&backup, "legacy-endpoint", "legacy-tool").await; + tokio::fs::create_dir_all(&target).await.unwrap(); + tokio::fs::write(target.join("tool.json"), "incomplete-tool") + .await + .unwrap(); + + let error = recover_mutation_stages(&mutations, &target) + .await + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert!(backup.exists(), "known-good legacy backup was discarded"); + assert!(stage.exists(), "legacy recovery evidence must be retained"); + } + + #[tokio::test] + async fn install_replaces_endpoint_and_tool_files_as_one_directory() { + let root = tempfile::tempdir().unwrap(); + let stage = root.path().join("mutations/txn"); + let staged = stage.join(STAGED_CATALOG); + let target = root.path().join("repo/.deco/tools"); + write_catalog(&target, "old-endpoint", "old-tool").await; + write_catalog(&staged, "new-endpoint", "new-tool").await; + + install(&stage, &staged, &target).await.unwrap(); + + assert_eq!( + tokio::fs::read_to_string(target.join(ENDPOINT_FILE)) + .await + .unwrap(), + "new-endpoint" + ); + assert_eq!( + tokio::fs::read_to_string(target.join("tool.json")) + .await + .unwrap(), + "new-tool" + ); + assert!(!stage.exists()); + } +} diff --git a/apps/native/crates/local-api/src/routes/git.rs b/apps/native/crates/local-api/src/routes/git.rs new file mode 100644 index 0000000000..e4a9d47218 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/git.rs @@ -0,0 +1,2758 @@ +//! `/_sandbox/git/*` — byte-parity target: `daemon/routes/git.ts` + +//! `daemon/git/rebase-onto-base.ts`, oracle `daemon.git.e2e.test.ts` +//! (minus the setup/clone-dependent specs — no clone pipeline, see +//! the native local-API contract). +//! +//! ## Scope vs. the daemon +//! +//! Local-api never clones — `state.repo_dir` is the user's *existing* +//! project folder (contract doc, env table). Two daemon behaviors that only +//! make sense for a cloned/managed repo are deliberately dropped here (both +//! documented at their call sites below): +//! +//! - No `getCloneUrl`/`syncOriginRemote` step in `publish()`: the daemon +//! rewrites `origin` to an OAuth-token-embedded clone URL from its config +//! store before pushing. Local-api has no such credentialed-URL config +//! (Upstream Proxy/Keychain OAuth is Phase 3) — the user's own git +//! credential setup (SSH key, `gh` CLI, macOS Git Credential Manager) +//! already makes `git push` work in this repo today, same as if they ran +//! it from a terminal. +//! - No "GitHub push requires an authenticated clone URL" guard: that guard +//! exists purely to catch the daemon's own clone-URL-rewrite silently +//! no-op'ing; with no rewrite step, it has nothing to guard. +//! - No `OperatorIdentity`/co-author trailer on generated commits +//! (`Before rebase`, `Update from sandbox`): the daemon appends a +//! `Co-authored-by:` trailer for the Studio user driving the sandbox. +//! Local-api has no such concept (single local user, their own git +//! identity) — commits use the plain message. +//! +//! Every other piece of argv/env parity (the `--no-verify` + empty +//! `core.hooksPath` hook bypass, `--force-with-lease` → `--force` fallback, +//! `GIT_CEILING_DIRECTORIES`/`GIT_OPTIONAL_LOCKS` pinning, the protected- +//! branch push guard, `-c safe.directory=*` on every invocation) is ported +//! faithfully — see the two env profiles ([`route_env`]/[`ceiling_env`]) +//! and the per-function doc comments below. +//! +//! ## Shutdown-hook wiring +//! +//! [`register`] builds the SIGTERM publish-on-shutdown hook (byte-parity +//! with `entry.ts::shutdown()`) and registers it via +//! `state.shutdown.register(..)`. `main.rs` (shared) calls +//! `routes::git::register(&state);` once, right after `AppState` is +//! constructed and before `axum::serve` starts — the only point in this +//! crate a `git`-owned file can reach at boot time without editing +//! `main.rs` itself. See `register_publishes_on_shutdown_run` below for a +//! direct test of the hook body (it drives `state.shutdown.run()` rather +//! than an OS signal, since the daemon e2e harness always `SIGKILL`s and +//! never exercises this path — see `daemon.e2e.helpers.ts::stopDaemon`). +//! +//! ## `"branch"` broadcaster event +//! +//! [`emit_branch_event`] fires after a successful publish/discard/rebase +//! (the native module-ownership contract's "Emits" table), shaped exactly like the daemon's +//! `BranchStatusMonitor` reconnect-snapshot payload (`{ meta: { kind: +//! "ready", branch, base, workingTreeDirty, unpushed, aheadOfBase, +//! behindBase, headSha } }`, see `daemon/git/branch-status.ts` and +//! `daemon.sse-shapes.e2e.test.ts`'s "branch" assertions). +//! +//! Reconnect snapshots are recomputed from the resolved sandbox target's +//! repository by `routes/events.rs`; there is deliberately no process-global +//! "last branch" cache. A process-global cache leaks one sandbox's branch into +//! another and starts empty after an app restart even though the worktree +//! survived. Per-sandbox live streams additionally run a bounded poller while +//! they have subscribers, matching the old daemon monitor's three-second +//! fallback without retaining another in-memory transcript. + +use std::collections::HashSet; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::Duration; + +use axum::{body::Bytes, extract::State, http::StatusCode, Json}; +use futures_util::FutureExt; +use serde_json::{json, Value}; +use tokio::sync::oneshot; + +use crate::error::{ApiError, ApiResult}; +use crate::process_group::ProcessGroupChild; +use crate::state::AppState; +use crate::tasks::{ + registry::now_ms, KillHandle, KillSignal, ProcessController, TaskEntry, TaskStatus, TaskSummary, +}; + +const GIT_TIMEOUT: Duration = Duration::from_secs(30); +const GIT_TERM_GRACE: Duration = Duration::from_millis(500); +const GIT_KILL_GRACE: Duration = Duration::from_secs(2); + +tokio::task_local! { + /// One controller per high-level route/shutdown-publish operation. Keeping + /// this task-local lets the deep git helper graph remain shared with setup + /// probes while every command polled inside an owned route observes the + /// same durable TERM -> KILL request. + static GIT_PROCESS_CONTROLLER: ProcessController; + /// App-root-wide crash/relaunch fence shared by every process family. + /// Keeping it beside the controller lets the deep git helper graph stay + /// parameter-free without falling back to a process-global singleton. + static GIT_CHILD_LIFETIME_LOCK_PATH: std::path::PathBuf; +} + +// --------------------------------------------------------------------------- +// HTTP handlers +// --------------------------------------------------------------------------- + +pub async fn status(State(state): State) -> ApiResult> { + run_owned_git_route(state.clone(), "git status", status_inner(state)).await +} + +async fn status_inner(state: AppState) -> ApiResult> { + require_git_repo(&state.repo_dir).await?; + let result = compute_status(&state.repo_dir) + .await + .map_err(|e| ApiError::internal(e.message))?; + Ok(Json(result)) +} + +pub async fn diff(State(state): State, body: Bytes) -> ApiResult> { + run_owned_git_route(state.clone(), "git diff", diff_inner(state, body)).await +} + +async fn diff_inner(state: AppState, body: Bytes) -> ApiResult> { + require_git_repo(&state.repo_dir).await?; + + // Byte-parity with `makeGitDiffHandler`: a malformed/empty/absent body is + // NOT an error here (unlike publish/discard/rebase below) — it just + // means "working-tree diff", matching the route's GET-or-POST shape. + let mut base: Option = None; + let mut head_sha: Option = None; + if !body.is_empty() { + if let Ok(v) = serde_json::from_slice::(&body) { + if let Some(b) = v.get("base").and_then(Value::as_str) { + let b = b.trim(); + if !b.is_empty() { + base = Some(b.to_string()); + } + } + if let Some(h) = v.get("headSha").and_then(Value::as_str) { + let h = h.trim(); + if !h.is_empty() { + head_sha = Some(h.to_string()); + } + } + } + } + + let result = if let Some(base) = base { + compute_diff_against_base(&state.repo_dir, &base, head_sha.as_deref()) + .await + .map_err(|e| route_error_response(e, false))? + } else { + compute_diff(&state.repo_dir) + .await + .map_err(|e| ApiError::internal(e.message))? + }; + Ok(Json(result)) +} + +pub async fn publish(State(state): State, body: Bytes) -> ApiResult> { + run_owned_git_route(state.clone(), "git publish", publish_inner(state, body)).await +} + +async fn publish_inner(state: AppState, body: Bytes) -> ApiResult> { + require_git_repo(&state.repo_dir).await?; + let parsed = parse_json_body(&body).map_err(ApiError::bad_request)?; + let message = parsed + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + + match publish_internal(&state.repo_dir, &message).await { + Ok(pushed) => { + if pushed { + emit_branch_event(&state.repo_dir, &state.broadcaster).await; + } + Ok(Json(json!({ "pushed": pushed }))) + } + Err(e) => Err(route_error_response(e, true)), + } +} + +pub async fn discard(State(state): State, body: Bytes) -> ApiResult> { + run_owned_git_route(state.clone(), "git discard", discard_inner(state, body)).await +} + +async fn discard_inner(state: AppState, body: Bytes) -> ApiResult> { + require_git_repo(&state.repo_dir).await?; + let parsed = parse_json_body(&body).map_err(ApiError::bad_request)?; + let filepaths = match parsed.get("filepaths").and_then(Value::as_array) { + Some(arr) if !arr.is_empty() => arr + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect::>(), + _ => return Err(ApiError::bad_request("filepaths is required")), + }; + + match discard_files(&state.app_root, &state.repo_dir, &filepaths).await { + Ok(()) => { + emit_branch_event(&state.repo_dir, &state.broadcaster).await; + Ok(Json(json!({ "success": true }))) + } + // Byte-parity with `makeGitDiscardHandler`'s catch-all 500 (including + // for a caller mistake like an escaping path) — see the module doc's + // note that `error.rs`'s "500 reserved for local-api's own bugs" + // guidance and this route's byte-parity target are in tension here; + // byte-parity wins since it's the stated evaluation target and no + // test in the oracle distinguishes the two. + Err(msg) => Err(ApiError::internal(msg)), + } +} + +pub async fn rebase(State(state): State, body: Bytes) -> ApiResult> { + run_owned_git_route(state.clone(), "git rebase", rebase_inner(state, body)).await +} + +async fn rebase_inner(state: AppState, body: Bytes) -> ApiResult> { + require_git_repo(&state.repo_dir).await?; + let parsed = parse_json_body(&body).map_err(ApiError::bad_request)?; + let base = parsed + .get("base") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or(""); + if base.is_empty() { + return Err(ApiError::bad_request("base is required")); + } + + match rebase_onto_base(&state.repo_dir, base).await { + Ok(()) => { + emit_branch_event(&state.repo_dir, &state.broadcaster).await; + Ok(Json(json!({ "rebased": true }))) + } + Err(e) => Err(route_error_response(e, true)), + } +} + +/// Request-side cancellation bridge. The operation itself lives in a detached, +/// registry-visible owner; dropping only the HTTP waiter requests TERM and +/// leaves that owner responsible for escalation, complete process-group reap, +/// and the terminal registry transition. +struct GitCancelOnDrop { + kill: Option, +} + +impl GitCancelOnDrop { + fn new(kill: KillHandle) -> Self { + Self { kill: Some(kill) } + } + + fn disarm(&mut self) { + self.kill = None; + } +} + +impl Drop for GitCancelOnDrop { + fn drop(&mut self) { + if let Some(kill) = self.kill.take() { + let _ = kill(KillSignal::Term); + } + } +} + +/// Registers and spawns one hidden owner for a complete high-level Git +/// operation. Registration is synchronous and happens before `tokio::spawn`, +/// so callers can hold shutdown admission through this function and release it +/// immediately afterward: every accepted operation is then enumerable through +/// `TaskRegistry`, without holding a read-admission guard for its full runtime. +fn spawn_git_operation_owner( + state: AppState, + command: &str, + operation: F, +) -> (KillHandle, oneshot::Receiver>) +where + T: Send + 'static, + F: Future + Send + 'static, +{ + let id = format!("internal-git-{}", uuid::Uuid::new_v4()); + let controller = ProcessController::new(); + let kill_handle = controller.kill_handle(); + state.tasks.insert(TaskEntry::new_internal( + TaskSummary { + id: id.clone(), + command: command.to_string(), + status: TaskStatus::Running, + exit_code: None, + started_at: now_ms(), + finished_at: None, + timed_out: false, + truncated: false, + log_name: None, + intentional: None, + }, + Some(kill_handle.clone()), + )); + + let tasks = state.tasks.clone(); + let child_lifetime_lock_path = state.tasks.child_lifetime_lock_path().to_path_buf(); + let owner_id = id.clone(); + let (result_tx, result_rx) = oneshot::channel(); + tokio::spawn(async move { + let operation = GIT_CHILD_LIFETIME_LOCK_PATH.scope(child_lifetime_lock_path, operation); + let driven = std::panic::AssertUnwindSafe( + GIT_PROCESS_CONTROLLER.scope(controller.clone(), operation), + ) + .catch_unwind() + .await; + + let requested = controller.requested(); + let (status, exit_code) = match (requested, &driven) { + (Some(signal), _) => (TaskStatus::Killed, signal.exit_code()), + (None, Ok(_)) => (TaskStatus::Exited, 0), + (None, Err(_)) => (TaskStatus::Failed, -1), + }; + + // Every `run_git_raw` inside the scoped future returns only after its + // child and inherited stdout/stderr holders are gone. Therefore this + // terminal transition is also the process-tree join outcome—not merely + // a signal-request acknowledgement. + tasks.finalize(&owner_id, status, exit_code, false); + let _ = tasks.remove(&owner_id).await; + let result = driven.map_err(|_| "git operation owner panicked"); + let _ = result_tx.send(result); + }); + + (kill_handle, result_rx) +} + +/// Admits only the spawn + hidden-owner registration critical section. The +/// detached owner remains visible to coordinated shutdown until all of its Git +/// process groups have been joined and the operation has finalized. +async fn run_owned_git_route(state: AppState, command: &str, operation: F) -> ApiResult +where + T: Send + 'static, + F: Future> + Send + 'static, +{ + let shutdown = state.shutdown.clone(); + let Some(admission) = shutdown.admit_work().await else { + return Err(ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "application is shutting down", + )); + }; + let (kill_handle, result_rx) = spawn_git_operation_owner(state, command, operation); + drop(admission); + + let mut cancel_on_drop = GitCancelOnDrop::new(kill_handle); + let result = result_rx + .await + .map_err(|_| ApiError::internal("git operation owner stopped unexpectedly"))? + .map_err(ApiError::internal)?; + cancel_on_drop.disarm(); + result +} + +/// Registers the publish-on-SIGTERM shutdown hook. See the module doc's +/// "Shutdown-hook wiring" section — called once from `main.rs` right after +/// `AppState` is constructed. +pub fn register(state: &AppState) { + let hook_state = state.clone(); + let shutdown = state.shutdown.clone(); + shutdown.register(Box::new(move || { + let hook_state = hook_state.clone(); + Box::pin(async move { run_owned_shutdown_publish(hook_state).await }) + })); +} + +/// The shutdown coordinator may drop this waiter when its 20-second hook +/// budget expires. The actual publish owner is detached and remains hidden in +/// `TaskRegistry`, so the post-hook registry sweep can still TERM -> KILL it +/// and wait for its process-group join before the app-root lock is released. +async fn run_owned_shutdown_publish(state: AppState) { + let operation_state = state.clone(); + run_owned_shutdown_operation(state, async move { + publish_all_on_shutdown(&operation_state).await + }) + .await; +} + +async fn run_owned_shutdown_operation(state: AppState, operation: F) +where + F: Future + Send + 'static, +{ + let (_kill_handle, result_rx) = + spawn_git_operation_owner(state, "git shutdown publish", operation); + match result_rx.await { + Ok(Ok(())) => {} + Ok(Err(message)) => tracing::error!(message, "shutdown git owner failed"), + Err(error) => tracing::error!(%error, "shutdown git owner stopped unexpectedly"), + } +} + +/// Publishes every materialized per-handle sandbox worktree and then the +/// process-global repository after their process owners have quiesced. Child +/// worktrees go first: when an app-root repo contains `sandboxes/*`, publishing +/// the parent first records the old embedded-repo gitlink and spends the hook's +/// finite budget before the user's actual sandbox changes. Workdirs are +/// deduplicated and processed serially: two sandboxes can share one remote, +/// and concurrent fetch/add/commit/push sequences would race that remote's +/// branch and credential helpers. +async fn publish_all_on_shutdown(state: &AppState) { + let mut seen = HashSet::new(); + let mut repo_dirs = Vec::new(); + let materialized = state + .sandbox_manager + .snapshot() + .into_iter() + .map(|sandbox| sandbox.workdir.clone()); + let durable = durable_sandbox_repo_dirs(&state.app_root).await; + for repo_dir in materialized + .chain(durable) + .chain(std::iter::once(state.repo_dir.clone())) + { + if seen.insert(repo_dir.clone()) { + repo_dirs.push(repo_dir); + } + } + + for repo_dir in repo_dirs { + match publish_internal(&repo_dir, "").await { + Ok(true) => { + tracing::info!(repo = %repo_dir.display(), "shutdown: git publish succeeded") + } + Ok(false) => tracing::debug!( + repo = %repo_dir.display(), + "shutdown: directory is not a git repository, nothing to publish" + ), + Err(error) => tracing::warn!( + repo = %repo_dir.display(), + %error, + "shutdown: git publish failed" + ), + } + } +} + +/// Enumerates persisted sandbox worktrees without resurrecting their runtime. +/// A fresh process starts with an empty `SandboxManager` map, but worktrees +/// with unsynced user changes may remain from a prior crash. Shutdown must +/// publish those too—even when the user never focused that chat during the +/// current launch. Re-running `ensure()` here would start install/dev work +/// after admission is closed, so the durable sidecar is used only to validate +/// the direct child directory before its existing `repo` is considered. +async fn durable_sandbox_repo_dirs(app_root: &Path) -> Vec { + let root = app_root.join("sandboxes"); + let mut entries = match tokio::fs::read_dir(&root).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Vec::new(), + Err(error) => { + tracing::warn!(path = %root.display(), %error, "shutdown: failed to enumerate durable sandboxes"); + return Vec::new(); + } + }; + let mut repo_dirs = Vec::new(); + loop { + let entry = match entries.next_entry().await { + Ok(Some(entry)) => entry, + Ok(None) => break, + Err(error) => { + tracing::warn!(path = %root.display(), %error, "shutdown: failed while enumerating durable sandboxes"); + break; + } + }; + let Ok(file_type) = entry.file_type().await else { + continue; + }; + // `DirEntry::file_type` does not follow symlinks. Refusing anything + // but a real directory keeps the shutdown hook confined to app_root. + if !file_type.is_dir() { + continue; + } + let Some(handle) = entry.file_name().to_str().map(str::to_string) else { + continue; + }; + let Some(config) = crate::sandbox::persist::read_sidecar(app_root, &handle) else { + continue; + }; + let branch = config + .branch + .as_deref() + .filter(|branch| !branch.is_empty()) + .unwrap_or("main"); + if crate::sandbox::SandboxManager::compute_handle(&config.virtual_mcp_id, branch) != handle + { + tracing::warn!( + handle, + "shutdown: ignored sandbox sidecar whose identity does not match its directory" + ); + continue; + } + let repo_dir = entry.path().join("repo"); + if tokio::fs::symlink_metadata(&repo_dir) + .await + .is_ok_and(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink()) + { + repo_dirs.push(repo_dir); + } + } + repo_dirs +} + +async fn require_git_repo(repo_dir: &Path) -> ApiResult<()> { + if is_git_repo(repo_dir).await { + Ok(()) + } else { + Err(ApiError::not_ready("repository not initialized")) + } +} + +/// Byte-parity with `parseJsonBody` — ANY parse failure (including an empty +/// body) is a 400 for publish/discard/rebase, unlike `diff` above which +/// swallows it. +fn parse_json_body(body: &Bytes) -> Result { + serde_json::from_slice::(body).map_err(|e| format!("Failed to parse body: {e}")) +} + +// --------------------------------------------------------------------------- +// Git process plumbing +// --------------------------------------------------------------------------- + +#[derive(Debug)] +struct GitError { + message: String, +} + +impl std::fmt::Display for GitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +/// `GIT_CEILING_DIRECTORIES` + `GIT_OPTIONAL_LOCKS=0` — the profile +/// `routes/git.ts`'s own `runGit`/`runGitAsync` use for every status/diff/ +/// publish/discard invocation. `GIT_OPTIONAL_LOCKS=0` skips the optional +/// index-refresh lock on read probes (status/diff/rev-parse) so they never +/// race `publish()`'s add/commit for `index.lock` — see the source comment +/// this ports (`daemon/routes/git.ts::gitEnv`). +fn route_env(repo_dir: &Path) -> Vec<(String, String)> { + vec![ + ( + "GIT_CEILING_DIRECTORIES".to_string(), + repo_dir.to_string_lossy().into_owned(), + ), + ("GIT_OPTIONAL_LOCKS".to_string(), "0".to_string()), + ] +} + +/// `GIT_CEILING_DIRECTORIES` only — the profile `branch-divergence.ts` and +/// `rebase-onto-base.ts` each define locally (no `GIT_OPTIONAL_LOCKS`, +/// since both do real writes and don't want lock-skipping in the mix). +fn ceiling_env(repo_dir: &Path) -> Vec<(String, String)> { + vec![( + "GIT_CEILING_DIRECTORIES".to_string(), + repo_dir.to_string_lossy().into_owned(), + )] +} + +/// Every invocation gets `-c safe.directory=*` — byte-parity with +/// `daemon/setup/git.ts`'s `git()` wrapper, which every `routes/git.ts` call +/// (and `rebase-onto-base.ts`'s own `gitSync` shim) goes through. +async fn run_git_raw>( + repo_dir: &Path, + args: &[S], + env: &[(String, String)], +) -> Result { + run_git_raw_with_timeout(repo_dir, args, env, GIT_TIMEOUT).await +} + +async fn run_git_raw_with_timeout>( + repo_dir: &Path, + args: &[S], + env: &[(String, String)], + timeout: Duration, +) -> Result { + let mut full: Vec = vec!["-c".to_string(), "safe.directory=*".to_string()]; + full.extend(args.iter().map(|s| s.as_ref().to_string())); + let joined = full.join(" "); + + let controller = GIT_PROCESS_CONTROLLER.try_with(Clone::clone).ok(); + if let Some(signal) = controller.as_ref().and_then(ProcessController::requested) { + return Err(GitError { + message: format!("git {joined} cancelled by {}", signal.flag()), + }); + } + + let mut cmd = tokio::process::Command::new("git"); + cmd.args(&full) + .current_dir(repo_dir) + .kill_on_drop(true) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + for (k, v) in env { + cmd.env(k, v); + } + + // The independent watchdog pins the process-group identity even if `git` + // exits while a credential helper or hook descendant survives with closed + // stdio. A terminal Git owner therefore proves the complete group is gone, + // rather than merely proving that the immediate `git` child was reaped. + let child_lifetime_lock_path = GIT_CHILD_LIFETIME_LOCK_PATH + .try_with(Clone::clone) + .unwrap_or_else(|_| { + repo_dir + .parent() + .unwrap_or(repo_dir) + .join(".decocms") + .join("child-lifetime.lock") + }); + let mut child = ProcessGroupChild::spawn(&mut cmd, &child_lifetime_lock_path) + .await + .map_err(|e| GitError { + message: format!("git {joined}: {e}"), + })?; + let pid = child.id(); + let process_group = child.control(); + let driven = std::panic::AssertUnwindSafe(async { + let mut output = Box::pin(child.wait_with_output()); + + #[derive(Clone, Copy)] + enum StopReason { + Timeout, + Cancelled(KillSignal), + } + + let timeout_sleep = tokio::time::sleep(timeout); + tokio::pin!(timeout_sleep); + let escalation_sleep = tokio::time::sleep(GIT_TERM_GRACE); + tokio::pin!(escalation_sleep); + let reap_warning_sleep = tokio::time::sleep(GIT_KILL_GRACE); + tokio::pin!(reap_warning_sleep); + let mut timeout_active = true; + let mut escalation_active = false; + let mut reap_warning_active = false; + let mut observed_signal = None; + let mut stop_reason = None; + + // Poll the SAME wait future through every signal. Completion means + // the leader has been wait(2)'d and every inherited output writer is + // closed. KILL never becomes an early return that could finalize the + // hidden operation while an SSH/credential helper is still alive. + let completed = loop { + tokio::select! { + completed = output.as_mut() => break completed, + _ = &mut timeout_sleep, if timeout_active => { + timeout_active = false; + stop_reason.get_or_insert(StopReason::Timeout); + if process_group.signal(KillSignal::Term).await { + escalation_sleep.as_mut().reset( + tokio::time::Instant::now() + GIT_TERM_GRACE, + ); + escalation_active = true; + } + } + signal = async { + controller + .as_ref() + .expect("controller branch is guarded") + .wait_for_change(observed_signal) + .await + }, if controller.is_some() => { + observed_signal = Some(signal); + timeout_active = false; + stop_reason.get_or_insert(StopReason::Cancelled(signal)); + if process_group.signal(signal).await { + match signal { + KillSignal::Term => { + escalation_sleep.as_mut().reset( + tokio::time::Instant::now() + GIT_TERM_GRACE, + ); + escalation_active = true; + } + KillSignal::Kill => { + escalation_active = false; + reap_warning_sleep.as_mut().reset( + tokio::time::Instant::now() + GIT_KILL_GRACE, + ); + reap_warning_active = true; + } + } + } + } + _ = &mut escalation_sleep, if escalation_active => { + escalation_active = false; + if process_group.signal(KillSignal::Kill).await { + reap_warning_sleep.as_mut().reset( + tokio::time::Instant::now() + GIT_KILL_GRACE, + ); + reap_warning_active = true; + } + } + _ = &mut reap_warning_sleep, if reap_warning_active => { + reap_warning_active = false; + tracing::error!( + pid, + command = %joined, + "git process group has not joined after KILL; continuing to own it" + ); + } + } + }; + + if let Some(reason) = stop_reason { + return Err(GitError { + message: match reason { + StopReason::Timeout => { + format!("git {joined} timed out after {}ms", timeout.as_millis()) + } + StopReason::Cancelled(signal) => { + format!("git {joined} cancelled by {}", signal.flag()) + } + }, + }); + } + + completed.map_err(|error| GitError { + message: format!("git {joined}: {error}"), + }) + }) + .catch_unwind() + .await; + + let completed = match driven { + Ok(Ok(completed)) => completed, + Ok(Err(error)) => { + // A pipe/read/wait error can end the driver before whole-group + // quiescence was established. Keep the hidden owner nonterminal + // until an explicit KILL + anchored reap proves cleanup. + child + .kill_and_reap(GIT_KILL_GRACE, "git process-owner error cleanup") + .await; + return Err(error); + } + Err(_) => { + // A panic must not turn the hidden task terminal until the anchored + // process group is completely gone. The deadline is diagnostic; + // ownership and retries continue beyond it. + child + .kill_and_reap(GIT_KILL_GRACE, "git process-owner panic cleanup") + .await; + return Err(GitError { + message: format!("git {joined} process owner panicked after group reap"), + }); + } + }; + + match completed { + out if out.status.success() => Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()), + out => { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let code = out + .status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "signal".to_string()); + let suffix = if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + }; + Err(GitError { + message: format!("git {joined} exited {code}{suffix}"), + }) + } + } +} + +async fn run_git>( + repo_dir: &Path, + args: &[S], + env: &[(String, String)], +) -> Result { + run_git_raw(repo_dir, args, env).await +} + +async fn try_git>( + repo_dir: &Path, + args: &[S], + env: &[(String, String)], +) -> Option { + run_git_raw(repo_dir, args, env).await.ok() +} + +/// `repo_dir` is created empty at boot (`main.rs`'s `mkdirSync`-equivalent). +/// A probe racing "not a repo yet" would otherwise leak a raw 128 exit as a +/// 500 — every handler above gates on this first via [`require_git_repo`]. +/// `pub(crate)`: also used by `setup/clone.rs` to decide clone vs. no-op. +pub(crate) async fn is_git_repo(repo_dir: &Path) -> bool { + try_git(repo_dir, &["rev-parse", "--git-dir"], &route_env(repo_dir)) + .await + .is_some() +} + +/// Current local branch, or `None` for a non-repository/detached checkout. +/// Setup uses this single probe to fast-path an unchanged sandbox without +/// rerunning checkout and the much heavier branch-divergence snapshot. +pub(crate) async fn current_branch(repo_dir: &Path) -> Option { + try_git( + repo_dir, + &["rev-parse", "--abbrev-ref", "HEAD"], + &ceiling_env(repo_dir), + ) + .await + .filter(|branch| !branch.is_empty() && branch != "HEAD") +} + +/// Strip ANSI SGR sequences (`ESC[...m`) from git's colorized stderr — +/// byte-parity with `formatGitError`/`stripAnsi` in `routes/git.ts` and +/// `rebase-onto-base.ts`. +fn strip_ansi(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\u{1b}' && chars.peek() == Some(&'[') { + chars.next(); + while matches!(chars.peek(), Some(d) if d.is_ascii_digit() || *d == ';') { + chars.next(); + } + if chars.peek() == Some(&'m') { + chars.next(); + } + continue; + } + out.push(c); + } + out +} + +// --------------------------------------------------------------------------- +// Route-level errors +// --------------------------------------------------------------------------- + +#[derive(Debug)] +enum RouteError { + InvalidBranchName(String), + Generic(String), +} + +impl std::fmt::Display for RouteError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RouteError::InvalidBranchName(m) | RouteError::Generic(m) => write!(f, "{m}"), + } + } +} + +impl From for RouteError { + fn from(e: GitError) -> Self { + RouteError::Generic(e.message) + } +} + +/// `strip`: publish/rebase strip ANSI on the generic 500 path +/// (`formatGitError`); status/diff do not. +fn route_error_response(err: RouteError, strip: bool) -> ApiError { + match err { + RouteError::InvalidBranchName(m) => ApiError::bad_request(m), + RouteError::Generic(m) => ApiError::internal(if strip { strip_ansi(&m) } else { m }), + } +} + +/// Git branch/ref segment safe for `git fetch origin ` (no flag +/// injection) — byte-parity with `git/ref-name.ts`. `pub(crate)`: also used +/// by `setup/clone.rs` before a remote-controlled branch name reaches argv. +pub(crate) fn is_valid_remote_branch_name(name: &str) -> bool { + if name.is_empty() || name.len() > 255 { + return false; + } + if name.contains("..") || name.contains("//") { + return false; + } + if name.starts_with('/') || name.ends_with('/') || name.ends_with(".lock") { + return false; + } + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphanumeric() => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || "/._-".contains(c)) +} + +fn assert_valid_remote_branch_name(name: &str) -> Result<(), RouteError> { + if is_valid_remote_branch_name(name) { + Ok(()) + } else { + Err(RouteError::InvalidBranchName(format!( + "Invalid base branch name: {name}" + ))) + } +} + +// --------------------------------------------------------------------------- +// Porcelain parsing — byte-parity with `git/porcelain.ts` +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +struct GitStatusFile { + path: String, + index: String, + working_dir: String, +} + +fn parse_porcelain_entry(entry: &str) -> Option<(char, char, String)> { + if entry.len() < 3 { + return None; + } + let bytes = entry.as_bytes(); + let index = bytes[0] as char; + let working = bytes[1] as char; + let path = if entry.len() >= 4 && bytes[2] == b' ' { + &entry[3..] + } else { + &entry[2..] + }; + if path.is_empty() { + return None; + } + Some((index, working, path.to_string())) +} + +/// Parses full `git status --porcelain=v1 -z` output. Rename/copy entries +/// (`index` is `R`/`C`) are followed by the original path as a second `-z` +/// segment — skip it, matching `porcelain.ts::parsePorcelainFiles`. +fn parse_porcelain_files(out: &str) -> Vec { + let parts: Vec<&str> = out.split('\0').collect(); + let mut files = Vec::new(); + let mut i = 0; + while i < parts.len() { + let entry = parts[i]; + if !entry.is_empty() { + if let Some((index, working, path)) = parse_porcelain_entry(entry) { + let is_rename_or_copy = index == 'R' || index == 'C'; + files.push(GitStatusFile { + path, + index: index.to_string(), + working_dir: working.to_string(), + }); + if is_rename_or_copy { + i += 1; + } + } + } + i += 1; + } + files +} + +// --------------------------------------------------------------------------- +// Working-tree status + branch divergence +// --------------------------------------------------------------------------- + +struct WorkingTreeStatus { + not_added: Vec, + conflicted: Vec, + created: Vec, + deleted: Vec, + modified: Vec, + renamed: Vec, + files: Vec, + staged: Vec, + ahead: i64, + behind: i64, + current: Option, + tracking: Option, + detached: bool, +} + +async fn compute_working_tree_status(repo_dir: &Path) -> Result { + let env = route_env(repo_dir); + let porcelain = run_git(repo_dir, &["status", "--porcelain=v1", "-z"], &env).await?; + let files = parse_porcelain_files(&porcelain); + + let mut not_added = Vec::new(); + let mut conflicted = Vec::new(); + let mut created = Vec::new(); + let mut deleted = Vec::new(); + let mut modified = Vec::new(); + let mut renamed = Vec::new(); + let mut staged = Vec::new(); + + for f in &files { + let xy = format!("{}{}", f.index, f.working_dir); + if xy.contains('U') { + conflicted.push(f.path.clone()); + } + if f.index == "?" && f.working_dir == "?" { + not_added.push(f.path.clone()); + } else if f.index == "A" || f.working_dir == "A" { + created.push(f.path.clone()); + } else if f.index == "D" || f.working_dir == "D" { + deleted.push(f.path.clone()); + } else if f.index == "R" || f.working_dir == "R" { + renamed.push(f.path.clone()); + } else { + modified.push(f.path.clone()); + } + if f.index != " " && f.index != "?" { + staged.push(f.path.clone()); + } + } + + let branch = try_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"], &env).await; + let detached = branch.as_deref() == Some("HEAD"); + let tracking = try_git( + repo_dir, + &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + &env, + ) + .await; + + let mut ahead = 0i64; + let mut behind = 0i64; + if tracking.is_some() && !detached { + if let Some(counts) = try_git( + repo_dir, + &["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], + &env, + ) + .await + { + let nums: Vec = counts + .split_whitespace() + .filter_map(|s| s.parse().ok()) + .collect(); + if nums.len() == 2 { + behind = nums[0]; + ahead = nums[1]; + } + } + } + + Ok(WorkingTreeStatus { + not_added, + conflicted, + created, + deleted, + modified, + renamed, + files, + staged, + ahead, + behind, + current: branch, + tracking, + detached, + }) +} + +struct Divergence { + base: String, + ahead_of_base: i64, + behind_base: i64, + head_sha: String, + unpushed: i64, +} + +/// Divergence vs. the default base branch — byte-parity with +/// `git/branch-divergence.ts::computeBranchDivergence`. +async fn compute_branch_divergence(repo_dir: &Path) -> Divergence { + let env = ceiling_env(repo_dir); + + let mut base = try_git( + repo_dir, + &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + &env, + ) + .await + .unwrap_or_default(); + if let Some(stripped) = base.strip_prefix("origin/") { + base = stripped.to_string(); + } + if base.is_empty() { + base = "main".to_string(); + } + + let branch = try_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"], &env) + .await + .unwrap_or_default(); + if branch.is_empty() || branch == "HEAD" { + let head_sha = try_git(repo_dir, &["rev-parse", "HEAD"], &env) + .await + .unwrap_or_default(); + return Divergence { + base, + ahead_of_base: 0, + behind_base: 0, + head_sha, + unpushed: 0, + }; + } + + let remote_branch_ref = format!("origin/{branch}"); + let has_remote_branch = try_git( + repo_dir, + &["rev-parse", "--verify", "--quiet", &remote_branch_ref], + &env, + ) + .await + .is_some(); + let branch_ref = if has_remote_branch { + remote_branch_ref.clone() + } else { + "HEAD".to_string() + }; + + let mut ahead_of_base = 0i64; + let mut behind_base = 0i64; + let base_remote_ref = format!("origin/{base}"); + if try_git( + repo_dir, + &["rev-parse", "--verify", "--quiet", &base_remote_ref], + &env, + ) + .await + .is_some() + { + if let Some(lr) = try_git( + repo_dir, + &[ + "rev-list", + "--left-right", + "--count", + &format!("{base_remote_ref}...{branch_ref}"), + ], + &env, + ) + .await + { + let nums: Vec = lr + .split_whitespace() + .filter_map(|s| s.parse().ok()) + .collect(); + if nums.len() == 2 { + behind_base = nums[0]; + ahead_of_base = nums[1]; + } + } + } + + let unpushed = if has_remote_branch { + try_git( + repo_dir, + &["rev-list", "--count", &format!("{remote_branch_ref}..HEAD")], + &env, + ) + .await + .and_then(|s| s.parse().ok()) + .unwrap_or(0) + } else { + ahead_of_base + }; + + let head_sha = try_git(repo_dir, &["rev-parse", &branch_ref], &env) + .await + .unwrap_or_default(); + + Divergence { + base, + ahead_of_base, + behind_base, + head_sha, + unpushed, + } +} + +async fn compute_status(repo_dir: &Path) -> Result { + let working = compute_working_tree_status(repo_dir).await?; + let divergence = compute_branch_divergence(repo_dir).await; + Ok(json!({ + "not_added": working.not_added, + "conflicted": working.conflicted, + "created": working.created, + "deleted": working.deleted, + "modified": working.modified, + "renamed": working.renamed, + "files": working.files.iter().map(|f| json!({ + "path": f.path, "index": f.index, "working_dir": f.working_dir, + })).collect::>(), + "staged": working.staged, + "ahead": working.ahead, + "behind": working.behind, + "current": working.current, + "tracking": working.tracking, + "detached": working.detached, + "base": divergence.base, + "aheadOfBase": divergence.ahead_of_base, + "behindBase": divergence.behind_base, + "headSha": divergence.head_sha, + "unpushed": divergence.unpushed, + })) +} + +// --------------------------------------------------------------------------- +// Diff +// --------------------------------------------------------------------------- + +async fn read_ref_file( + repo_dir: &Path, + ref_: &str, + path: &str, + env: &[(String, String)], +) -> Option { + try_git(repo_dir, &["show", &format!("{ref_}:{path}")], env).await +} + +async fn read_working_file(repo_dir: &Path, path: &str) -> Option { + tokio::fs::read_to_string(repo_dir.join(path)).await.ok() +} + +async fn diff_one_file( + repo_dir: &Path, + files: &[GitStatusFile], + path: &str, + env: &[(String, String)], +) -> Value { + let file = files.iter().find(|f| f.path == path); + let index = file.map(|f| f.index.as_str()).unwrap_or(" "); + let working = file.map(|f| f.working_dir.as_str()).unwrap_or(" "); + let is_deleted = index == "D" || working == "D"; + let head = read_ref_file(repo_dir, "HEAD", path, env).await; + let is_new = (index == "?" && working == "?") + || index == "A" + || working == "A" + || (head.is_none() && !is_deleted); + + let from = if is_new { None } else { head }; + let to = if is_deleted { + None + } else { + read_working_file(repo_dir, path).await + }; + json!({ "from": from, "to": to }) +} + +/// Uncommitted working-tree diff — byte-parity with `routes/git.ts::computeDiff`. +async fn compute_diff(repo_dir: &Path) -> Result { + let status = compute_working_tree_status(repo_dir).await?; + let env = route_env(repo_dir); + + let mut seen = HashSet::new(); + let mut paths = Vec::new(); + for f in &status.files { + if !f.path.is_empty() && seen.insert(f.path.clone()) { + paths.push(f.path.clone()); + } + } + + let futs = paths + .iter() + .map(|p| diff_one_file(repo_dir, &status.files, p, &env)); + let results = futures::future::join_all(futs).await; + + let mut diffs = serde_json::Map::new(); + for (p, v) in paths.into_iter().zip(results) { + diffs.insert(p, v); + } + Ok(json!({ "diffs": diffs })) +} + +fn is_full_sha(s: &str) -> bool { + s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit()) +} + +async fn list_three_dot_diff_paths( + repo_dir: &Path, + env: &[(String, String)], + left: &str, + right: &str, +) -> Vec { + match try_git( + repo_dir, + &["diff", "--name-only", "-z", &format!("{left}...{right}")], + env, + ) + .await + { + Some(out) if !out.is_empty() => out + .split('\0') + .filter(|s| !s.is_empty()) + .map(String::from) + .collect(), + _ => Vec::new(), + } +} + +/// Committed changes on HEAD since branching from `origin/{base}` (PR +/// scope) — byte-parity with `routes/git.ts::computeDiffAgainstBase`. +async fn compute_diff_against_base( + repo_dir: &Path, + base: &str, + head_sha: Option<&str>, +) -> Result { + assert_valid_remote_branch_name(base)?; + let env = route_env(repo_dir); + + let branch = try_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"], &env).await; + let branch = match branch { + Some(b) if b != "HEAD" => b, + _ => { + return Err(RouteError::Generic( + "Cannot compute PR diff from detached HEAD".to_string(), + )) + } + }; + assert_valid_remote_branch_name(&branch)?; + + let upstream = format!("origin/{base}"); + let remote_head = format!("origin/{branch}"); + let has_valid_head_sha = head_sha.map(is_full_sha).unwrap_or(false); + + async fn resolve_locally(repo_dir: &Path, env: &[(String, String)], r: &str) -> bool { + try_git(repo_dir, &["rev-parse", "--verify", r], env) + .await + .is_some() + } + + let can_skip_fetch = has_valid_head_sha + && resolve_locally(repo_dir, &env, &upstream).await + && resolve_locally(repo_dir, &env, &format!("{}^{{commit}}", head_sha.unwrap())).await + && try_git( + repo_dir, + &["merge-base", &upstream, head_sha.unwrap()], + &env, + ) + .await + .is_some(); + + let head_ref = if can_skip_fetch { + head_sha.unwrap().to_string() + } else { + let _ = run_git( + repo_dir, + &["fetch", "--depth", "100", "origin", base, &branch], + &env, + ) + .await; + if has_valid_head_sha { + let _ = run_git( + repo_dir, + &["fetch", "--depth", "100", "origin", head_sha.unwrap()], + &env, + ) + .await; + } + if !resolve_locally(repo_dir, &env, &upstream).await { + return Err(RouteError::Generic(format!( + "Base branch '{base}' not found on origin" + ))); + } + if has_valid_head_sha + && resolve_locally(repo_dir, &env, &format!("{}^{{commit}}", head_sha.unwrap())).await + { + head_sha.unwrap().to_string() + } else if resolve_locally(repo_dir, &env, &remote_head).await { + remote_head.clone() + } else { + "HEAD".to_string() + } + }; + + let mut paths = list_three_dot_diff_paths(repo_dir, &env, &upstream, &head_ref).await; + if paths.is_empty() && !can_skip_fetch { + let _ = run_git( + repo_dir, + &["fetch", "--deepen", "500", "origin", base, &branch], + &env, + ) + .await; + paths = list_three_dot_diff_paths(repo_dir, &env, &upstream, &head_ref).await; + } + + let merge_base = try_git(repo_dir, &["merge-base", &upstream, &head_ref], &env) + .await + .unwrap_or_else(|| upstream.clone()); + + let futs = paths.iter().map(|p| { + let merge_base = merge_base.clone(); + let head_ref = head_ref.clone(); + let env = env.clone(); + async move { + let from = read_ref_file(repo_dir, &merge_base, p, &env).await; + let to = read_ref_file(repo_dir, &head_ref, p, &env).await; + (p.clone(), json!({ "from": from, "to": to })) + } + }); + let mut diffs = serde_json::Map::new(); + for (p, v) in futures::future::join_all(futs).await { + diffs.insert(p, v); + } + + Ok(json!({ "diffs": diffs, "mergeBaseSha": merge_base.trim() })) +} + +// --------------------------------------------------------------------------- +// Discard +// --------------------------------------------------------------------------- + +/// Pure-lexical `path.resolve`-equivalent (no filesystem access — a new file +/// that doesn't exist yet must still resolve, e.g. for the diff/discard +/// "new file" cases) — byte-parity with `daemon/paths.ts::safePath`'s +/// resolution step. +fn lexical_resolve(base: &Path, user_path: &str) -> PathBuf { + let user = Path::new(user_path); + let joined = if user.is_absolute() { + user.to_path_buf() + } else { + base.join(user) + }; + let mut out: Vec = Vec::new(); + for comp in joined.components() { + match comp { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => match out.last() { + Some(std::path::Component::Normal(_)) => { + out.pop(); + } + Some(std::path::Component::RootDir) | Some(std::path::Component::Prefix(_)) => {} + _ => out.push(comp), + }, + other => out.push(other), + } + } + out.into_iter().collect() +} + +/// Resolves `user_path` against `base_dir`, then clamps to `workspace_root` +/// — byte-parity with `daemon/paths.ts::safePath`. Returns `None` on escape. +fn safe_path(workspace_root: &Path, base_dir: &Path, user_path: &str) -> Option { + let resolved = lexical_resolve(base_dir, user_path); + if resolved.strip_prefix(workspace_root).is_ok() { + Some(resolved) + } else { + None + } +} + +/// Byte-parity with `routes/git.ts::resolveRepoRelativePath`: clamps to +/// `app_root` (broad) then requires the result stay inside `repo_dir` +/// (narrow — discard's `filepaths` are relative to the repo, not the +/// workspace root). +fn resolve_repo_relative_path(app_root: &Path, repo_dir: &Path, user_path: &str) -> Option { + let abs = safe_path(app_root, repo_dir, user_path)?; + let rel = abs.strip_prefix(repo_dir).ok()?; + Some(rel.to_string_lossy().replace('\\', "/")) +} + +async fn discard_files( + app_root: &Path, + repo_dir: &Path, + filepaths: &[String], +) -> Result<(), String> { + let mut validated = Vec::with_capacity(filepaths.len()); + for fp in filepaths { + let rel = resolve_repo_relative_path(app_root, repo_dir, fp) + .ok_or_else(|| format!("Invalid path: {fp}"))?; + validated.push(rel); + } + + let status = compute_working_tree_status(repo_dir) + .await + .map_err(|e| e.message)?; + let env = route_env(repo_dir); + + let mut to_restore = Vec::new(); + let mut to_delete = Vec::new(); + for fp in &validated { + let is_new = status.not_added.contains(fp) + || status.created.contains(fp) + || read_ref_file(repo_dir, "HEAD", fp, &env).await.is_none(); + if is_new { + to_delete.push(fp.clone()); + } else { + to_restore.push(fp.clone()); + } + } + + if !to_restore.is_empty() { + let mut args: Vec = vec!["checkout".to_string(), "--".to_string()]; + args.extend(to_restore); + run_git(repo_dir, &args, &env) + .await + .map_err(|e| e.message)?; + } + for fp in &to_delete { + let _ = tokio::fs::remove_file(repo_dir.join(fp)).await; // best-effort, ignore missing files + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Publish +// --------------------------------------------------------------------------- + +/// `core.hooksPath` pointed at an empty dir so publish/rebase commits never +/// run the repo's own lefthook/husky hooks — byte-parity with +/// `getEmptyHooksDir()` in both `routes/git.ts` and `rebase-onto-base.ts`. +fn empty_hooks_dir() -> &'static Path { + static DIR: OnceLock = OnceLock::new(); + DIR.get_or_init(|| { + let dir = std::env::temp_dir().join(format!("local-api-no-hooks-{}", uuid::Uuid::new_v4())); + let _ = std::fs::create_dir_all(&dir); + dir + }) +} + +async fn resolve_remote_default_branch(repo_dir: &Path) -> String { + match try_git( + repo_dir, + &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + &ceiling_env(repo_dir), + ) + .await + { + Some(mut base) => { + if let Some(stripped) = base.strip_prefix("origin/") { + base = stripped.to_string(); + } + if base.is_empty() { + "main".to_string() + } else { + base + } + } + None => "main".to_string(), + } +} + +/// Branches a sandbox must never push to directly — byte-parity with +/// `git/protect-branch.ts::protectedBranches`. The daemon also installs a +/// pre-push hook mirroring this list; local-api's `publish()` runs +/// `--no-verify` (see [`push_branch`]) so the hook would be skipped anyway +/// — this in-code guard is the only enforcement, same as the daemon's own +/// comment notes. +async fn protected_branches(repo_dir: &Path) -> HashSet { + let mut set = HashSet::new(); + set.insert("main".to_string()); + set.insert("master".to_string()); + set.insert(resolve_remote_default_branch(repo_dir).await); + set +} + +async fn push_branch(repo_dir: &Path, branch: &str) -> Result<(), GitError> { + let mut env = route_env(repo_dir); + env.push(("GIT_TERMINAL_PROMPT".to_string(), "0".to_string())); + env.push(("GIT_ASKPASS".to_string(), "true".to_string())); + env.push(("LEFTHOOK".to_string(), "0".to_string())); + env.push(("HUSKY".to_string(), "0".to_string())); + // --no-verify: skip native pre-push hooks. A repo's own hook can fail or + // hang the push, and the shutdown-publish path (which shares this + // function) has no room to wait it out before SIGKILL drops the unsynced + // work — byte-parity with `routes/git.ts::pushBranch`. + run_git( + repo_dir, + &[ + "-c", + "credential.helper=", + "push", + "--no-verify", + "-u", + "origin", + branch, + ], + &env, + ) + .await?; + Ok(()) +} + +/// Byte-parity with `routes/git.ts::publish`. `""` message falls back to +/// "Update from sandbox". Returns `Ok(false)` (not an error) when +/// `repo_dir` isn't a git repo yet — the HTTP route already gates on this, +/// but the shutdown hook calls this directly with no such gate. +async fn publish_internal(repo_dir: &Path, message: &str) -> Result { + if !is_git_repo(repo_dir).await { + return Ok(false); + } + + let branch = try_git( + repo_dir, + &["rev-parse", "--abbrev-ref", "HEAD"], + &ceiling_env(repo_dir), + ) + .await; + let branch = match branch { + Some(b) if !b.is_empty() && b != "HEAD" => b, + _ => { + return Err(RouteError::Generic( + "Cannot publish from a detached HEAD".to_string(), + )) + } + }; + + // The pre-push hook the daemon installs also guards this, but publish + // runs --no-verify and skips it — the in-code check MUST stand alone. + if protected_branches(repo_dir).await.contains(&branch) { + return Err(RouteError::Generic(format!( + "Refusing to push to protected branch \"{branch}\" from a sandbox. Work on a feature branch; changes reach the default branch via PR." + ))); + } + + let env = route_env(repo_dir); + let status = compute_working_tree_status(repo_dir).await?; + let paths: Vec = { + let mut seen = HashSet::new(); + status + .files + .iter() + .map(|f| f.path.clone()) + .filter(|p| !p.is_empty() && seen.insert(p.clone())) + .collect() + }; + if !paths.is_empty() { + let mut args: Vec = vec!["add".to_string(), "--".to_string()]; + args.extend(paths); + run_git(repo_dir, &args, &env).await?; + } + + let has_staged_changes = try_git(repo_dir, &["diff", "--cached", "--quiet"], &env) + .await + .is_none(); + if has_staged_changes { + let trimmed = message.trim(); + let commit_msg = if trimmed.is_empty() { + "Update from sandbox" + } else { + trimmed + }; + let hooks_flag = format!("core.hooksPath={}", empty_hooks_dir().display()); + let mut commit_env = env.clone(); + commit_env.push(("LEFTHOOK".to_string(), "0".to_string())); + commit_env.push(("HUSKY".to_string(), "0".to_string())); + run_git( + repo_dir, + &["-c", &hooks_flag, "commit", "--no-verify", "-m", commit_msg], + &commit_env, + ) + .await?; + } + + push_branch(repo_dir, &branch).await?; + Ok(true) +} + +// --------------------------------------------------------------------------- +// Rebase +// --------------------------------------------------------------------------- + +fn is_rebase_in_progress(repo_dir: &Path) -> bool { + repo_dir.join(".git").join("rebase-merge").exists() + || repo_dir.join(".git").join("rebase-apply").exists() +} + +async fn abort_rebase(repo_dir: &Path, env: &[(String, String)]) { + if is_rebase_in_progress(repo_dir) { + let _ = try_git(repo_dir, &["rebase", "--abort"], env).await; + } +} + +async fn commit_before_rebase(repo_dir: &Path, env: &[(String, String)]) -> Result<(), GitError> { + let porcelain = try_git(repo_dir, &["status", "--porcelain"], env).await; + let dirty = porcelain + .as_deref() + .map(|s| !s.trim().is_empty()) + .unwrap_or(false); + if !dirty { + return Ok(()); + } + run_git(repo_dir, &["add", "."], env).await?; + let hooks_flag = format!("core.hooksPath={}", empty_hooks_dir().display()); + let mut commit_env = env.to_vec(); + commit_env.push(("LEFTHOOK".to_string(), "0".to_string())); + commit_env.push(("HUSKY".to_string(), "0".to_string())); + run_git( + repo_dir, + &[ + "-c", + &hooks_flag, + "commit", + "--no-verify", + "-m", + "Before rebase", + ], + &commit_env, + ) + .await?; + Ok(()) +} + +async fn read_status_files(repo_dir: &Path, env: &[(String, String)]) -> Vec { + match run_git(repo_dir, &["status", "--porcelain=v1", "-z"], env).await { + Ok(out) => parse_porcelain_files(&out), + Err(_) => Vec::new(), + } +} + +async fn get_conflicted_files(repo_dir: &Path, env: &[(String, String)]) -> Vec { + read_status_files(repo_dir, env) + .await + .into_iter() + .filter(|f| format!("{}{}", f.index, f.working_dir).contains('U')) + .map(|f| f.path) + .collect() +} + +/// Legacy deco CMS conflict-resolution strategy: prefer the replayed +/// (branch) side — byte-parity with `rebase-onto-base.ts::resolveConflictFile`. +async fn resolve_conflict_file(repo_dir: &Path, env: &[(String, String)], f: &GitStatusFile) { + let xy = format!("{}{}", f.index, f.working_dir); + let abs = repo_dir.join(&f.path); + + if xy.contains('U') && (f.index == "D" || f.working_dir == "D") { + if abs.exists() { + let _ = run_git(repo_dir, &["add", "--", &f.path], env).await; + } else { + let _ = run_git(repo_dir, &["rm", "-f", "--", &f.path], env).await; + } + return; + } + if f.working_dir == "D" && !xy.contains('U') { + let _ = run_git(repo_dir, &["rm", "-f", &f.path], env).await; + return; + } + let _ = run_git(repo_dir, &["checkout", "--theirs", "--", &f.path], env).await; + let _ = run_git(repo_dir, &["add", "--", &f.path], env).await; +} + +async fn continue_rebase(repo_dir: &Path, env: &[(String, String)]) -> Result<(), GitError> { + let mut cenv = env.to_vec(); + cenv.push(("GIT_EDITOR".to_string(), "true".to_string())); + cenv.push(("EDITOR".to_string(), "true".to_string())); + + match run_git(repo_dir, &["rebase", "--continue"], &cenv).await { + Ok(_) => Ok(()), + Err(e) => { + let message = strip_ansi(&e.message); + if !message.contains("staged changes in your working tree") { + return Err(e); + } + let hooks_flag = format!("core.hooksPath={}", empty_hooks_dir().display()); + let mut commit_env = cenv.clone(); + commit_env.push(("LEFTHOOK".to_string(), "0".to_string())); + commit_env.push(("HUSKY".to_string(), "0".to_string())); + run_git( + repo_dir, + &["-c", &hooks_flag, "commit", "--no-edit", "--no-verify"], + &commit_env, + ) + .await?; + if is_rebase_in_progress(repo_dir) { + run_git(repo_dir, &["rebase", "--continue"], &cenv).await?; + } + Ok(()) + } + } +} + +const MAX_CONFLICT_RESOLUTION_ATTEMPTS: u32 = 50; + +/// Iterative port of `rebase-onto-base.ts::resolveConflictsRecursively` +/// (recursion flattened to a loop — Rust async fns can't recurse without +/// boxing every frame, and a loop is equivalent here). +async fn resolve_conflicts(repo_dir: &Path, env: &[(String, String)]) -> Result<(), GitError> { + let mut attempts_left = MAX_CONFLICT_RESOLUTION_ATTEMPTS; + loop { + if attempts_left == 0 { + abort_rebase(repo_dir, env).await; + return Err(GitError { + message: "Rebase conflict resolution exceeded maximum attempts".to_string(), + }); + } + + let files = read_status_files(repo_dir, env).await; + for f in files + .iter() + .filter(|f| format!("{}{}", f.index, f.working_dir).contains('U')) + { + resolve_conflict_file(repo_dir, env, f).await; + } + + if !get_conflicted_files(repo_dir, env).await.is_empty() { + abort_rebase(repo_dir, env).await; + return Err(GitError { + message: "Unresolved rebase conflicts remain".to_string(), + }); + } + + match continue_rebase(repo_dir, env).await { + Ok(()) => return Ok(()), + Err(e) => { + let message = strip_ansi(&e.message); + let remaining = get_conflicted_files(repo_dir, env).await; + if (!message.contains("CONFLICT") + && remaining.is_empty() + && !is_rebase_in_progress(repo_dir)) + || remaining.is_empty() + { + abort_rebase(repo_dir, env).await; + return Err(e); + } + attempts_left -= 1; + } + } + } +} + +async fn force_push_rebased_branch( + repo_dir: &Path, + branch: &str, + lease_sha: Option<&str>, +) -> Result<(), GitError> { + let env = ceiling_env(repo_dir); + if let Some(lease) = lease_sha { + let lease_ref = format!("refs/heads/{branch}:{lease}"); + match run_git( + repo_dir, + &[ + "push", + &format!("--force-with-lease={lease_ref}"), + "origin", + branch, + ], + &env, + ) + .await + { + Ok(_) => return Ok(()), + Err(e) => { + let message = strip_ansi(&e.message); + let retriable = + message.contains("stale info") || message.contains("failed to push some refs"); + if !retriable { + return Err(e); + } + let _ = run_git(repo_dir, &["fetch", "origin", branch], &env).await; + if let Some(refreshed) = try_git( + repo_dir, + &[ + "rev-parse", + "--verify", + &format!("refs/remotes/origin/{branch}"), + ], + &env, + ) + .await + { + let lease_ref2 = format!("refs/heads/{branch}:{refreshed}"); + run_git( + repo_dir, + &[ + "push", + &format!("--force-with-lease={lease_ref2}"), + "origin", + branch, + ], + &env, + ) + .await?; + return Ok(()); + } + } + } + } + // Fall back to a plain --force when there's no lease SHA to pin against + // (branch never had a remote-tracking ref) or the lease attempt's + // refresh-and-retry also raced someone else's push. + run_git(repo_dir, &["push", "--force", "origin", branch], &env).await?; + Ok(()) +} + +/// Byte-parity with `rebase-onto-base.ts::rebaseOntoBase`. +async fn rebase_onto_base(repo_dir: &Path, base: &str) -> Result<(), RouteError> { + assert_valid_remote_branch_name(base)?; + let env = ceiling_env(repo_dir); + + let branch = try_git(repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"], &env).await; + let branch = match branch { + Some(b) if !b.is_empty() && b != "HEAD" => b, + _ => { + return Err(RouteError::Generic( + "Cannot rebase from a detached HEAD".to_string(), + )) + } + }; + + run_git(repo_dir, &["fetch", "-p", "origin", base, &branch], &env) + .await + .map_err(RouteError::from)?; + let lease_sha = try_git( + repo_dir, + &[ + "rev-parse", + "--verify", + &format!("refs/remotes/origin/{branch}"), + ], + &env, + ) + .await; + + let _ = try_git( + repo_dir, + &[ + "submodule", + "update", + "--init", + "--recursive", + "--depth", + "1", + ], + &env, + ) + .await; + + let upstream = format!("origin/{base}"); + if try_git(repo_dir, &["rev-parse", "--verify", &upstream], &env) + .await + .is_none() + { + return Err(RouteError::Generic(format!( + "Base branch '{base}' not found on origin" + ))); + } + + commit_before_rebase(repo_dir, &env) + .await + .map_err(RouteError::from)?; + + // --autostash: a dev server churning tracked generated files can dirty + // the tree between commit_before_rebase and the rebase's internal + // checkout, which would otherwise abort. Autostash stashes that churn, + // runs the rebase, and restores it after. + if let Err(e) = run_git( + repo_dir, + &["rebase", "--autostash", "-X", "theirs", &upstream], + &env, + ) + .await + { + if !is_rebase_in_progress(repo_dir) { + return Err(RouteError::from(e)); + } + resolve_conflicts(repo_dir, &env) + .await + .map_err(RouteError::from)?; + } + + if is_rebase_in_progress(repo_dir) { + abort_rebase(repo_dir, &env).await; + return Err(RouteError::Generic("Rebase did not complete".to_string())); + } + + force_push_rebased_branch(repo_dir, &branch, lease_sha.as_deref()) + .await + .map_err(RouteError::from)?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// "branch" broadcaster event +// --------------------------------------------------------------------------- + +/// Fires after a successful publish/discard/rebase (the native module-ownership contract's +/// "Emits" table) — shaped like the daemon's `BranchStatusMonitor` +/// reconnect-snapshot payload. See the module doc for why this is +/// mutation-triggered only, not the daemon's continuous file-watch version. +/// +/// Also called from `setup/clone.rs` once a clone/checkout lands (the setup +/// pipeline's equivalent of `orchestrator.stepClone()`'s +/// `branchStatus.refresh()` call) — hence taking `repo_dir`/`broadcaster` +/// directly rather than a full `&AppState` (the setup module constructs its +/// own view of these, not a route-extracted `AppState`). +pub(crate) async fn emit_branch_event(repo_dir: &Path, broadcaster: &crate::events::Broadcaster) { + let Some(meta) = branch_snapshot(repo_dir).await else { + return; + }; + broadcaster.emit("branch", json!({ "meta": meta })); +} + +/// Compute the current reconnect/live branch payload for exactly `repo_dir`. +/// +/// This is intentionally stateless. Callers must never reuse a snapshot +/// computed for another target: native can have many independently checked-out +/// sandbox branches in one process, and their worktrees survive process +/// restarts even though Rust's in-memory state does not. +pub(crate) async fn branch_snapshot(repo_dir: &Path) -> Option { + if !is_git_repo(repo_dir).await { + return None; + } + + let dirty = match run_git( + repo_dir, + &["status", "--porcelain=v1", "-z"], + &route_env(repo_dir), + ) + .await + { + Ok(out) => !parse_porcelain_files(&out).is_empty(), + Err(_) => false, + }; + let branch = current_branch(repo_dir).await?; + let divergence = compute_branch_divergence(repo_dir).await; + + Some(json!({ + "kind": "ready", + "branch": branch, + "base": divergence.base, + "workingTreeDirty": dirty, + "unpushed": divergence.unpushed, + "aheadOfBase": divergence.ahead_of_base, + "behindBase": divergence.behind_base, + "headSha": divergence.head_sha, + })) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + use std::sync::Arc; + use tempfile::TempDir; + + // -- pure logic, no git process ------------------------------------------ + + #[test] + fn parses_simple_porcelain_entries() { + let raw = "?? untracked.txt\0 M modified.txt\0"; + let files = parse_porcelain_files(raw); + assert_eq!(files.len(), 2); + assert_eq!( + files[0], + GitStatusFile { + path: "untracked.txt".into(), + index: "?".into(), + working_dir: "?".into() + } + ); + assert_eq!( + files[1], + GitStatusFile { + path: "modified.txt".into(), + index: " ".into(), + working_dir: "M".into() + } + ); + } + + #[test] + fn parses_rename_entry_consuming_orig_path() { + let raw = "R new.txt\0old.txt\0M other.txt\0"; + let files = parse_porcelain_files(raw); + assert_eq!(files.len(), 2); + assert_eq!(files[0].path, "new.txt"); + assert_eq!(files[0].index, "R"); + assert_eq!(files[1].path, "other.txt"); + } + + #[test] + fn strip_ansi_removes_sgr_sequences() { + let colored = "\u{1b}[31merror\u{1b}[0m: bad"; + assert_eq!(strip_ansi(colored), "error: bad"); + } + + #[test] + fn valid_branch_names_accepted() { + assert!(is_valid_remote_branch_name("main")); + assert!(is_valid_remote_branch_name("feature/x-1.2")); + } + + #[test] + fn invalid_branch_names_rejected() { + assert!(!is_valid_remote_branch_name("")); + assert!(!is_valid_remote_branch_name("../etc")); + assert!(!is_valid_remote_branch_name("/leading")); + assert!(!is_valid_remote_branch_name("trailing/")); + assert!(!is_valid_remote_branch_name("name.lock")); + assert!(!is_valid_remote_branch_name("has space")); + assert!(!is_valid_remote_branch_name("-flag-looking")); + } + + #[test] + fn safe_path_allows_paths_inside_root() { + let root = Path::new("/workspace"); + let base = Path::new("/workspace/repo"); + let resolved = safe_path(root, base, "foo/bar.txt").unwrap(); + assert_eq!(resolved, Path::new("/workspace/repo/foo/bar.txt")); + } + + #[test] + fn safe_path_rejects_escape() { + let root = Path::new("/workspace/repo"); + let base = Path::new("/workspace/repo"); + assert!(safe_path(root, base, "../../etc/passwd").is_none()); + } + + #[test] + fn resolve_repo_relative_path_rejects_escape() { + let app_root = Path::new("/workspace"); + let repo_dir = Path::new("/workspace/repo"); + assert!(resolve_repo_relative_path(app_root, repo_dir, "../outside.txt").is_none()); + assert_eq!( + resolve_repo_relative_path(app_root, repo_dir, "nested/file.txt").unwrap(), + "nested/file.txt" + ); + } + + #[tokio::test] + async fn detached_git_owner_releases_admission_but_stays_registry_owned() { + let root = TempDir::new().unwrap(); + let state = test_app_state(root.path()); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let release = Arc::new(tokio::sync::Notify::new()); + let operation_release = release.clone(); + let caller_state = state.clone(); + let caller = tokio::spawn(async move { + run_owned_git_route(caller_state, "git ownership test", async move { + let _ = started_tx.send(()); + operation_release.notified().await; + Ok::<(), ApiError>(()) + }) + .await + }); + + started_rx.await.expect("git owner acquired admission"); + caller.abort(); + let _ = caller.await; + + tokio::time::timeout(Duration::from_secs(1), state.shutdown.begin_shutdown()) + .await + .expect("admission covers only registration, not the full operation"); + + let sweep = state + .tasks + .kill_all_and_wait(Duration::ZERO, Duration::ZERO) + .await; + assert_eq!(sweep.initially_running, 1); + assert_eq!(sweep.remaining.len(), 1); + assert!(sweep.remaining[0].starts_with("internal-git-")); + + release.notify_waiters(); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if state + .tasks + .kill_all_and_wait(Duration::ZERO, Duration::ZERO) + .await + .initially_running + == 0 + { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("detached owner finalized after its operation completed"); + } + + #[tokio::test] + async fn git_route_rejects_work_after_shutdown_admission_closes() { + let root = TempDir::new().unwrap(); + let state = test_app_state(root.path()); + state.shutdown.begin_shutdown().await; + let ran = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let operation_ran = ran.clone(); + let error = run_owned_git_route(state, "git rejected test", async move { + operation_ran.store(true, std::sync::atomic::Ordering::SeqCst); + Ok::<(), ApiError>(()) + }) + .await + .expect_err("closed admission rejects git work"); + + assert_eq!(error.status, StatusCode::SERVICE_UNAVAILABLE); + assert!(!ran.load(std::sync::atomic::Ordering::SeqCst)); + } + + #[tokio::test] + async fn dropped_shutdown_hook_waiter_leaves_an_enumerable_owner() { + let root = TempDir::new().unwrap(); + let state = test_app_state(root.path()); + let (started_tx, started_rx) = oneshot::channel(); + let hook_state = state.clone(); + let hook_waiter = tokio::spawn(async move { + run_owned_shutdown_operation(hook_state, async move { + let controller = GIT_PROCESS_CONTROLLER.with(Clone::clone); + let _ = started_tx.send(()); + controller.wait_for_change(None).await; + }) + .await; + }); + + started_rx.await.expect("shutdown publish owner started"); + // Equivalent to ShutdownCoordinator's hook timeout dropping the hook + // future: only the waiter dies; the detached operation remains in the + // registry for the top-level post-hook sweep. + hook_waiter.abort(); + let _ = hook_waiter.await; + + let sweep = state + .tasks + .kill_all_and_wait(Duration::from_secs(1), Duration::from_secs(1)) + .await; + assert_eq!(sweep.initially_running, 1); + assert_eq!(sweep.term_signaled, 1); + assert_eq!(sweep.kill_signaled, 0); + assert!(sweep.remaining.is_empty()); + } + + #[cfg(unix)] + #[tokio::test] + async fn slow_git_group_is_term_kill_reaped_before_owner_finalizes() { + use std::os::unix::fs::PermissionsExt; + + let root = TempDir::new().unwrap(); + let state = test_app_state(root.path()); + let bin_dir = root.path().join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let script = bin_dir.join("git"); + std::fs::write( + &script, + "#!/bin/sh\n\ + trap '' TERM\n\ + printf '%s\\n' \"$$\" > \"$GIT_TEST_PID_FILE\"\n\ + /bin/sleep 30 &\n\ + printf '%s\\n' \"$!\" > \"$GIT_TEST_CHILD_PID_FILE\"\n\ + wait\n", + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + let pid_file = root.path().join("git.pid"); + let child_pid_file = root.path().join("git-child.pid"); + let env = vec![ + ("PATH".to_string(), bin_dir.to_string_lossy().into_owned()), + ( + "GIT_TEST_PID_FILE".to_string(), + pid_file.to_string_lossy().into_owned(), + ), + ( + "GIT_TEST_CHILD_PID_FILE".to_string(), + child_pid_file.to_string_lossy().into_owned(), + ), + ]; + let workdir = root.path().to_path_buf(); + let caller_state = state.clone(); + let caller = tokio::spawn(async move { + run_owned_git_route(caller_state, "slow git test", async move { + run_git_raw_with_timeout(&workdir, &["status"], &env, Duration::from_secs(30)) + .await + .map(|_| ()) + .map_err(|error| ApiError::internal(error.message)) + }) + .await + }); + + let pid = wait_for_pid_file(&pid_file).await; + let child_pid = wait_for_pid_file(&child_pid_file).await; + let sweep = state + .tasks + .kill_all_and_wait(Duration::from_millis(40), Duration::from_secs(2)) + .await; + assert_eq!(sweep.initially_running, 1); + assert_eq!(sweep.term_signaled, 1); + assert_eq!(sweep.kill_signaled, 1); + assert!(sweep.remaining.is_empty()); + + let error = tokio::time::timeout(Duration::from_secs(1), caller) + .await + .expect("route owner joined after KILL") + .expect("route waiter task joined") + .expect_err("cancellation is returned to a still-connected caller"); + assert!(error.body["error"] + .as_str() + .is_some_and(|message| message.contains("cancelled"))); + assert_process_group_gone(pid).await; + assert_process_gone(child_pid).await; + } + + #[cfg(unix)] + async fn wait_for_pid_file(path: &Path) -> u32 { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Ok(contents) = tokio::fs::read_to_string(path).await { + if let Ok(pid) = contents.trim().parse() { + return pid; + } + } + tokio::task::yield_now().await; + } + }) + .await + .expect("fake git wrote its pid file") + } + + #[cfg(unix)] + fn process_exists(pid_arg: String) -> bool { + Command::new("kill") + .args(["-0", &pid_arg]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|status| status.success()) + } + + #[cfg(unix)] + async fn assert_process_group_gone(pid: u32) { + tokio::time::timeout(Duration::from_secs(1), async { + while process_exists(format!("-{pid}")) { + tokio::task::yield_now().await; + } + }) + .await + .expect("Git process group was still alive after owner finalization"); + } + + #[cfg(unix)] + async fn assert_process_gone(pid: u32) { + tokio::time::timeout(Duration::from_secs(1), async { + while process_exists(pid.to_string()) { + tokio::task::yield_now().await; + } + }) + .await + .expect("Git descendant was still alive after owner finalization"); + } + + // -- real git repo integration tests ------------------------------------- + + fn git(dir: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git failed to spawn"); + assert!( + output.status.success(), + "git {args:?} failed in {dir:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + /// Bare "origin" + a working clone on a feature branch — local-api has no + /// clone pipeline, so tests lay the repo out directly on disk the way a + /// real user's existing project would already be, mirroring the daemon + /// e2e harness's `setupBareRepo()` + clone-onto-branch flow. + struct TestRepo { + root: TempDir, + bare_dir: PathBuf, + work_dir: PathBuf, + } + + fn setup_repo() -> TestRepo { + let root = TempDir::new().unwrap(); + let bare_dir = root.path().join("origin.git"); + let work_dir = root.path().join("repo"); + std::fs::create_dir_all(&bare_dir).unwrap(); + std::fs::create_dir_all(&work_dir).unwrap(); + git(&bare_dir, &["init", "--bare", "-q"]); + git(&work_dir, &["init", "-q", "-b", "main"]); + git(&work_dir, &["config", "user.name", "Test User"]); + git(&work_dir, &["config", "user.email", "test@example.com"]); + std::fs::write(work_dir.join("README.md"), "hello\n").unwrap(); + git(&work_dir, &["add", "."]); + git(&work_dir, &["commit", "-q", "-m", "initial"]); + let bare_str = bare_dir.to_str().unwrap(); + git(&work_dir, &["remote", "add", "origin", bare_str]); + git(&work_dir, &["push", "-q", "-u", "origin", "main"]); + // Point the bare repo's HEAD symref at `main` — `git init --bare` + // leaves it on whatever `init.defaultBranch` the host git is + // configured with (often still `master`), which would otherwise + // leave a fresh `git clone` of this bare repo with no local branch + // checked out (`HEAD` referring to a ref that was never pushed). + git(&bare_dir, &["symbolic-ref", "HEAD", "refs/heads/main"]); + git(&work_dir, &["fetch", "-q", "origin"]); + git(&work_dir, &["checkout", "-q", "-b", "sandbox-work"]); + TestRepo { + root, + bare_dir, + work_dir, + } + } + + /// Builds a full `AppState` rooted at `dir` (both `app_root`/`repo_dir` + /// point at the same directory) for tests that do not materialize a + /// per-handle sandbox. + fn test_app_state(dir: &Path) -> AppState { + test_app_state_at(dir, dir) + } + + /// Production keeps the app root and process-global repository separate: + /// `/repo` and `/sandboxes//repo`. Tests that + /// exercise both repositories must preserve that boundary, otherwise an + /// empty sandbox directory is falsely discovered as part of its parent Git + /// worktree before the clone step can initialize it. + fn test_app_state_at(app_root: &Path, repo_dir: &Path) -> AppState { + let config = std::sync::Arc::new(crate::config::ConfigStore::new()); + let logs = std::sync::Arc::new(crate::log_store::LogStore::new(app_root.join("logs"))); + let tasks = std::sync::Arc::new(crate::tasks::TaskRegistry::new(logs)); + let broadcaster = std::sync::Arc::new(crate::events::Broadcaster::new()); + let setup = crate::setup::SetupOrchestrator::new( + repo_dir.to_path_buf(), + repo_dir.to_path_buf(), + config.clone(), + tasks.clone(), + broadcaster.clone(), + ); + AppState { + token: "test-token".into(), + boot_id: "test-boot".into(), + sandbox_manager: crate::sandbox::SandboxManager::new(app_root.to_path_buf()), + app_root: app_root.to_path_buf(), + repo_dir: repo_dir.to_path_buf(), + mode: crate::state::ApiMode::Strict, + config, + tasks, + broadcaster, + shutdown: std::sync::Arc::new(crate::shutdown::ShutdownCoordinator::new()), + setup, + } + } + + fn remote_has_branch(bare_dir: &Path, branch: &str) -> bool { + let out = Command::new("git") + .args([ + "ls-remote", + bare_dir.to_str().unwrap(), + &format!("refs/heads/{branch}"), + ]) + .output() + .unwrap(); + !String::from_utf8_lossy(&out.stdout).trim().is_empty() + } + + #[tokio::test] + async fn status_reports_current_branch() { + let repo = setup_repo(); + let status = compute_status(&repo.work_dir).await.unwrap(); + assert_eq!(status["current"], "sandbox-work"); + assert_eq!(status["detached"], false); + assert!(status["files"].as_array().unwrap().is_empty()); + } + + #[tokio::test] + async fn is_git_repo_false_before_init() { + let root = TempDir::new().unwrap(); + let repo_dir = root.path().join("not-a-repo"); + std::fs::create_dir_all(&repo_dir).unwrap(); + assert!(!is_git_repo(&repo_dir).await); + } + + #[tokio::test] + async fn diff_surfaces_uncommitted_new_file() { + let repo = setup_repo(); + std::fs::write(repo.work_dir.join("new-file.txt"), "fresh\n").unwrap(); + let result = compute_diff(&repo.work_dir).await.unwrap(); + let diffs = result["diffs"].as_object().unwrap(); + assert!(diffs.contains_key("new-file.txt")); + assert!(diffs["new-file.txt"]["from"].is_null()); + assert_eq!(diffs["new-file.txt"]["to"], "fresh\n"); + } + + #[tokio::test] + async fn discard_removes_untracked_file() { + let repo = setup_repo(); + std::fs::write(repo.work_dir.join("scratch.txt"), "discard me\n").unwrap(); + discard_files(&repo.work_dir, &repo.work_dir, &["scratch.txt".to_string()]) + .await + .unwrap(); + assert!(!repo.work_dir.join("scratch.txt").exists()); + } + + #[tokio::test] + async fn discard_restores_modified_tracked_file() { + let repo = setup_repo(); + std::fs::write(repo.work_dir.join("README.md"), "changed\n").unwrap(); + discard_files(&repo.work_dir, &repo.work_dir, &["README.md".to_string()]) + .await + .unwrap(); + assert_eq!( + std::fs::read_to_string(repo.work_dir.join("README.md")).unwrap(), + "hello\n" + ); + } + + #[tokio::test] + async fn discard_rejects_escaping_path() { + let repo = setup_repo(); + let err = discard_files( + &repo.work_dir, + &repo.work_dir, + &["../outside.txt".to_string()], + ) + .await + .unwrap_err(); + assert!(err.contains("Invalid path")); + } + + #[tokio::test] + async fn publish_commits_and_pushes() { + let repo = setup_repo(); + std::fs::write(repo.work_dir.join("published.txt"), "ship it\n").unwrap(); + let pushed = publish_internal(&repo.work_dir, "test publish") + .await + .unwrap(); + assert!(pushed); + assert!(remote_has_branch(&repo.bare_dir, "sandbox-work")); + } + + #[tokio::test] + async fn publish_pushes_past_failing_pre_push_hook() { + let repo = setup_repo(); + let hooks_dir = repo.work_dir.join(".git").join("hooks"); + std::fs::create_dir_all(&hooks_dir).unwrap(); + let hook_path = hooks_dir.join("pre-push"); + std::fs::write(&hook_path, "#!/bin/sh\nexit 1\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + std::fs::write(repo.work_dir.join("past-hook.txt"), "survived the hook\n").unwrap(); + let pushed = publish_internal(&repo.work_dir, "publish past a failing hook") + .await + .unwrap(); + assert!(pushed); + assert!(remote_has_branch(&repo.bare_dir, "sandbox-work")); + } + + #[tokio::test] + async fn publish_refuses_protected_branch() { + let repo = setup_repo(); + git(&repo.work_dir, &["checkout", "-q", "main"]); + std::fs::write(repo.work_dir.join("oops.txt"), "nope\n").unwrap(); + let err = publish_internal(&repo.work_dir, "should be refused") + .await + .unwrap_err(); + assert!(err.to_string().contains("protected branch")); + } + + #[tokio::test] + async fn rebase_onto_base_rebases_and_force_pushes() { + let repo = setup_repo(); + + // `rebaseOntoBase`'s leading `git fetch -p origin ` + // requires `` (the CURRENT branch) to already exist on + // origin — byte-parity with `rebase-onto-base.ts`, which has the + // exact same requirement. A branch that was never published yet + // (this repo's `sandbox-work` right after `setup_repo()`) makes that + // fetch fail with "couldn't find remote ref sandbox-work" before + // rebase logic proper even starts, so publish once first to seed it. + git( + &repo.work_dir, + &["push", "-q", "-u", "origin", "sandbox-work"], + ); + + // Advance origin/main from a second clone. + let other = repo.root.path().join("other-clone"); + git( + repo.root.path(), + &[ + "clone", + "-q", + repo.bare_dir.to_str().unwrap(), + other.to_str().unwrap(), + ], + ); + git(&other, &["config", "user.name", "Other"]); + git(&other, &["config", "user.email", "other@example.com"]); + std::fs::write(other.join("base-change.txt"), "base\n").unwrap(); + git(&other, &["add", "."]); + git(&other, &["commit", "-q", "-m", "advance base"]); + git(&other, &["push", "-q", "origin", "main"]); + + // sandbox-work gets its own commit before rebasing. + std::fs::write(repo.work_dir.join("feature.txt"), "feature\n").unwrap(); + git(&repo.work_dir, &["add", "."]); + git(&repo.work_dir, &["commit", "-q", "-m", "feature work"]); + + let result = rebase_onto_base(&repo.work_dir, "main").await; + assert!(result.is_ok(), "{:?}", result.err().map(|e| e.to_string())); + + assert!(repo.work_dir.join("feature.txt").exists()); + assert!(repo.work_dir.join("base-change.txt").exists()); + assert!(remote_has_branch(&repo.bare_dir, "sandbox-work")); + } + + /// A `base` that's a syntactically valid ref but doesn't exist on origin + /// fails at the leading `git fetch -p origin ` step + /// (`fatal: couldn't find remote ref `, exit 128) before + /// `rebaseOntoBase`'s own friendlier "Base branch '' not found on + /// origin" check is ever reached — that check only guards a narrower + /// case (base resolves locally as `origin/HEAD`-adjacent but the + /// specific `origin/` ref is still missing after the fetch + /// succeeds). Byte-parity: `rebase-onto-base.ts` has the exact same + /// ordering, so the daemon surfaces the same raw git error here too — + /// this isn't a Rust-port bug, it's the upstream behavior. + #[tokio::test] + async fn rebase_missing_base_on_origin_is_an_error() { + let repo = setup_repo(); + let err = rebase_onto_base(&repo.work_dir, "does-not-exist") + .await + .unwrap_err(); + assert!(err.to_string().contains("does-not-exist")); + } + + #[tokio::test] + async fn rebase_rejects_invalid_branch_name() { + let repo = setup_repo(); + let err = rebase_onto_base(&repo.work_dir, "../escape") + .await + .unwrap_err(); + assert!(matches!(err, RouteError::InvalidBranchName(_))); + } + + /// Mirrors `daemon.git.e2e.test.ts`'s "SIGTERM triggers a graceful + /// publish to origin before exit" — but drives the hook directly via + /// `ShutdownCoordinator::run()` instead of an OS signal, since the real + /// signal path requires the (currently unwired, see module doc) + /// `register(&state)` call in `main.rs`. + #[tokio::test] + async fn register_publishes_on_shutdown_run() { + let repo = setup_repo(); + std::fs::write(repo.work_dir.join("graceful.txt"), "saved on shutdown\n").unwrap(); + assert!(!remote_has_branch(&repo.bare_dir, "sandbox-work")); + + let state = test_app_state(&repo.work_dir); + register(&state); + state.shutdown.run().await; + + assert!(remote_has_branch(&repo.bare_dir, "sandbox-work")); + } + + #[tokio::test] + async fn shutdown_hook_publishes_materialized_sandbox_worktrees_too() { + let global = setup_repo(); + let sandbox_origin = setup_repo(); + git( + &sandbox_origin.work_dir, + &["push", "-q", "-u", "origin", "sandbox-work"], + ); + + let state = test_app_state_at(global.root.path(), &global.work_dir); + let sandbox = state + .sandbox_manager + .ensure(&crate::sandbox::GitSandboxConfig { + virtual_mcp_id: "shutdown-publish-sandbox".to_string(), + clone_url: sandbox_origin.bare_dir.to_string_lossy().into_owned(), + branch: Some("sandbox-work".to_string()), + ..Default::default() + }) + .await + .expect("materialize sandbox worktree"); + std::fs::write( + sandbox.workdir.join("sandbox-shutdown.txt"), + "saved from sandbox\n", + ) + .unwrap(); + + register(&state); + state.shutdown.run().await; + + let shown = Command::new("git") + .args([ + "--git-dir", + sandbox_origin.bare_dir.to_str().unwrap(), + "show", + "refs/heads/sandbox-work:sandbox-shutdown.txt", + ]) + .output() + .unwrap(); + assert!( + shown.status.success(), + "sandbox worktree was not published: {}", + String::from_utf8_lossy(&shown.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&shown.stdout), + "saved from sandbox\n" + ); + } + + #[tokio::test] + async fn shutdown_hook_publishes_persisted_worktree_absent_from_fresh_manager() { + let global = setup_repo(); + let sandbox_origin = setup_repo(); + git( + &sandbox_origin.work_dir, + &["push", "-q", "-u", "origin", "sandbox-work"], + ); + + let materializer = test_app_state_at(global.root.path(), &global.work_dir); + let sandbox = materializer + .sandbox_manager + .ensure(&crate::sandbox::GitSandboxConfig { + virtual_mcp_id: "persisted-shutdown-publish".to_string(), + clone_url: sandbox_origin.bare_dir.to_string_lossy().into_owned(), + branch: Some("sandbox-work".to_string()), + ..Default::default() + }) + .await + .expect("materialize durable sandbox worktree"); + std::fs::write( + sandbox.workdir.join("persisted-shutdown.txt"), + "saved after a later boot\n", + ) + .unwrap(); + + // Model a new process: its in-memory map starts empty, while the + // worktree and validated sidecar created above remain on disk. + let fresh = test_app_state_at(global.root.path(), &global.work_dir); + assert!(fresh.sandbox_manager.snapshot().is_empty()); + register(&fresh); + fresh.shutdown.run().await; + + let shown = Command::new("git") + .args([ + "--git-dir", + sandbox_origin.bare_dir.to_str().unwrap(), + "show", + "refs/heads/sandbox-work:persisted-shutdown.txt", + ]) + .output() + .unwrap(); + assert!( + shown.status.success(), + "persisted sandbox worktree was not published: {}", + String::from_utf8_lossy(&shown.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&shown.stdout), + "saved after a later boot\n" + ); + } + + #[tokio::test] + async fn branch_event_emitted_after_publish() { + let repo = setup_repo(); + std::fs::write(repo.work_dir.join("evented.txt"), "x\n").unwrap(); + + let state = test_app_state(&repo.work_dir); + let mut rx = state.broadcaster.subscribe(); + + let pushed = publish_internal(&state.repo_dir, "evented publish") + .await + .unwrap(); + assert!(pushed); + emit_branch_event(&state.repo_dir, &state.broadcaster).await; + + let evt = rx.recv().await.expect("branch event delivered"); + assert_eq!(evt.name, "branch"); + assert_eq!(evt.data["meta"]["kind"], "ready"); + assert_eq!(evt.data["meta"]["branch"], "sandbox-work"); + assert_eq!(evt.data["meta"]["workingTreeDirty"], false); + } +} diff --git a/apps/native/crates/local-api/src/routes/health.rs b/apps/native/crates/local-api/src/routes/health.rs new file mode 100644 index 0000000000..1cb2461faf --- /dev/null +++ b/apps/native/crates/local-api/src/routes/health.rs @@ -0,0 +1,32 @@ +//! `GET /health` — no auth, byte-parity shape with the daemon. Byte-parity +//! target: `daemon/routes/health.ts`, oracle `daemon.e2e.test.ts` ("GET +//! /health works without auth...") and `apps/native/e2e/health.e2e.test.ts`. +//! +//! `orchestrator`/`setup` now reflect the REAL clone -> install -> start +//! pipeline (`crate::setup::SetupOrchestrator`) — corrected from an earlier +//! Phase-1 stub that always reported `{running:false,pending:0}` under the +//! (since-corrected) assumption that local-api has no setup pipeline at all; +//! see `crate::setup`'s module doc. `waitForOrchestratorIdle()` in both +//! `daemon.e2e.helpers.ts` and (for the local-api contract suite) +//! `apps/native/e2e/helpers.ts` polls this endpoint until +//! `orchestrator.running === false && orchestrator.pending === 0`. + +use axum::{extract::State, Json}; +use serde_json::{json, Value}; + +use crate::state::AppState; + +pub async fn health(State(state): State) -> Json { + let configured = state.config.is_configured(); + let running = state.setup.is_running(); + let pending = state.setup.pending_count(); + Json(json!({ + "ready": true, + "bootId": state.boot_id.as_ref(), + "configured": configured, + "orchestrator": { "running": running, "pending": pending }, + // Legacy shape — daemon-client polls /health and validates this + // exists. Orchestrator queue empty -> setup is done. + "setup": { "running": running, "done": !running }, + })) +} diff --git a/apps/native/crates/local-api/src/routes/intercept/agent_sessions.rs b/apps/native/crates/local-api/src/routes/intercept/agent_sessions.rs new file mode 100644 index 0000000000..848021fde0 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/intercept/agent_sessions.rs @@ -0,0 +1,231 @@ +//! `GET /api/:org/agent-sandbox-sessions/:virtualMcpId` — the agent's sandbox +//! sessions, with this machine's included. +//! +//! Upstream answers from the cloud's session table, which has no row for a +//! sandbox that only exists here. The shell, the branch picker and the runtime +//! card all read this list, so a desktop-run agent otherwise looks like it has +//! no sessions at all. +//! +//! Same treatment as `sandbox_lifecycle`'s `sandboxMap` merge: proxy upstream +//! first, then EXTEND the response with local sandboxes rather than replacing +//! it — one user can have cloud sandboxes on some branches and desktop ones on +//! others, and the list has to show both. + +use axum::body::Bytes; +use axum::http::{header, HeaderMap, Method, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::{json, Map, Value}; + +use super::sandbox_lifecycle::local_sandbox_sessions; +use crate::routes::upstream; +use crate::state::AppState; + +pub(super) async fn try_dispatch( + state: &AppState, + method: &Method, + path_and_query: &str, + rest: &[&str], + query: Option<&str>, +) -> Option { + let ["agent-sandbox-sessions", encoded_virtual_mcp_id] = rest else { + return None; + }; + if *method != Method::GET { + return None; + } + let virtual_mcp_id = urlencoding::decode(encoded_virtual_mcp_id) + .map(|decoded| decoded.into_owned()) + .unwrap_or_else(|_| (*encoded_virtual_mcp_id).to_string()); + + let branch_filter = query.and_then(branch_param); + Some( + merged_sessions( + state, + path_and_query, + &virtual_mcp_id, + branch_filter.as_deref(), + ) + .await, + ) +} + +async fn merged_sessions( + state: &AppState, + path_and_query: &str, + virtual_mcp_id: &str, + branch_filter: Option<&str>, +) -> Response { + let mut items = upstream_items(path_and_query).await; + + // Local sandboxes WIN on a branch the cloud also lists: for a desktop + // agent this process is the authority on whether its own sandbox is + // running, and a stale cloud row would otherwise mask it. + for local in local_sandbox_sessions(state, virtual_mcp_id) { + let branch = local.get("branch").and_then(Value::as_str).unwrap_or(""); + if branch_filter.is_some_and(|wanted| wanted != branch) { + continue; + } + items.retain(|item| item.get("branch").and_then(Value::as_str) != Some(branch)); + items.push(local); + } + + Json(json!({ "items": items })).into_response() +} + +/// The cloud's own list, or an empty one. +/// +/// Upstream being unreachable must NOT hide this machine's sandboxes — the +/// desktop can run them with no network at all — so a failure degrades to +/// "cloud contributed nothing" rather than to an error. +async fn upstream_items(path_and_query: &str) -> Vec { + let mut headers = HeaderMap::new(); + // A payload we parse must not arrive gzipped; reqwest is built without it. + headers.insert( + header::ACCEPT_ENCODING, + header::HeaderValue::from_static("identity"), + ); + let Ok(response) = + upstream::send_org_request(Method::GET, path_and_query, headers, Bytes::new()).await + else { + return Vec::new(); + }; + if response.status() != StatusCode::OK { + return Vec::new(); + } + let Ok(bytes) = response.bytes().await else { + return Vec::new(); + }; + serde_json::from_slice::(&bytes) + .ok() + .and_then(|value| value.get("items")?.as_array().cloned()) + .unwrap_or_default() +} + +/// The `branch` value from a raw query string. +fn branch_param(query: &str) -> Option { + query.split('&').find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key == "branch").then(|| { + urlencoding::decode(value) + .map(|decoded| decoded.into_owned()) + .unwrap_or_else(|_| value.to_string()) + }) + }) +} + +/// One local sandbox, as the session wire shape needs it. +pub(super) struct LocalSession<'a> { + pub virtual_mcp_id: &'a str, + pub branch: &'a str, + pub handle: &'a str, + pub preview_url: Option<&'a str>, + pub desired_status: &'a str, + pub observed_status: &'a str, + pub failure_reason: Option<&'a str>, + pub updated_at_rfc3339: String, +} + +/// Shared by this module and its tests: the wire shape one session takes. +pub(super) fn session_json(session: LocalSession<'_>) -> Value { + let mut map = Map::new(); + map.insert("virtualMcpId".into(), json!(session.virtual_mcp_id)); + map.insert("branch".into(), json!(session.branch)); + map.insert("sandboxHandle".into(), json!(session.handle)); + map.insert("previewUrl".into(), json!(session.preview_url)); + // For user-desktop the preview origin IS the sandbox API origin. + map.insert("sandboxApiUrl".into(), json!(session.preview_url)); + map.insert( + "desiredState".into(), + json!(desired_state(session.desired_status)), + ); + map.insert("status".into(), json!(ui_status(session.observed_status))); + map.insert("startedWith".into(), Value::Null); + map.insert("failureReason".into(), json!(session.failure_reason)); + map.insert("updatedAt".into(), json!(session.updated_at_rfc3339)); + Value::Object(map) +} + +/// The registry's desired status, narrowed to the two the wire allows. +fn desired_state(desired: &str) -> &'static str { + if desired == "running" { + "running" + } else { + "stopped" + } +} + +/// Registry observed status -> the UI's session status. +/// +/// `stopping`/`reaping`/`deleting` have no local equivalent: this runtime +/// stops a sandbox synchronously, so it is never observed mid-transition. +fn ui_status(observed: &str) -> &'static str { + match observed { + "running" => "ready", + "provisioning" => "provisioning", + "failed" => "failed", + "absent" => "missing", + _ => "stopped", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_registry_state_onto_the_wire_status() { + assert_eq!(ui_status("running"), "ready"); + assert_eq!(ui_status("provisioning"), "provisioning"); + assert_eq!(ui_status("failed"), "failed"); + assert_eq!(ui_status("absent"), "missing"); + assert_eq!(ui_status("stopped"), "stopped"); + // Anything unrecognized reads as stopped rather than inventing a state. + assert_eq!(ui_status("something-new"), "stopped"); + + assert_eq!(desired_state("running"), "running"); + assert_eq!(desired_state("stopped"), "stopped"); + assert_eq!(desired_state(""), "stopped"); + } + + #[test] + fn a_session_carries_every_field_the_ui_reads() { + let value = session_json(LocalSession { + virtual_mcp_id: "vm-1", + branch: "main", + handle: "main-abc123", + preview_url: Some("http://localhost:5000/"), + desired_status: "running", + observed_status: "running", + failure_reason: None, + updated_at_rfc3339: "2026-07-27T12:00:00.000Z".to_string(), + }); + for key in [ + "virtualMcpId", + "branch", + "sandboxHandle", + "previewUrl", + "sandboxApiUrl", + "desiredState", + "status", + "startedWith", + "failureReason", + "updatedAt", + ] { + assert!(value.get(key).is_some(), "missing {key}"); + } + assert_eq!(value["status"], "ready"); + // The preview origin doubles as the sandbox API origin on desktop. + assert_eq!(value["sandboxApiUrl"], value["previewUrl"]); + } + + #[test] + fn reads_the_branch_filter_out_of_a_raw_query() { + assert_eq!(branch_param("branch=main").as_deref(), Some("main")); + assert_eq!( + branch_param("x=1&branch=feature%2Ffoo").as_deref(), + Some("feature/foo") + ); + assert_eq!(branch_param("other=1"), None); + } +} diff --git a/apps/native/crates/local-api/src/routes/intercept/decopilot.rs b/apps/native/crates/local-api/src/routes/intercept/decopilot.rs new file mode 100644 index 0000000000..0ca4f87361 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/intercept/decopilot.rs @@ -0,0 +1,4422 @@ +//! `/api/:org/decopilot/*` — chat dispatch + streaming. Wire contract: +//! the native interception contract §3.2. This ENTIRE +//! family is intercepted, 100% of the time (see `routes/intercept/mod.rs`'s +//! module doc for why) — [`dispatch`] never returns `None`. +//! +//! Routes: +//! - `POST threads/:threadId/messages` → [`send_message`]: `202 {taskId}`, +//! enqueues the turn on a durable SQLite per-thread FIFO (with a process-local +//! cancellation mirror). Different threads can run concurrently, but a thread +//! has exactly one active harness. A queued turn is not mirrored onto SSE +//! until it becomes the head, matching the production thread-gate contract. +//! - `GET threads/:threadId/stream` → [`stream`]: SSE `event: message` / +//! `data: ` frames — replays whatever's already happened +//! for this thread's current turn, then tails live until the turn ends. +//! A thread with no dispatch YET holds the connection open (matching the +//! real backend's "persistent connection stays open across runs" model, +//! `apps/api/src/api/routes/decopilot/routes.ts`'s stream handler) rather +//! than `204`ing — the real client (`thread-connection.ts`'s +//! `runSseLoop`) treats `204` as terminal ("nothing will ever come here, +//! stop reconnecting"), which is only correct for the real backend's own +//! degraded-JetStream case, never for an ordinary idle thread. `204` only +//! fires here for a genuinely-impossible state (see [`DecopilotRun`]'s +//! doc comment). +//! - `POST cancel/:threadId` → [`cancel`]: best-effort abort, `202`. +//! - `GET queue/:threadId` → [`queue_list`]: the running head + queued tail in +//! production's `QueueItemDTO` wire shape. +//! - `POST queue/:threadId/cancel/:workflowId` → [`queue_cancel`]: cancels an +//! active harness or removes a queued turn; `404` when the workflow is not +//! pending for this thread. +//! - anything else under this prefix → local `404`, never forwarded (the +//! 100%-intercepted backstop). +//! +//! ## Chunk translation: (almost) none needed +//! +//! `harness::run::RunEvent::Chunk` payloads are ALREADY AI-SDK +//! `UIMessageChunk`-shaped JSON (`{"type":"start"}`, `{"type":"text-delta", +//! ...}`, `{"type":"finish",...}`, ...) — see `crates/harness/src/events/ +//! {claude,codex}.rs`. This module's ENTIRE translation job is: (1) wrap +//! each chunk in `event: message\ndata: \n\n` framing (decopilot's +//! real SSE framing, map §3.2 — NOT dispatch's data-only framing), (2) +//! prepend one `data-user-message` chunk mirroring the just-sent turn (map +//! §3.2's "other viewers see it live" note — best-effort shape, not +//! byte-pinned anywhere this crate can verify), and (3) turn a +//! [`harness::run::RunEvent::FatalError`] into an AI-SDK-shaped `error` +//! chunk followed by a synthetic `finish` (mirrors `routes/dispatch.rs`'s +//! own `harness_crashed` handling, adapted to this family's chunk-shaped +//! error convention instead of dispatch's `{"type":"error","code":...}` +//! envelope). + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::convert::Infallible; +#[cfg(test)] +use std::future::Future; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use axum::body::{Body, Bytes}; +use axum::http::{header, Method, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use futures::future::join_all; +use futures::stream; +use regex::Regex; +use serde_json::{json, Value}; +use tokio::sync::{Mutex as AsyncMutex, Notify}; + +use crate::error::ApiError; +use crate::routes::intercept::run_spool::{RunSpool, RunSubscription, StagedFrame}; +use crate::routes::intercept::watch; +use crate::routes::threads::db::{ + is_native_assistant_message_id, legacy_native_assistant_message_id, DbError, RtAccountScope, + RtOrphanedTurn, RtThreadFence, RtTurnBeginOutcome, RtTurnCancelOutcome, RtTurnClaimOutcome, + RtTurnEnqueueInput, RtTurnEnqueueOutcome, RtTurnQueueItem, RtTurnQueueState, + RtTurnTerminalOutcome, RtTurnTerminalStatus, ThreadsDb, NATIVE_ASSISTANT_MESSAGE_ID_PREFIX, +}; +use crate::routes::threads::shared_db; +use crate::state::AppState; + +/// Opaque `virtual_mcp_id` stamped on a thread implicitly created by a +/// desktop dispatch (no prior `COLLECTION_THREADS_CREATE` call) — never +/// validated against a real virtual-MCP registry, see `routes/intercept/ +/// mod.rs`'s module doc's "`:org` is opaque" note. Mirrors the SHAPE of +/// `getWellKnownDecopilotVirtualMCP(orgId).id` (map §3.1) closely enough to +/// read as "the org's default agent" in a debugger, without importing any +/// TS constant. +fn implicit_virtual_mcp_id(org: &str) -> String { + format!("i:{org}:decopilot") +} + +/// `state.repo_dir` (today's plain-dir behavior, unchanged) unless the +/// dispatch body carries a `sandbox` block — see +/// the native Git-sandbox contract. The real UI already resolves +/// the virtual MCP's GitHub repo (owner/name/runtime) client-side before +/// sending a chat message; a git-backed thread includes it here as +/// `{ virtualMcpId, repo: { cloneUrl, branch }, workload?: { runtime, +/// packageManager, packageManagerPath } }` so this route never needs to +/// re-fetch it. A missing `sandbox` block does NOT mean "not git-backed" — +/// the webview also omits it while its lifecycle context is still attaching — +/// so it falls through to [`sandbox_config_from_thread`], and only a thread +/// whose agent really has no repository reaches the plain `repo_dir`. Ensure +/// failure (bad repo/branch, offline, ...) keeps the run in its OWN +/// `/sandboxes//repo` — never the shared `repo_dir`, see +/// [`crate::sandbox::SandboxManager::workdir_for`] — rather than failing the +/// dispatch outright: the clone/install failure is independently observable +/// via the sandbox's own `SetupOrchestrator` lifecycle, not this route's SSE +/// error frame. +async fn resolve_send_message_cwd( + state: &AppState, + input: &Value, + fence: &RtThreadFence, + db: &'static ThreadsDb, +) -> std::path::PathBuf { + let git_cfg = match git_sandbox_config_from_input(input) { + Some(mut cfg) => { + // The block names the repo but not the organization, and the org + // is what the shared org-filesystem mounts are keyed by. + cfg.org_slug + .get_or_insert_with(|| fence.organization_id.clone()); + Some(cfg) + } + None => sandbox_config_from_thread(fence, db).await, + }; + let Some(git_cfg) = git_cfg else { + // A gitless agent has no repository to sit in, so it runs INSIDE the + // organization filesystem: `/orgs/` is the directory + // whose children are the mounted volumes. That gives it `home`, + // `public`, `uploads` and `outputs` with no symlink indirection — + // and replaces the previous behavior, where EVERY gitless agent ran + // in one shared `state.repo_dir` and could see every other's files. + // + // Scoped per organization rather than per agent because the content + // is org-wide: two agents in one org would see identical volumes + // anyway, and cross-ORG is the boundary that actually matters. + let org_dir = + crate::sandbox::org_view::org_mount_root(&state.app_root, &fence.organization_id); + if let Some(org_dir) = org_dir { + crate::sandbox::org_mount::warm(&state.app_root, &fence.organization_id); + if tokio::fs::create_dir_all(&org_dir).await.is_ok() { + tracing::info!( + org = %fence.organization_id, + dir = %org_dir.display(), + "send_message: gitless agent runs in the organization filesystem" + ); + return org_dir; + } + } + tracing::warn!( + "send_message: could not prepare the organization directory → plain repo_dir" + ); + return state.repo_dir.clone(); + }; + tracing::info!( + virtual_mcp_id = %git_cfg.virtual_mcp_id, + branch = ?git_cfg.branch, + "send_message: sandbox block present → SandboxManager::ensure" + ); + match state.sandbox_manager.ensure(&git_cfg).await { + Ok(sandbox) => sandbox.workdir.clone(), + Err(err) => { + let workdir = state.sandbox_manager.workdir_for(&git_cfg); + tracing::warn!( + error = %err, + workdir = %workdir.display(), + "git sandbox ensure failed for decopilot send_message; staying in the sandbox workdir" + ); + let _ = tokio::fs::create_dir_all(&workdir).await; + workdir + } + } +} + +/// The thread's own git config, recovered from the local thread store plus +/// upstream, for a dispatch that carried no `sandbox` block. +/// +/// The webview omits that block whenever its sandbox lifecycle context has +/// not attached yet — a fresh thread, or a reload mid-provision. Treating +/// that window as "not git-backed" sent the agent, and every file it wrote, +/// into the shared `state.repo_dir` instead of the user's worktree: work +/// silently landed outside the branch it belonged to, in a directory every +/// other thread also writes. Nothing here needs the client's help — the +/// thread row already names its agent and the agent's metadata already names +/// its repository, so the desktop resolves it the same way `SANDBOX_START` +/// does. +/// +/// `None` only for a thread whose agent genuinely has no repository, which is +/// the one case where the plain repo dir is correct. +async fn sandbox_config_from_thread( + fence: &RtThreadFence, + db: &'static ThreadsDb, +) -> Option { + let thread = db.rt_get_thread_for_fence(fence).ok().flatten()?; + let virtual_mcp = crate::routes::upstream::call_org_tool( + &fence.organization_id, + "COLLECTION_VIRTUAL_MCP_GET", + &json!({ "id": thread.virtual_mcp_id }), + ) + .await + .map_err(|error| { + tracing::warn!( + %error, + thread_id = %fence.thread_id, + "could not resolve the thread's agent repository" + ); + }) + .ok()?; + // `COLLECTION_VIRTUAL_MCP_GET` answers `{ item: … }`; tolerate a bare + // entity too, matching `sandbox_lifecycle::start`. + let entity = virtual_mcp.get("item").unwrap_or(&virtual_mcp); + let config = super::sandbox_lifecycle::config_from_virtual_mcp( + &thread.virtual_mcp_id, + thread.branch.as_deref(), + entity.get("metadata")?, + Some(&fence.organization_id), + )?; + tracing::info!( + thread_id = %fence.thread_id, + virtual_mcp_id = %thread.virtual_mcp_id, + "send_message: no sandbox block → recovered the thread's repository from upstream" + ); + Some(config) +} + +/// Parses the `sandbox` block (see [`resolve_send_message_cwd`]'s doc +/// comment for the shape) — `None` when absent, or missing either required +/// field (`virtualMcpId`, `repo.cloneUrl`), which is treated exactly like +/// "not git-backed" rather than an error: this endpoint has no dedicated +/// `sandbox`-shape validation gate (unlike `routes/dispatch.rs`'s +/// `harnessStreamInputSchema` port), so a malformed block degrades to the +/// plain-dir default instead of failing the whole chat send. +fn git_sandbox_config_from_input(input: &Value) -> Option { + let sandbox = input.get("sandbox")?; + let virtual_mcp_id = sandbox + .get("virtualMcpId") + .and_then(Value::as_str)? + .to_string(); + let repo = sandbox.get("repo")?; + let clone_url = repo.get("cloneUrl").and_then(Value::as_str)?.to_string(); + let branch = repo + .get("branch") + .and_then(Value::as_str) + .map(str::to_string); + let workload = sandbox.get("workload"); + let runtime = workload + .and_then(|w| w.get("runtime")) + .and_then(Value::as_str) + .map(str::to_string); + let package_manager = workload + .and_then(|w| w.get("packageManager")) + .and_then(Value::as_str) + .map(str::to_string); + let package_manager_path = workload + .and_then(|w| w.get("packageManagerPath")) + .and_then(Value::as_str) + .map(str::to_string); + Some(crate::sandbox::GitSandboxConfig { + // The webview's sandbox block carries no org; `resolve_send_message_cwd` + // fills it from the turn's fence, which always has one. + org_slug: None, + virtual_mcp_id, + clone_url, + branch, + runtime, + package_manager, + package_manager_path, + git_user_name: None, + git_user_email: None, + }) +} + +/// One in-flight-or-just-finished chat turn's SSE frames, keyed by the +/// organization/thread pair in the process-wide [`registry`]. Replay bytes +/// live in a bounded 0600 disk spool; RAM contains only bounded live fan-out. +/// +/// ## Idle placeholder (GET arrives before any dispatch) +/// +/// [`stream`] eagerly creates (rather than 204ing) a registry entry for a +/// thread_id it's never seen — an empty spool, a live sender, zero +/// frames. [`send_message`] REUSES that same instance (see +/// [`Self::is_idle_placeholder`]) instead of unconditionally replacing it, +/// so the frame it pushes reaches the subscriber that GET already +/// registered, live — rather than orphaning that subscriber against a +/// channel nothing ever writes to. Once a dispatch has actually pushed +/// frames (or a later dispatch starts a genuinely new turn), the NEXT +/// dispatch replaces the entry outright (only the FIFO head is ever current; +/// later turns stay in the per-thread queue, map §3.2). +/// +/// Idle placeholders expire after ten minutes. Finished spools get a five +/// minute reconnect grace period, then their registry slot and file are +/// removed even if no client consumed the terminal stream. +/// +/// ## Replay policy (a stated simplification, not the real backend's exact +/// behavior) +/// +/// Every [`stream`] call replays the FULL disk spool from the start, then (if +/// the run is still live) tails new frames. The entry is removed from the +/// registry only once some consumer has read the stream through to its +/// natural end (see `stream`'s cleanup). For a single-viewer desktop +/// webview this is unobservable in practice (there's only ever one tab +/// tailing a thread); a multi-viewer scenario would see a full replay on +/// every reconnect instead of a true incremental tail, which map §3.2's +/// real contract doesn't need for desktop v1. +struct DecopilotRun { + key: ThreadKey, + spool: Arc, + has_frames: AtomicBool, + finished: AtomicBool, + finish_lock: AsyncMutex<()>, +} + +impl DecopilotRun { + async fn open(state: &AppState, key: ThreadKey) -> Result, String> { + let path = state + .app_root + .join(".decocms") + .join("run-streams") + .join(format!("{}.sse", uuid::Uuid::new_v4())); + let spool = RunSpool::open(path) + .await + .map_err(|error| error.to_string())?; + let run = Arc::new(Self { + key, + spool, + has_frames: AtomicBool::new(false), + finished: AtomicBool::new(false), + finish_lock: AsyncMutex::new(()), + }); + Ok(run) + } + + async fn push(&self, frame: Bytes) -> Result<(), String> { + self.spool + .append(frame) + .await + .map_err(|error| error.to_string())?; + self.has_frames.store(true, Ordering::SeqCst); + Ok(()) + } + + async fn stage_terminal(&self, frame: Bytes) -> Result { + self.spool + .stage(frame) + .await + .map_err(|error| error.to_string()) + } + + async fn commit_terminal(&self, staged: StagedFrame) -> Result<(), String> { + self.spool + .commit_staged(staged) + .await + .map_err(|error| error.to_string())?; + self.has_frames.store(true, Ordering::SeqCst); + Ok(()) + } + + async fn rollback_terminal(&self, staged: StagedFrame) -> Result<(), String> { + self.spool + .rollback_staged(staged) + .await + .map_err(|error| error.to_string()) + } + + async fn finish(self: &Arc) { + let guard = self.finish_lock.lock().await; + if self.finished.load(Ordering::SeqCst) { + return; + } + if let Err(error) = self.spool.finish().await { + tracing::warn!(%error, "failed to finish native chat run spool"); + // Keep the run retryable: setting `finished` before durable spool + // closure would make every later caller falsely believe the live + // sender was closed. + return; + } + self.finished.store(true, Ordering::SeqCst); + drop(guard); + let run = self.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(5 * 60)).await; + cleanup_run(&run).await; + }); + } + + async fn subscribe(&self) -> Result { + self.spool + .subscribe() + .await + .map_err(|error| error.to_string()) + } + + /// True only for a [`stream`]-created placeholder that no dispatch has + /// touched yet (empty spool, live sender still active) — see this struct's + /// "Idle placeholder" doc section. `send_message` reuses `self` rather + /// than replacing it exactly when this is true. + fn is_idle_placeholder(&self) -> bool { + !self.has_frames.load(Ordering::SeqCst) && !self.finished.load(Ordering::SeqCst) + } + + fn schedule_idle_cleanup(self: &Arc) { + let run = self.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(10 * 60)).await; + if run.is_idle_placeholder() { + cleanup_run(&run).await; + } + }); + } +} + +/// Registry ownership and file ownership are deliberately separate. An old +/// response may finish after a newer turn replaced its registry slot: identity +/// fencing protects that newer slot, but the old run still owns (and must +/// delete) its unique spool file. +async fn cleanup_run(run: &Arc) { + remove_run_if_current(&run.key, run); + if let Err(error) = run.spool.remove().await { + tracing::warn!(%error, "failed to remove native chat run spool"); + } +} + +/// Cancellation exists before a turn has a child process: cancelling a head +/// while it is resolving its sandbox/CLI must prevent that child from ever +/// escaping. Installing a handle and requesting cancellation are race-safe in +/// both orders (the installer re-checks `requested` after publishing it). +struct TurnCancellation { + requested: AtomicBool, + handle: Mutex>, +} + +impl TurnCancellation { + fn new() -> Arc { + Arc::new(Self { + requested: AtomicBool::new(false), + handle: Mutex::new(None), + }) + } + + fn is_requested(&self) -> bool { + self.requested.load(Ordering::SeqCst) + } + + fn install(&self, handle: harness::run::CancelHandle) -> bool { + *self.handle.lock().unwrap() = Some(handle); + self.is_requested() + } + + async fn request(&self) { + self.requested.store(true, Ordering::SeqCst); + let handle = self.handle.lock().unwrap().clone(); + if let Some(handle) = handle { + handle.cancel().await; + } + } +} + +#[derive(Clone)] +struct QueuedTurn { + key: ThreadKey, + fence: RtThreadFence, + workflow_id: String, + message_id: String, + input: Value, + user_message: Value, + #[cfg(test)] + enqueued_at: u64, + #[cfg(test)] + text: String, + #[cfg(test)] + has_attachments: bool, + cancellation: Arc, +} + +impl QueuedTurn { + fn from_durable(item: RtTurnQueueItem) -> Self { + let user_message = item.user_message; + Self { + key: ThreadKey { + account_scope: item.fence.account_scope.clone(), + org: item.fence.organization_id.clone(), + thread_id: item.fence.thread_id.clone(), + }, + fence: item.fence, + workflow_id: item.workflow_id, + message_id: item.message_id, + input: item.normalized_input, + #[cfg(test)] + text: queue_text(&user_message), + #[cfg(test)] + has_attachments: has_attachments(&user_message), + user_message, + #[cfg(test)] + enqueued_at: item.enqueued_at, + cancellation: TurnCancellation::new(), + } + } +} + +/// Runtime registries are tenant-scoped even though the production workflow id +/// string is not: two organizations may legitimately reuse a local thread id, +/// and neither SSE replay nor cancellation may cross that boundary. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct ThreadKey { + account_scope: String, + org: String, + thread_id: String, +} + +impl ThreadKey { + fn scoped(scope: &RtAccountScope, org: &str, thread_id: &str) -> Self { + Self { + account_scope: scope.storage_key(), + org: org.to_string(), + thread_id: thread_id.to_string(), + } + } + + fn from_fence(fence: &RtThreadFence) -> Self { + Self { + account_scope: fence.account_scope.clone(), + org: fence.organization_id.clone(), + thread_id: fence.thread_id.clone(), + } + } + + #[cfg(test)] + fn new(org: &str, thread_id: &str) -> Self { + Self::scoped( + &RtAccountScope::new("test.invalid", "local-desktop-user").unwrap(), + org, + thread_id, + ) + } +} + +struct ThreadQueueInner { + /// The front is the running/resolving head whenever `worker_running` is + /// true. Keeping the head in this deque until *all* persistence and the + /// terminal SSE publish complete makes LIST/cancel atomic and truthful. + items: VecDeque, + worker_running: bool, + /// Set by thread deletion or app shutdown. The current head may finish + /// its cancellation path, but it must never promote another queued turn. + stop_after_current: bool, + /// Sticky process-lifetime evidence that a harness stopped without its + /// terminal SQLite transaction committing. Shutdown must not report a + /// clean reap merely because the process-local worker is no longer alive: + /// the durable head still needs boot recovery. + durable_terminal_failed: bool, +} + +struct ThreadQueue { + inner: Mutex, + changed: Notify, +} + +enum EnqueueOutcome { + Duplicate, + Enqueued { first: Option> }, +} + +type DurableClaimAdmission = ( + Result, DbError>, + Option, +); + +#[cfg(test)] +enum QueueCancelOutcome { + Active(Arc), + Queued, + NotFound, +} + +impl ThreadQueue { + fn new() -> Arc { + Arc::new(Self { + inner: Mutex::new(ThreadQueueInner { + items: VecDeque::new(), + worker_running: false, + stop_after_current: false, + durable_terminal_failed: false, + }), + changed: Notify::new(), + }) + } + + /// Atomically dedupes against both the active head and queued tail. The + /// returned `first` is an ownership token: exactly its recipient may start + /// a drain worker, so two concurrent POSTs can never both launch a harness. + fn enqueue(&self, turn: QueuedTurn) -> EnqueueOutcome { + let mut inner = self.inner.lock().unwrap(); + if inner + .items + .iter() + .any(|item| item.message_id == turn.message_id) + { + return EnqueueOutcome::Duplicate; + } + + inner.items.push_back(turn); + let first = if inner.worker_running { + None + } else { + inner.stop_after_current = false; + inner.worker_running = true; + inner.items.front().cloned().map(Box::new) + }; + self.changed.notify_waiters(); + EnqueueOutcome::Enqueued { first } + } + + /// Claims process-local worker ownership for a durable queue discovered + /// from SQLite. Recovery deliberately does not pre-decode every queued + /// payload into this RAM mirror: the worker claims and parses one raw FIFO + /// head at a time, so a malformed tail cannot fail account recovery. + fn start_worker_if_idle(&self) -> bool { + let mut inner = self.inner.lock().unwrap(); + if inner.worker_running { + return false; + } + inner.stop_after_current = false; + inner.worker_running = true; + self.changed.notify_waiters(); + true + } + + /// Completes exactly the head the worker just executed and atomically + /// hands it the next item while retaining worker ownership. If empty, the + /// worker relinquishes ownership before returning; a concurrent enqueue + /// then receives a fresh `first` token and starts the next worker. + #[cfg(test)] + fn complete_and_next(&self, workflow_id: &str) -> Option { + let mut inner = self.inner.lock().unwrap(); + if inner.items.front().map(|item| item.workflow_id.as_str()) != Some(workflow_id) { + tracing::error!( + workflow_id, + "decopilot queue worker attempted to complete a non-head turn" + ); + inner.worker_running = false; + return None; + } + inner.items.pop_front(); + if inner.stop_after_current { + inner.worker_running = false; + self.changed.notify_waiters(); + return None; + } + let next = inner.items.front().cloned(); + if next.is_none() { + inner.worker_running = false; + } + self.changed.notify_waiters(); + next + } + + #[cfg(test)] + fn list(&self) -> Vec { + let inner = self.inner.lock().unwrap(); + inner + .items + .iter() + .enumerate() + .map(|(index, item)| { + json!({ + "workflowId": item.workflow_id, + "messageId": item.message_id, + "status": if index == 0 && inner.worker_running { "running" } else { "queued" }, + "enqueuedAt": item.enqueued_at, + "source": "user-message", + "text": item.text, + "hasAttachments": item.has_attachments, + }) + }) + .collect() + } + + #[cfg(test)] + fn cancel_workflow(&self, workflow_id: &str) -> QueueCancelOutcome { + let mut inner = self.inner.lock().unwrap(); + let Some(index) = inner + .items + .iter() + .position(|item| item.workflow_id == workflow_id) + else { + return QueueCancelOutcome::NotFound; + }; + if index == 0 && inner.worker_running { + return QueueCancelOutcome::Active(inner.items[0].cancellation.clone()); + } + inner.items.remove(index); + self.changed.notify_waiters(); + QueueCancelOutcome::Queued + } + + /// Reconciles the process-local cancellation handle with SQLite's claimed + /// head while the worker retains the queue admission mutex across its stop + /// check, durable claim, and cancellation-handle installation. + /// That single critical section prevents shutdown from observing an empty + /// recovered RAM mirror after the durable head was already claimed. + fn activate_claimed_locked( + &self, + inner: &mut ThreadQueueInner, + item: RtTurnQueueItem, + ) -> QueuedTurn { + let position = inner + .items + .iter() + .position(|turn| turn.workflow_id == item.workflow_id); + let cancellation = position + .and_then(|index| inner.items.get(index)) + .map(|turn| turn.cancellation.clone()) + .unwrap_or_else(TurnCancellation::new); + if let Some(index) = position { + if index > 0 { + tracing::warn!( + workflow_id = item.workflow_id, + stale_items = index, + "dropping stale in-memory queue entries before durable head" + ); + inner.items.drain(..index); + } + inner.items.pop_front(); + } + let mut turn = QueuedTurn::from_durable(item); + turn.cancellation = cancellation; + inner.items.push_front(turn.clone()); + self.changed.notify_waiters(); + turn + } + + /// Returns `None` when shutdown/delete won admission. Otherwise the SQLite + /// claim result and (for a ready head) its already-installed RAM mirror are + /// returned after releasing the non-async mutex, keeping the drain future + /// `Send` while preserving the atomic stop-vs-claim boundary. + fn claim_durable_head( + &self, + db: &ThreadsDb, + fence: &RtThreadFence, + ) -> Option { + let mut inner = self.inner.lock().unwrap(); + if inner.stop_after_current { + inner.items.clear(); + inner.worker_running = false; + self.changed.notify_waiters(); + return None; + } + let claim_result = db.rt_claim_turn_queue_head_fenced(fence); + let activated = match &claim_result { + Ok(Some(RtTurnClaimOutcome::Ready(item))) => { + Some(self.activate_claimed_locked(&mut inner, item.clone())) + } + _ => None, + }; + Some((claim_result, activated)) + } + + /// Removes a completed claimed head while retaining worker ownership. The + /// worker itself performs the next SQLite claim under the lifecycle gate; + /// relinquishing ownership merely because the RAM mirror is momentarily + /// empty would let a concurrent sender start a second worker. + fn complete_claimed(&self, workflow_id: &str) -> bool { + let mut inner = self.inner.lock().unwrap(); + if let Some(index) = inner + .items + .iter() + .position(|turn| turn.workflow_id == workflow_id) + { + inner.items.remove(index); + } + let stop = inner.stop_after_current; + if stop { + inner.worker_running = false; + } + self.changed.notify_waiters(); + stop + } + + fn stop_worker(&self) { + let mut inner = self.inner.lock().unwrap(); + inner.worker_running = false; + self.changed.notify_waiters(); + } + + /// Records a lost durable completion fence before releasing worker + /// ownership. This flag is intentionally never cleared on this queue: a + /// later in-process enqueue/reap cannot prove the failed transaction was + /// recovered, while the next process boot owns durable recovery. + fn stop_worker_after_durable_terminal_failure(&self) { + let mut inner = self.inner.lock().unwrap(); + inner.durable_terminal_failed = true; + inner.worker_running = false; + self.changed.notify_waiters(); + } + + fn durable_terminal_failed(&self) -> bool { + self.inner.lock().unwrap().durable_terminal_failed + } + + fn cancellation_for(&self, workflow_id: &str) -> Option> { + self.inner + .lock() + .unwrap() + .items + .iter() + .find(|turn| turn.workflow_id == workflow_id) + .map(|turn| turn.cancellation.clone()) + } + + fn remove_mirror(&self, workflow_id: &str) { + let mut inner = self.inner.lock().unwrap(); + if let Some(index) = inner + .items + .iter() + .position(|turn| turn.workflow_id == workflow_id) + { + inner.items.remove(index); + self.changed.notify_waiters(); + } + } + + /// Prevents any queued tail from being promoted, removes that tail from + /// the process-local mirror, and returns the active head's cancellation + /// handle plus the removed workflow ids. Durable queue rows are mutated by + /// the caller while holding the same thread lifecycle gate. + fn stop_and_drain_tail(&self) -> (Option>, Vec) { + let mut inner = self.inner.lock().unwrap(); + inner.stop_after_current = true; + let active = inner + .worker_running + .then(|| inner.items.front().map(|turn| turn.cancellation.clone())) + .flatten(); + let keep = usize::from(active.is_some()); + let removed = inner + .items + .drain(keep..) + .map(|turn| turn.workflow_id) + .collect(); + // A recovered worker may be alive but not yet have mirrored its first + // durable claim. Empty RAM therefore does not prove there is no worker; + // the worker relinquishes ownership at its claim-admission boundary. + self.changed.notify_waiters(); + (active, removed) + } + + async fn wait_worker_stopped(&self) { + loop { + let changed = self.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if !self.inner.lock().unwrap().worker_running { + return; + } + changed.await; + } + } + + fn is_idle(&self) -> bool { + let inner = self.inner.lock().unwrap(); + !inner.worker_running && inner.items.is_empty() && !inner.durable_terminal_failed + } +} + +fn registry() -> &'static Mutex>> { + static REGISTRY: OnceLock>>> = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn thread_queues() -> &'static Mutex>> { + static QUEUES: OnceLock>>> = OnceLock::new(); + QUEUES.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn lifecycle_gates() -> &'static Mutex>>> { + static GATES: OnceLock>>>> = OnceLock::new(); + GATES.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn recovery_gates() -> &'static Mutex>>> { + static GATES: OnceLock>>>> = OnceLock::new(); + GATES.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn recovered_accounts() -> &'static Mutex> { + static RECOVERED: OnceLock>> = OnceLock::new(); + RECOVERED.get_or_init(|| Mutex::new(HashSet::new())) +} + +fn closing_threads() -> &'static Mutex> { + static CLOSING: OnceLock>> = OnceLock::new(); + CLOSING.get_or_init(|| Mutex::new(HashSet::new())) +} + +pub(crate) fn mark_thread_closing(scope: &RtAccountScope, org: &str, thread_id: &str) { + closing_threads() + .lock() + .unwrap() + .insert(ThreadKey::scoped(scope, org, thread_id)); +} + +pub(crate) fn clear_thread_closing(scope: &RtAccountScope, org: &str, thread_id: &str) { + closing_threads() + .lock() + .unwrap() + .remove(&ThreadKey::scoped(scope, org, thread_id)); +} + +pub(crate) fn thread_is_closing(scope: &RtAccountScope, org: &str, thread_id: &str) -> bool { + closing_threads() + .lock() + .unwrap() + .contains(&ThreadKey::scoped(scope, org, thread_id)) +} + +/// Serializes the accept/delete boundary for one tenant-scoped thread. A send +/// must not return `202` while deletion is cancelling the old generation, and +/// a recreated thread id must not become visible until that worker is gone. +pub(crate) fn thread_lifecycle_gate( + scope: &RtAccountScope, + org: &str, + thread_id: &str, +) -> Arc> { + thread_lifecycle_gate_for_key(&ThreadKey::scoped(scope, org, thread_id)) +} + +fn thread_lifecycle_gate_for_key(key: &ThreadKey) -> Arc> { + lifecycle_gates() + .lock() + .unwrap() + .entry(key.clone()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone() +} + +fn enqueue_thread_turn(key: &ThreadKey, turn: QueuedTurn) -> (Arc, EnqueueOutcome) { + // Keep the registry lock through `enqueue`: this pairs with + // `remove_queue_if_idle` and prevents a sender from retaining an idle queue + // just as cleanup removes it, then enqueueing onto an orphan Arc. + let mut queues = thread_queues().lock().unwrap(); + let queue = queues + .entry(key.clone()) + .or_insert_with(ThreadQueue::new) + .clone(); + let outcome = queue.enqueue(turn); + (queue, outcome) +} + +fn start_recovered_thread_queue(key: &ThreadKey) -> (Arc, bool) { + let mut queues = thread_queues().lock().unwrap(); + let queue = queues + .entry(key.clone()) + .or_insert_with(ThreadQueue::new) + .clone(); + let started = queue.start_worker_if_idle(); + (queue, started) +} + +fn remove_queue_if_idle(key: &ThreadKey, expected: &Arc) -> bool { + let mut queues = thread_queues().lock().unwrap(); + let is_current = queues + .get(key) + .is_some_and(|current| Arc::ptr_eq(current, expected)); + if is_current && expected.is_idle() { + queues.remove(key); + return true; + } + false +} + +fn epoch_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +fn queue_text(user_message: &Value) -> String { + user_message + .get("parts") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|part| part.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::() + .trim() + .to_string() +} + +fn has_attachments(user_message: &Value) -> bool { + user_message + .get("parts") + .and_then(Value::as_array) + .is_some_and(|parts| { + parts + .iter() + .any(|part| part.get("type").and_then(Value::as_str) == Some("file")) + }) +} + +/// Retains only fields the local harness execution path consumes. HTTP auth +/// lives in headers and never reaches this value, but an explicit allowlist +/// also prevents future request-only secrets from silently becoming durable +/// SQLite data. The user message is stored separately by the queue row. +fn normalized_execution_input(input: &Value) -> Value { + const KEYS: &[&str] = &[ + "harnessId", + "tier", + "mode", + "toolApprovalLevel", + "branch", + "sandboxProviderKind", + "sandbox", + ]; + let mut normalized = serde_json::Map::new(); + for key in KEYS { + if let Some(value) = input.get(*key) { + normalized.insert((*key).to_string(), value.clone()); + } + } + Value::Object(normalized) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HarnessSelection { + Pinned(harness::HarnessId), + Requested(harness::HarnessId), + Detect, +} + +fn harness_selection( + pinned_harness_id: Option<&str>, + requested_harness_id: Option<&str>, +) -> Result { + if let Some(pinned_harness_id) = pinned_harness_id { + return harness::HarnessId::from_wire_id(pinned_harness_id) + .map(HarnessSelection::Pinned) + .ok_or_else(|| { + format!("thread is pinned to unsupported local harness {pinned_harness_id:?}") + }); + } + Ok( + match requested_harness_id.and_then(harness::HarnessId::from_wire_id) { + Some(harness_id) => HarnessSelection::Requested(harness_id), + None => HarnessSelection::Detect, + }, + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NativeRunStatusStage { + StartingRun, + GatheringContext, + PreparingTools, + StartingAssistant, +} + +impl NativeRunStatusStage { + const fn wire_id(self) -> &'static str { + match self { + Self::StartingRun => "starting-run", + Self::GatheringContext => "gathering-context", + Self::PreparingTools => "preparing-tools", + Self::StartingAssistant => "starting-assistant", + } + } +} + +fn run_status_chunk(stage: NativeRunStatusStage) -> Value { + json!({ + "type": "data-run-status", + "id": "run-status", + "data": {"stage": stage.wire_id()}, + }) +} + +fn assistant_start_chunk(claimed: &RtTurnQueueItem) -> Value { + json!({ + "type": "start", + "messageId": reserved_assistant_message_id(claimed), + }) +} + +async fn publish_run_status(run: &DecopilotRun, stage: NativeRunStatusStage) -> Result<(), String> { + run.push(frame(&run_status_chunk(stage))).await +} + +fn elapsed_millis(started: Instant) -> u64 { + u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX) +} + +fn is_meaningful_harness_chunk(chunk: &Value) -> bool { + !matches!( + chunk.get("type").and_then(Value::as_str), + None | Some("start") | Some("start-step") | Some("finish-step") + ) +} + +fn reserved_assistant_message_id(turn: &RtTurnQueueItem) -> String { + turn.assistant_message_id + .clone() + .unwrap_or_else(|| legacy_native_assistant_message_id(&turn.message_id)) +} + +fn malformed_reserved_assistant_message_id( + turn: &crate::routes::threads::db::RtMalformedOrphanedTurn, +) -> String { + turn.assistant_message_id + .clone() + .unwrap_or_else(|| legacy_native_assistant_message_id(&turn.message_id)) +} + +const INTERRUPTED_RESPONSE: &str = + "This response was interrupted because the desktop app stopped. Send your message again to retry."; + +fn recovered_assistant_payload( + db: &ThreadsDb, + fence: &RtThreadFence, + assistant_id: &str, + workflow_id: &str, +) -> Result<(Value, Option), String> { + let Some(assistant) = db + .rt_get_message_fenced(fence, assistant_id) + .map_err(|error| error.to_string())? + else { + return Ok(( + json!([{"type": "text", "text": INTERRUPTED_RESPONSE}]), + Some(json!({"interrupted": true})), + )); + }; + if assistant.thread_id != fence.thread_id || assistant.role != "assistant" { + return Err(format!( + "assistant completion fence {assistant_id} conflicts with interrupted workflow {workflow_id}" + )); + } + // Older native builds could commit the assistant before crashing prior to + // queue cleanup. Reuse its exact payload so the new exact-idempotent + // terminal transaction can adopt and close that legacy split boundary. + Ok((assistant.parts, assistant.metadata)) +} + +fn finalize_orphaned_turn(db: &ThreadsDb, turn: &RtTurnQueueItem) -> Result<(), String> { + match db + .rt_begin_claimed_turn(turn) + .map_err(|error| error.to_string())? + { + RtTurnBeginOutcome::Begun(_) | RtTurnBeginOutcome::CancelRequested => {} + RtTurnBeginOutcome::Stale => return Ok(()), + } + let assistant_id = reserved_assistant_message_id(turn); + let (parts, metadata) = + recovered_assistant_payload(db, &turn.fence, &assistant_id, &turn.workflow_id)?; + match db + .rt_finalize_claimed_turn( + turn, + &parts, + metadata.as_ref(), + RtTurnTerminalStatus::Failed, + ) + .map_err(|error| error.to_string())? + { + RtTurnTerminalOutcome::Completed(_) + | RtTurnTerminalOutcome::Quarantined + | RtTurnTerminalOutcome::Stale => Ok(()), + } +} + +fn finalize_malformed_orphan( + db: &ThreadsDb, + orphan: &crate::routes::threads::db::RtMalformedOrphanedTurn, +) -> Result<(), String> { + let (parts, metadata) = if orphan.canonical_user.is_some() { + let assistant_id = malformed_reserved_assistant_message_id(orphan); + recovered_assistant_payload(db, &orphan.fence, &assistant_id, &orphan.workflow_id)? + } else { + // The DB ignores this payload for unsafe quarantines. Avoid reading an + // assistant id that may deliberately belong to another legacy turn. + (Value::Array(Vec::new()), None) + }; + match db + .rt_finalize_malformed_orphan(orphan, &parts, metadata.as_ref()) + .map_err(|error| error.to_string())? + { + RtTurnTerminalOutcome::Completed(_) + | RtTurnTerminalOutcome::Quarantined + | RtTurnTerminalOutcome::Stale => Ok(()), + } +} + +fn finalize_active_malformed_orphan( + db: &ThreadsDb, + orphan: &crate::routes::threads::db::RtMalformedOrphanedTurn, +) -> Result<(), String> { + finalize_malformed_orphan(db, orphan)?; + emit_committed_thread_status_fenced( + db, + &orphan.fence, + &orphan.workflow_id, + watch::ThreadStatus::Failed, + None, + ); + Ok(()) +} + +/// Reconciles the durable queue before either HTTP listener starts serving. +/// Previously-running turns are explicitly failed rather than rerun because a +/// CLI may already have performed non-idempotent filesystem/network actions; +/// untouched queued tails are safe to resume automatically. +pub(crate) async fn recover_durable_queue(state: &AppState) -> Result<(), String> { + cleanup_stale_run_spools(state).await?; + let Some(scope) = super::thread_tools::current_account_scope().await else { + tracing::debug!("signed out at startup; native chat queue recovery deferred"); + return Ok(()); + }; + ensure_account_recovered(state, &scope).await +} + +/// Runs queue recovery once for an authenticated upstream+user scope. Startup +/// calls this when a Keychain session is already available; the interception +/// router calls it lazily on the first scoped request so signing in after a +/// signed-out boot does not require another app restart. +pub(crate) async fn ensure_account_recovered( + state: &AppState, + scope: &RtAccountScope, +) -> Result<(), String> { + let recovery_key = format!("{}\0{}", state.app_root.display(), scope.storage_key()); + if recovered_accounts().lock().unwrap().contains(&recovery_key) { + return Ok(()); + } + let gate = recovery_gates() + .lock() + .unwrap() + .entry(recovery_key.clone()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone(); + let _guard = gate.lock().await; + if recovered_accounts().lock().unwrap().contains(&recovery_key) { + return Ok(()); + } + recover_durable_queue_for_scope(state, scope).await?; + recovered_accounts().lock().unwrap().insert(recovery_key); + Ok(()) +} + +async fn recover_durable_queue_for_scope( + state: &AppState, + scope: &RtAccountScope, +) -> Result<(), String> { + let db = shared_db(state).map_err(|error| format!("{:?}", error.body))?; + db.prepare_account_scope(scope) + .map_err(|error| error.to_string())?; + let mut finalization_errors = Vec::new(); + for orphan in db + .rt_list_orphaned_active_turns_scoped(scope) + .map_err(|error| error.to_string())? + { + let (fence, workflow_id, result) = match &orphan { + RtOrphanedTurn::Ready(turn) => ( + &turn.fence, + turn.workflow_id.as_str(), + finalize_orphaned_turn(db, turn), + ), + RtOrphanedTurn::Malformed(turn) => ( + &turn.fence, + turn.workflow_id.as_str(), + finalize_malformed_orphan(db, turn), + ), + }; + if let Err(error) = result { + tracing::error!( + %error, + workflow_id, + org = fence.organization_id, + thread_id = fence.thread_id, + "failed to isolate and finalize orphaned native chat turn" + ); + finalization_errors.push(format!( + "{}/{}@{} workflow {}: {}", + fence.organization_id, fence.thread_id, fence.generation, workflow_id, error + )); + } + } + + // Fail the entire account closed before launching any untouched queued + // work. Startup propagates this error and drops its server/instance-lock; + // spawning a harness first would let paid side effects outlive a failed + // boot and overlap the next process that acquires the app root. + if !finalization_errors.is_empty() { + return Err(format!( + "native chat recovery left {} orphaned turn(s) retryable: {}", + finalization_errors.len(), + finalization_errors.join("; ") + )); + } + + for fence in db + .rt_list_recoverable_turn_queues_scoped(scope) + .map_err(|error| error.to_string())? + { + let key = ThreadKey::from_fence(&fence); + let (queue, started) = start_recovered_thread_queue(&key); + if started { + spawn_durable_thread_worker(state.clone(), db, key, fence, queue); + } + } + Ok(()) +} + +/// Run registry entries are intentionally process-local, so a hard crash can +/// leave random-name spool files that no future process could associate with a +/// thread. Clear only regular `.sse` files directly inside the app-owned spool +/// directory before queue recovery starts; unrelated files and nested paths +/// are never touched. +async fn cleanup_stale_run_spools(state: &AppState) -> Result<(), String> { + let directory = state.app_root.join(".decocms").join("run-streams"); + let mut entries = match tokio::fs::read_dir(&directory).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "failed to inspect stale native chat run spools at {directory:?}: {error}" + )); + } + }; + while let Some(entry) = entries + .next_entry() + .await + .map_err(|error| format!("failed to enumerate {directory:?}: {error}"))? + { + let path = entry.path(); + let is_regular_file = entry + .file_type() + .await + .map_err(|error| format!("failed to inspect stale run spool {path:?}: {error}"))? + .is_file(); + if is_regular_file && path.extension().and_then(|value| value.to_str()) == Some("sse") { + tokio::fs::remove_file(&path) + .await + .map_err(|error| format!("failed to remove stale run spool {path:?}: {error}"))?; + } + } + Ok(()) +} + +/// Direct, idempotent Decopilot reap phase used by `ServerHandle::shutdown`. +/// Queued tails stay in SQLite for the next boot; active heads are cancelled +/// concurrently and every queue worker is joined before setup/git shutdown +/// advances. Repeated calls simply observe already-stopped queues. +pub(crate) async fn shutdown_all(budget: Duration) -> bool { + let queues: Vec> = thread_queues().lock().unwrap().values().cloned().collect(); + stop_thread_queues(queues, budget).await +} + +/// Stops every process-local chat queue under one wall-clock budget. All active +/// cancellations start together, then every worker is joined together; neither +/// phase multiplies latency by the number of threads. +async fn stop_thread_queues(queues: Vec>, budget: Duration) -> bool { + let cancellations: Vec> = queues + .iter() + .filter_map(|queue| queue.stop_and_drain_tail().0) + .collect(); + let stopped = tokio::time::timeout(budget, async { + join_all(cancellations.iter().map(|active| active.request())).await; + join_all(queues.iter().map(|queue| queue.wait_worker_stopped())).await; + }) + .await + .is_ok(); + if !stopped { + tracing::error!( + queue_count = queues.len(), + ?budget, + "timed out waiting for native chat harnesses to stop during shutdown" + ); + } + let durable_terminal_failure_count = queues + .iter() + .filter(|queue| queue.durable_terminal_failed()) + .count(); + if durable_terminal_failure_count > 0 { + tracing::error!( + queue_count = queues.len(), + durable_terminal_failure_count, + "native chat harnesses stopped without committing every durable terminal transaction" + ); + } + stopped && durable_terminal_failure_count == 0 +} + +/// Cancels one thread generation and waits until its worker has reaped the +/// harness. The caller marks the thread closing before entering and performs +/// the final generation-fenced thread delete afterwards. +pub(crate) async fn quiesce_thread_for_delete( + state: &AppState, + fence: &RtThreadFence, +) -> Result<(), ApiError> { + quiesce_thread_for_delete_with_timeout(state, fence, Duration::from_secs(15)).await +} + +async fn quiesce_thread_for_delete_with_timeout( + state: &AppState, + fence: &RtThreadFence, + stop_budget: Duration, +) -> Result<(), ApiError> { + let db = shared_db(state)?; + db.rt_cancel_all_turns_in_org(fence) + .map_err(|error| ApiError::internal(format!("thread queue database error: {error}")))?; + let key = ThreadKey::from_fence(fence); + let queue = thread_queues().lock().unwrap().get(&key).cloned(); + if let Some(queue) = &queue { + let (active, _) = queue.stop_and_drain_tail(); + if let Some(active) = active { + active.request().await; + } + tokio::time::timeout(stop_budget, queue.wait_worker_stopped()) + .await + .map_err(|_| ApiError::conflict("thread agent is still stopping"))?; + } + + let run = registry().lock().unwrap().remove(&key); + if let Some(run) = run { + run.finish().await; + // Deletion has no reconnect grace period: the parent thread and all of + // its durable messages are about to disappear, so its replay file must + // disappear in the same lifecycle when possible. It is not part of the + // database consistency boundary, however: a transient filesystem error + // must not fail DELETE after every harness is already quiescent and + // strand its accepted queued tails behind a half-torn-down RAM state. + if let Err(error) = run.spool.remove().await { + tracing::warn!(%error, "failed to remove deleted thread run spool"); + } + } + thread_queues().lock().unwrap().remove(&key); + Ok(()) +} + +fn frame(chunk: &Value) -> Bytes { + Bytes::from(format!( + "event: message\ndata: {}\n\n", + serde_json::to_string(chunk).unwrap_or_else(|_| "null".to_string()) + )) +} + +fn sse_headers() -> [(header::HeaderName, &'static str); 3] { + [ + (header::CONTENT_TYPE, "text/event-stream"), + (header::CACHE_CONTROL, "no-store"), + (header::CONNECTION, "keep-alive"), + ] +} + +pub async fn dispatch( + state: &AppState, + scope: &RtAccountScope, + org: &str, + method: &Method, + rest: &[&str], + body: &Bytes, +) -> Response { + match rest { + ["threads", thread_id, "messages"] if *method == Method::POST => { + send_message(state, scope, org, thread_id, body).await + } + ["threads", thread_id, "stream"] if *method == Method::GET => { + stream(state, scope, org, thread_id).await + } + ["cancel", thread_id] if *method == Method::POST => { + cancel(state, scope, org, thread_id).await + } + ["queue", thread_id] if *method == Method::GET => queue_list(state, scope, org, thread_id), + ["queue", thread_id, "cancel", workflow_id] if *method == Method::POST => { + let workflow_id = match urlencoding::decode(workflow_id) { + Ok(decoded) => decoded, + Err(error) => { + return ApiError::bad_request(format!( + "invalid percent-encoded workflow id: {error}" + )) + .into_response(); + } + }; + queue_cancel(state, scope, org, thread_id, &workflow_id).await + } + _ => ApiError::not_found(format!( + "Not found: /decopilot/{} (desktop local-api intercepts the entire /decopilot/* \ + family and does not forward it upstream — see routes/intercept/mod.rs)", + rest.join("/") + )) + .into_response(), + } +} + +fn queue_list(state: &AppState, scope: &RtAccountScope, org: &str, thread_id: &str) -> Response { + let db = match shared_db(state) { + Ok(db) => db, + Err(error) => return error.into_response(), + }; + let items: Vec = match db.rt_list_turn_queue_scoped(scope, org, thread_id) { + Ok(items) => items + .into_iter() + .map(|item| { + json!({ + "workflowId": item.workflow_id, + "messageId": item.message_id, + "status": match item.state { + RtTurnQueueState::Queued => "queued", + RtTurnQueueState::Running | RtTurnQueueState::CancelRequested => "running", + }, + "enqueuedAt": item.enqueued_at, + "source": "user-message", + "text": queue_text(&item.user_message), + "hasAttachments": has_attachments(&item.user_message), + }) + }) + .collect(), + Err(error) => { + return ApiError::internal(format!("thread queue database error: {error}")) + .into_response(); + } + }; + Json(json!({ "items": items })).into_response() +} + +async fn queue_cancel( + state: &AppState, + scope: &RtAccountScope, + org: &str, + thread_id: &str, + workflow_id: &str, +) -> Response { + let key = ThreadKey::scoped(scope, org, thread_id); + let lifecycle_gate = thread_lifecycle_gate(scope, org, thread_id); + let _lifecycle_guard = lifecycle_gate.lock().await; + if thread_is_closing(scope, org, thread_id) { + return ApiError::conflict("thread is being deleted").into_response(); + } + let db = match shared_db(state) { + Ok(db) => db, + Err(error) => return error.into_response(), + }; + let queue = thread_queues().lock().unwrap().get(&key).cloned(); + match db.rt_cancel_turn_scoped(scope, org, thread_id, workflow_id) { + Ok(RtTurnCancelOutcome::QueuedDeleted(_)) => { + if let Some(queue) = queue { + queue.remove_mirror(workflow_id); + } + } + Ok(RtTurnCancelOutcome::ActiveCancelRequested(_)) => { + if let Some(cancellation) = queue.and_then(|queue| queue.cancellation_for(workflow_id)) + { + cancellation.request().await; + } + } + Ok(RtTurnCancelOutcome::NotFound) => return StatusCode::NOT_FOUND.into_response(), + Err(error) => { + return ApiError::internal(format!("thread queue database error: {error}")) + .into_response(); + } + } + (StatusCode::ACCEPTED, Json(json!({ "cancelled": true }))).into_response() +} + +async fn cancel(state: &AppState, scope: &RtAccountScope, org: &str, thread_id: &str) -> Response { + let key = ThreadKey::scoped(scope, org, thread_id); + let db = match shared_db(state) { + Ok(db) => db, + Err(error) => return error.into_response(), + }; + let active = match db.rt_list_turn_queue_scoped(scope, org, thread_id) { + Ok(items) => items.into_iter().find(|item| { + matches!( + item.state, + RtTurnQueueState::Running | RtTurnQueueState::CancelRequested + ) + }), + Err(error) => { + return ApiError::internal(format!("thread queue database error: {error}")) + .into_response(); + } + }; + if let Some(active) = active { + if let Err(error) = db.rt_cancel_turn_scoped(scope, org, thread_id, &active.workflow_id) { + return ApiError::internal(format!("thread queue database error: {error}")) + .into_response(); + } + let cancellation = thread_queues() + .lock() + .unwrap() + .get(&key) + .and_then(|queue| queue.cancellation_for(&active.workflow_id)); + if let Some(cancellation) = cancellation { + cancellation.request().await; + } + } + ( + StatusCode::ACCEPTED, + Json(json!({ "cancelled": true, "async": true })), + ) + .into_response() +} + +async fn stream(state: &AppState, scope: &RtAccountScope, org: &str, thread_id: &str) -> Response { + let key = ThreadKey::scoped(scope, org, thread_id); + // Eagerly create (not 204) a placeholder for a thread_id never seen + // before — see `DecopilotRun`'s "Idle placeholder" doc section for why: + // the real client treats 204 as "never reconnect", which is only + // correct for a genuinely-impossible state here, not an ordinary idle + // thread awaiting its first dispatch. + let run = match run_for_stream(state, &key).await { + Ok(run) => run, + Err(error) => { + return ApiError::internal(format!("could not open native chat replay: {error}")) + .into_response(); + } + }; + let mut subscription = match run.subscribe().await { + Ok(subscription) => subscription, + Err(error) => { + return ApiError::internal(format!("could not read native chat replay: {error}")) + .into_response(); + } + }; + let replay = subscription.take_replay(); + if replay.is_empty() && run.finished.load(Ordering::SeqCst) { + // Genuinely impossible in practice (a placeholder is never + // `finish()`-ed without a frame having been pushed first — see + // `send_message`), kept as a defensive fallback rather than an + // `unreachable!()`. + return StatusCode::NO_CONTENT.into_response(); + } + + let replay = (!replay.is_empty()).then_some(replay); + let cleanup_target = run.clone(); + let body_stream = stream::unfold( + (replay, subscription, Some(cleanup_target)), + |(mut replay, mut subscription, mut cleanup)| async move { + if let Some(bytes) = replay.take() { + return Some((Ok::<_, Infallible>(bytes), (replay, subscription, cleanup))); + } + match subscription.recv().await { + Ok(Some(bytes)) => { + return Some((Ok::<_, Infallible>(bytes), (replay, subscription, cleanup))); + } + Err(error) => { + // A lagged response must terminate visibly so the browser's + // SSE loop reconnects. Keep the identity-fenced registry + // slot and disk file: the next GET replays the complete + // transcript rather than silently skipping the gap. + tracing::warn!(%error, "native chat SSE subscriber lagged; reconnect required"); + cleanup.take(); + return None; + } + Ok(None) => {} + } + // Clean terminal consumption needs no reconnect grace period. + // `cleanup_run` identity-fences the registry slot but ALWAYS + // deletes this Arc's own unique file, even if a newer turn won. + if let Some(run) = cleanup.take() { + cleanup_run(&run).await; + } + None + }, + ); + + let mut response = Response::new(Body::from_stream(body_stream)); + for (name, value) in sse_headers() { + response.headers_mut().insert(name, value.parse().unwrap()); + } + response +} + +async fn run_for_stream(state: &AppState, key: &ThreadKey) -> Result, String> { + if let Some(existing) = registry().lock().unwrap().get(key).cloned() { + return Ok(existing); + } + + let candidate = DecopilotRun::open(state, key.clone()).await?; + let (selected, inserted) = { + let mut runs = registry().lock().unwrap(); + match runs.get(key) { + Some(existing) => (existing.clone(), false), + None => { + runs.insert(key.clone(), candidate.clone()); + (candidate.clone(), true) + } + } + }; + if inserted { + selected.schedule_idle_cleanup(); + } else if let Err(error) = candidate.spool.remove().await { + tracing::warn!(%error, "failed to remove raced native chat placeholder spool"); + } + Ok(selected) +} + +/// An old response body may finish after the queue has already installed the +/// next turn's run under the same thread id. Only the stream that still owns +/// the registry slot may clear it; blindly removing by key loses the new run's +/// replay and was the source of intermittent missing/duplicated turns. +fn remove_run_if_current(key: &ThreadKey, expected: &Arc) -> bool { + let mut runs = registry().lock().unwrap(); + let is_current = runs + .get(key) + .is_some_and(|current| Arc::ptr_eq(current, expected)); + if is_current { + runs.remove(key); + } + is_current +} + +async fn send_message( + state: &AppState, + scope: &RtAccountScope, + org: &str, + thread_id: &str, + body: &Bytes, +) -> Response { + let request_started = Instant::now(); + let input: Value = match serde_json::from_slice(body) { + Ok(v) => v, + Err(e) => return ApiError::bad_request(format!("invalid JSON body: {e}")).into_response(), + }; + let Some(messages) = input.get("messages").and_then(Value::as_array) else { + return ApiError::bad_request("messages must include exactly one user message") + .into_response(); + }; + let mut non_system = messages + .iter() + .filter(|message| message.get("role").and_then(Value::as_str) != Some("system")); + let Some(mut user_message) = non_system.next().cloned() else { + return ApiError::bad_request("messages must include exactly one user message") + .into_response(); + }; + if non_system.next().is_some() { + return ApiError::bad_request("messages must include exactly one non-system message") + .into_response(); + } + if user_message.get("role").and_then(Value::as_str) != Some("user") { + return ApiError::bad_request( + "native chat only supports a user message; assistant/tool continuations are not supported", + ) + .into_response(); + } + let user_message_id = user_message + .get("id") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("msg-{}-user", uuid::Uuid::new_v4())); + if is_native_assistant_message_id(&user_message_id) { + return ApiError::bad_request(format!( + "message id uses reserved namespace {NATIVE_ASSISTANT_MESSAGE_ID_PREFIX}" + )) + .into_response(); + } + // The generated id is part of the durable/idempotency contract, not just + // a database key. Mirror it back into the queued/SSE message so the + // optimistic, streamed, and persisted copies all dedupe by the same id. + if let Some(message) = user_message.as_object_mut() { + message.insert("id".to_string(), Value::String(user_message_id.clone())); + } + let lifecycle_gate = thread_lifecycle_gate(scope, org, thread_id); + let _lifecycle_guard = lifecycle_gate.lock().await; + if thread_is_closing(scope, org, thread_id) { + return ApiError::conflict("thread is being deleted").into_response(); + } + // Do not hold shutdown's admission guard while awaiting a per-thread + // lifecycle operation (delete can legitimately take seconds). Once the + // thread gate is ours, this section is synchronous through durable enqueue + // and worker launch, so shutdown's write barrier has a tight bound. + let Some(_shutdown_admission) = state.shutdown.admit_work().await else { + return ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "application is shutting down", + ) + .into_response(); + }; + let db = match shared_db(state) { + Ok(d) => d, + Err(e) => return e.into_response(), + }; + + let branch = input.get("branch").and_then(Value::as_str); + + // Check ownership before the idempotent create: an existing id from + // another account/upstream/org scope must be a tenant-scoped 404, not an + // idempotency-conflict 500. + match db.rt_get_thread_in_scope(scope, org, thread_id) { + Ok(Some(_)) => {} + Ok(None) => { + // Implicit create on first dispatch — same convention + // `routes/dispatch.rs` uses for the daemon-parity family. + if let Err(error) = db.rt_create_thread_scoped( + scope, + Some(thread_id), + org, + "", + None, + &implicit_virtual_mcp_id(org), + branch, + &scope.user_id, + ) { + // A request minted before DELETE may arrive only after the + // deletion committed. It must not implicitly resurrect the + // public id and enqueue work into a new incarnation. + if matches!(&error, DbError::RetiredThreadId { .. }) { + return ApiError::gone("thread was deleted").into_response(); + } + if matches!(db.rt_get_thread_in_scope(scope, org, thread_id), Ok(None)) { + return ApiError::not_found(format!("thread not found: {thread_id}")) + .into_response(); + } + return ApiError::internal(format!("thread database error: {error}")) + .into_response(); + } + } + Err(error) => { + return ApiError::internal(format!("thread database error: {error}")).into_response(); + } + } + + // Production returns the thread id as `taskId`; each turn's durable + // identity lives in `messageId` / `workflowId` instead. + let task_id = thread_id.to_string(); + let workflow_id = format!("thread-run:{thread_id}:{user_message_id}"); + let key = ThreadKey::scoped(scope, org, thread_id); + let enqueue = RtTurnEnqueueInput { + message_id: user_message_id, + workflow_id, + task_id: task_id.clone(), + normalized_input: normalized_execution_input(&input), + user_message, + enqueued_at: epoch_millis(), + }; + let durable = match db.rt_enqueue_turn_scoped(scope, org, thread_id, &enqueue) { + Ok(RtTurnEnqueueOutcome::Inserted(item) | RtTurnEnqueueOutcome::Existing(item)) => item, + Ok(RtTurnEnqueueOutcome::Completed) => { + tracing::info!( + target: "decocms.native.chat.latency", + organization_id = org, + thread_id, + request_to_accepted_ms = elapsed_millis(request_started), + outcome = "already_completed", + "native chat request accepted" + ); + return (StatusCode::ACCEPTED, Json(json!({ "taskId": task_id }))).into_response(); + } + Err(DbError::IdempotencyConflict { .. }) => { + return ( + StatusCode::CONFLICT, + Json(json!({"error": format!( + "message id {} already exists with different content", + enqueue.message_id + )})), + ) + .into_response(); + } + Err(DbError::ThreadDeletePending { .. }) => { + return ApiError::conflict("thread is being deleted").into_response(); + } + Err(DbError::InvalidQueueData(error)) + if error.contains(NATIVE_ASSISTANT_MESSAGE_ID_PREFIX) => + { + return ApiError::bad_request(error).into_response(); + } + Err(error) => { + return ApiError::internal(format!("thread queue database error: {error}")) + .into_response(); + } + }; + let task_id = durable.task_id.clone(); + let turn = QueuedTurn::from_durable(durable); + let fence = turn.fence.clone(); + + let (queue, outcome) = enqueue_thread_turn(&key, turn); + match outcome { + EnqueueOutcome::Duplicate => {} + EnqueueOutcome::Enqueued { first } => { + if first.is_some() { + spawn_durable_thread_worker(state.clone(), db, key, fence, queue); + } + } + } + + tracing::info!( + target: "decocms.native.chat.latency", + organization_id = org, + thread_id, + request_to_accepted_ms = elapsed_millis(request_started), + outcome = "enqueued", + "native chat request accepted" + ); + (StatusCode::ACCEPTED, Json(json!({ "taskId": task_id }))).into_response() +} + +fn spawn_durable_thread_worker( + state: AppState, + db: &'static ThreadsDb, + key: ThreadKey, + fence: RtThreadFence, + queue: Arc, +) { + tokio::spawn(async move { + let mut panic_guard = WorkerPanicGuard::new(key.clone(), queue.clone()); + drain_durable_thread_queue(&state, db, key, fence, queue).await; + panic_guard.disarm(); + }); +} + +/// Tokio catches a task panic at the join boundary, which otherwise skips every +/// normal drain epilogue and leaves `worker_running=true` forever. This sync +/// drop guard relinquishes that process-local latch on panic/abort. It does not +/// rerun the claimed durable turn: boot recovery remains the only safe policy +/// for a possibly-side-effecting generation interrupted at an unknown point. +struct WorkerPanicGuard { + key: ThreadKey, + queue: Arc, + armed: bool, +} + +impl WorkerPanicGuard { + fn new(key: ThreadKey, queue: Arc) -> Self { + Self { + key, + queue, + armed: true, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for WorkerPanicGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + tracing::error!( + org = %self.key.org, + thread_id = %self.key.thread_id, + "native chat queue worker aborted unexpectedly; durable head left for restart recovery" + ); + // A panic/abort can happen after the CLI was spawned but before the + // durable assistant/status transaction committed. Treating that as a + // clean worker stop makes graceful shutdown falsely publish Git state + // while detached process teardown may still be running. Latch the + // missing terminal fence exactly like the explicit persistence-error + // path; only next-boot recovery may classify the claimed head. + self.queue.stop_worker_after_durable_terminal_failure(); + remove_queue_if_idle(&self.key, &self.queue); + } +} + +/// Drains SQLite's tenant/thread FIFO. The lifecycle gate closes the only +/// dangerous transition: a sender cannot insert after a worker observes an +/// empty durable queue but before that worker relinquishes ownership. +async fn drain_durable_thread_queue( + state: &AppState, + db: &'static ThreadsDb, + key: ThreadKey, + fence: RtThreadFence, + queue: Arc, +) { + loop { + let lifecycle_gate = thread_lifecycle_gate_for_key(&key); + let lifecycle_guard = lifecycle_gate.lock().await; + // Queue admission and the durable claim are one critical section. + // Shutdown/delete set `stop_after_current` under this same mutex; once + // they win, a recovered worker with an empty RAM mirror cannot claim a + // SQLite tail. Once the worker wins, it installs the cancellation + // handle in the mirror before shutdown can inspect it. + let Some((claim_result, activated)) = queue.claim_durable_head(db, &fence) else { + drop(lifecycle_guard); + remove_queue_if_idle(&key, &queue); + return; + }; + let claimed = match claim_result { + Ok(Some(RtTurnClaimOutcome::Ready(item))) => item, + Ok(Some(RtTurnClaimOutcome::Malformed(orphan))) => { + let workflow_id = orphan.workflow_id.clone(); + match finalize_active_malformed_orphan(db, &orphan) { + Ok(()) => { + let stop = queue.complete_claimed(&workflow_id); + if stop { + remove_queue_if_idle(&key, &queue); + return; + } + drop(lifecycle_guard); + continue; + } + Err(error) => { + tracing::error!( + %error, + workflow_id, + org = key.org, + thread_id = key.thread_id, + "failed to quarantine malformed durable chat FIFO head" + ); + queue.stop_worker(); + return; + } + } + } + Ok(Some(RtTurnClaimOutcome::Completed { workflow_id })) => { + let stop = queue.complete_claimed(&workflow_id); + if stop { + remove_queue_if_idle(&key, &queue); + return; + } + drop(lifecycle_guard); + continue; + } + Ok(None) => { + queue.stop_worker(); + remove_queue_if_idle(&key, &queue); + return; + } + Err(error) => { + tracing::error!(%error, org = key.org, thread_id = key.thread_id, "failed to claim durable chat turn"); + queue.stop_worker(); + return; + } + }; + let turn = activated.expect("ready durable claim installs its cancellation mirror"); + let begin = db.rt_begin_claimed_turn(&claimed); + drop(lifecycle_guard); + + let persisted_terminal = match begin { + Ok(RtTurnBeginOutcome::Begun(_)) => { + emit_committed_thread_status( + db, + &claimed, + watch::ThreadStatus::InProgress, + Some(&claimed.message_id), + ); + execute_turn(state, db, turn.clone(), claimed.clone()).await + } + Ok(RtTurnBeginOutcome::CancelRequested) => { + finalize_cancelled_before_start(db, &claimed) + } + Ok(RtTurnBeginOutcome::Stale) => { + tracing::warn!( + workflow_id = claimed.workflow_id, + "durable chat turn lost its claim before begin" + ); + false + } + Err(error) => { + tracing::error!( + %error, + workflow_id = claimed.workflow_id, + "failed to atomically begin durable chat turn" + ); + false + } + }; + + let lifecycle_guard = lifecycle_gate.lock().await; + if !persisted_terminal { + // Never promote a tail after losing its durable completion fence. + // A subsequent boot will classify this claimed head as interrupted + // rather than rerunning a potentially side-effecting CLI. + queue.stop_worker_after_durable_terminal_failure(); + tracing::error!( + workflow_id = claimed.workflow_id, + "durable chat turn did not reach a terminal persistence fence; tail left queued for recovery" + ); + return; + } + // `rt_finalize_claimed_turn` already deleted this exact claimed row in + // the same transaction as assistant + terminal status. The RAM mirror + // is the only cleanup remaining; a second DB delete here would reopen + // the split completion window this worker is designed to eliminate. + let stop = queue.complete_claimed(&claimed.workflow_id); + if stop { + remove_queue_if_idle(&key, &queue); + return; + } + drop(lifecycle_guard); + } +} + +/// Runs exactly one callback at a time for this thread. The callback abstraction +/// gives unit tests a controllable latch without spawning a real paid CLI. +#[cfg(test)] +async fn drain_thread_queue(queue: Arc, first: QueuedTurn, mut execute: F) +where + F: FnMut(QueuedTurn) -> Fut, + Fut: Future, +{ + let mut current = first; + loop { + execute(current.clone()).await; + let Some(next) = queue.complete_and_next(¤t.workflow_id) else { + remove_queue_if_idle(¤t.key, &queue); + return; + }; + current = next; + } +} + +async fn run_for_turn(state: &AppState, key: &ThreadKey) -> Result, String> { + if let Some(placeholder) = registry() + .lock() + .unwrap() + .get(key) + .filter(|existing| existing.is_idle_placeholder()) + .cloned() + { + return Ok(placeholder); + } + + let candidate = DecopilotRun::open(state, key.clone()).await?; + let (selected, installed) = { + let mut runs = registry().lock().unwrap(); + if let Some(placeholder) = runs + .get(key) + .filter(|existing| existing.is_idle_placeholder()) + .cloned() + { + (placeholder, false) + } else { + runs.insert(key.clone(), candidate.clone()); + (candidate.clone(), true) + } + }; + if !installed { + if let Err(error) = candidate.spool.remove().await { + tracing::warn!(%error, "failed to remove raced native chat run spool"); + } + } + Ok(selected) +} + +const STREAM_FAILURE_RESPONSE: &str = + "The local response stream could not retain more output. Send your message again to retry."; + +fn emit_committed_thread_status( + db: &ThreadsDb, + claimed: &RtTurnQueueItem, + status: watch::ThreadStatus, + message_id: Option<&str>, +) { + emit_committed_thread_status_fenced( + db, + &claimed.fence, + &claimed.workflow_id, + status, + message_id, + ); +} + +fn emit_committed_thread_status_fenced( + db: &ThreadsDb, + fence: &RtThreadFence, + workflow_id: &str, + status: watch::ThreadStatus, + message_id: Option<&str>, +) { + match db.rt_thread_fenced(fence) { + Ok(Some(thread)) if thread.status == status.as_str() => { + watch::emit_thread_status(fence, status, &thread, message_id); + } + Ok(Some(thread)) => { + tracing::error!( + workflow_id, + expected_status = status.as_str(), + actual_status = thread.status, + "native thread status changed unexpectedly before watch publication" + ); + } + Ok(None) => { + tracing::warn!( + workflow_id, + "native thread disappeared before watch publication" + ); + } + Err(error) => { + tracing::error!( + %error, + workflow_id, + "failed to read committed native thread status for watch publication" + ); + } + } +} + +fn finalize_claimed_result( + db: &ThreadsDb, + claimed: &RtTurnQueueItem, + assistant_parts: &Value, + assistant_metadata: Option<&Value>, + status: RtTurnTerminalStatus, +) -> bool { + match db.rt_finalize_claimed_turn(claimed, assistant_parts, assistant_metadata, status) { + Ok(RtTurnTerminalOutcome::Completed(_)) => { + let watch_status = match status { + RtTurnTerminalStatus::Completed => watch::ThreadStatus::Completed, + RtTurnTerminalStatus::RequiresAction => watch::ThreadStatus::RequiresAction, + RtTurnTerminalStatus::Failed => watch::ThreadStatus::Failed, + }; + emit_committed_thread_status(db, claimed, watch_status, None); + true + } + Ok(RtTurnTerminalOutcome::Quarantined) => { + tracing::error!( + workflow_id = claimed.workflow_id, + "healthy durable chat turn was unexpectedly quarantined" + ); + false + } + Ok(RtTurnTerminalOutcome::Stale) => { + tracing::error!( + workflow_id = claimed.workflow_id, + "durable chat terminal transaction lost its claim fence" + ); + false + } + Err(error) => { + tracing::error!( + %error, + workflow_id = claimed.workflow_id, + "durable chat terminal transaction failed" + ); + false + } + } +} + +fn finalize_cancelled_before_start(db: &ThreadsDb, claimed: &RtTurnQueueItem) -> bool { + let metadata = json!({"cancelled": true}); + finalize_claimed_result( + db, + claimed, + &json!([]), + Some(&metadata), + RtTurnTerminalStatus::Failed, + ) +} + +/// Makes a spool failure durable even though the SSE channel itself can no +/// longer be trusted. Assistant + failed status + claimed queue deletion are +/// one SQLite transaction; no tail may be promoted if it loses that fence. +fn persist_stream_failure(db: &ThreadsDb, claimed: &RtTurnQueueItem, cause: &str) -> bool { + tracing::error!(error = cause, "native chat run spool failed"); + let metadata = json!({"failed": true, "errorCode": "local_stream_failure"}); + finalize_claimed_result( + db, + claimed, + &json!([{"type": "text", "text": STREAM_FAILURE_RESPONSE}]), + Some(&metadata), + RtTurnTerminalStatus::Failed, + ) +} + +async fn terminate_for_spool_failure( + run: &Arc, + db: &ThreadsDb, + claimed: &RtTurnQueueItem, + cancellation: &Arc, + cause: &str, +) -> bool { + cancellation.request().await; + let persisted = persist_stream_failure(db, claimed, cause); + run.finish().await; + persisted +} + +/// Flushes a terminal frame privately, atomically commits assistant + thread +/// status + queue deletion, then publishes the already-durable frame. A DB +/// failure truncates the hidden bytes, so reconnects can never observe a +/// terminal SSE frame for storage that did not commit. +async fn finalize_with_terminal_frame( + run: &Arc, + db: &ThreadsDb, + claimed: &RtTurnQueueItem, + assistant_parts: &Value, + assistant_metadata: Option<&Value>, + status: RtTurnTerminalStatus, + terminal_frame: Bytes, +) -> bool { + let staged = match run.stage_terminal(terminal_frame).await { + Ok(staged) => staged, + Err(error) => { + let persisted = persist_stream_failure(db, claimed, &error); + run.finish().await; + return persisted; + } + }; + if !finalize_claimed_result(db, claimed, assistant_parts, assistant_metadata, status) { + if let Err(error) = run.rollback_terminal(staged).await { + tracing::error!(%error, "failed to roll back unpublished native chat terminal frame"); + } + run.finish().await; + return false; + } + if let Err(error) = run.commit_terminal(staged).await { + // The terminal transaction is already durable. `commit_terminal` has + // no filesystem work and its opaque token came from this same spool, + // so this can only be an internal lifecycle invariant violation. + tracing::error!(%error, "SQLite terminal committed but staged SSE publication failed"); + } + run.finish().await; + true +} + +/// Terminal path after `rt_begin_claimed_turn` committed the user row but +/// before a harness could produce parts. +async fn finish_after_user( + run: &Arc, + db: &ThreadsDb, + claimed: &RtTurnQueueItem, + cancellation: &Arc, + error: Option, +) -> bool { + if let Some(error) = error { + if let Err(spool_error) = run + .push(frame(&json!({"type": "error", "errorText": error}))) + .await + { + return terminate_for_spool_failure(run, db, claimed, cancellation, &spool_error).await; + } + } + let metadata = json!({"finishReason": "error"}); + finalize_with_terminal_frame( + run, + db, + claimed, + &json!([]), + Some(&metadata), + RtTurnTerminalStatus::Failed, + frame(&json!({ + "type": "finish", + "finishReason": "error", + })), + ) + .await +} + +fn terminal_finish_reason(terminal_chunks: &[Value]) -> Option<&str> { + terminal_chunks.iter().rev().find_map(|chunk| { + (chunk.get("type").and_then(Value::as_str) == Some("finish")) + .then(|| chunk.get("finishReason").and_then(Value::as_str)) + .flatten() + }) +} + +fn response_url_pattern() -> &'static Regex { + static PATTERN: OnceLock = OnceLock::new(); + PATTERN.get_or_init(|| { + Regex::new(r"https?://[^\s)>\]]+").expect("response URL regex must compile") + }) +} + +/// Native equivalent of the production `resolveThreadStatus` policy. Keeping +/// this decision next to the persisted assistant parts makes SQLite and the +/// local `/watch` event agree on one authoritative terminal state. +fn resolve_terminal_status( + finish_reason: Option<&str>, + response_parts: &[Value], +) -> RtTurnTerminalStatus { + match finish_reason { + Some("stop") => { + let text = response_parts + .iter() + .filter(|part| part.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + if response_url_pattern().replace_all(&text, "").contains('?') { + RtTurnTerminalStatus::RequiresAction + } else { + RtTurnTerminalStatus::Completed + } + } + Some("tool-calls") => { + let requires_action = response_parts.iter().any(|part| { + let part_type = part.get("type").and_then(Value::as_str); + let state = part.get("state").and_then(Value::as_str); + (part_type == Some("tool-user_ask") && state == Some("input-available")) + || state == Some("approval-requested") + }); + if requires_action { + RtTurnTerminalStatus::RequiresAction + } else { + RtTurnTerminalStatus::Completed + } + } + _ => RtTurnTerminalStatus::Failed, + } +} + +fn assistant_finish_metadata(terminal_chunks: &[Value], status: RtTurnTerminalStatus) -> Value { + let finish_reason = terminal_finish_reason(terminal_chunks).unwrap_or( + if status == RtTurnTerminalStatus::Failed { + "error" + } else { + "stop" + }, + ); + json!({"finishReason": finish_reason}) +} + +async fn execute_turn( + state: &AppState, + db: &'static ThreadsDb, + turn: QueuedTurn, + claimed: RtTurnQueueItem, +) -> bool { + let queue_wait_ms = epoch_millis().saturating_sub(claimed.enqueued_at); + tracing::info!( + target: "decocms.native.chat.latency", + workflow_id = claimed.workflow_id, + organization_id = claimed.fence.organization_id, + thread_id = claimed.fence.thread_id, + queue_wait_ms, + "native chat turn started" + ); + // The durable begin already committed. A cancellation winning immediately + // after that boundary still needs the atomic failed terminal transaction. + if turn.cancellation.is_requested() { + return finalize_cancelled_before_start(db, &claimed); + } + + let fence = turn.fence.clone(); + let run = match run_for_turn(state, &turn.key).await { + Ok(run) => run, + Err(spool_error) => { + // No SSE transport exists, but the accepted durable turn still + // needs an explicit terminal result instead of becoming an + // eternally-running queue row. + turn.cancellation.request().await; + return persist_stream_failure(db, &claimed, &spool_error); + } + }; + if turn.cancellation.is_requested() { + return finish_after_user(&run, db, &claimed, &turn.cancellation, None).await; + } + if let Err(spool_error) = run + .push(frame(&json!({ + "type": "data-user-message", + "data": turn.user_message, + }))) + .await + { + return terminate_for_spool_failure(&run, db, &claimed, &turn.cancellation, &spool_error) + .await; + } + if let Err(spool_error) = run.push(frame(&assistant_start_chunk(&claimed))).await { + return terminate_for_spool_failure(&run, db, &claimed, &turn.cancellation, &spool_error) + .await; + } + if let Err(spool_error) = publish_run_status(&run, NativeRunStatusStage::StartingRun).await { + return terminate_for_spool_failure(&run, db, &claimed, &turn.cancellation, &spool_error) + .await; + } + + if turn.cancellation.is_requested() { + return finish_after_user(&run, db, &claimed, &turn.cancellation, None).await; + } + + if let Err(spool_error) = publish_run_status(&run, NativeRunStatusStage::GatheringContext).await + { + return terminate_for_spool_failure(&run, db, &claimed, &turn.cancellation, &spool_error) + .await; + } + let harness_resolution_started = Instant::now(); + let explicit_harness_id = turn.input.get("harnessId").and_then(Value::as_str); + let pinned_harness_id = match db.rt_harness_id_fenced(&fence) { + Ok(Some(harness_id)) => harness_id, + Ok(None) => { + tracing::warn!( + workflow_id = claimed.workflow_id, + "native chat turn lost its thread generation before harness resolution" + ); + return false; + } + Err(error) => { + return finish_after_user( + &run, + db, + &claimed, + &turn.cancellation, + Some(format!( + "could not read the thread's local harness: {error}" + )), + ) + .await; + } + }; + let selection = match harness_selection(pinned_harness_id.as_deref(), explicit_harness_id) { + Ok(selection) => selection, + Err(error) => { + return finish_after_user(&run, db, &claimed, &turn.cancellation, Some(error)).await; + } + }; + let proposed_harness_id = match selection { + HarnessSelection::Pinned(harness) | HarnessSelection::Requested(harness) => Some(harness), + HarnessSelection::Detect => { + let cache = harness::detect::ensure_detected().await; + if cache.get(harness::HarnessId::ClaudeCode) { + Some(harness::HarnessId::ClaudeCode) + } else if cache.get(harness::HarnessId::Codex) { + Some(harness::HarnessId::Codex) + } else { + None + } + } + }; + let Some(proposed_harness_id) = proposed_harness_id else { + return finish_after_user( + &run, + db, + &claimed, + &turn.cancellation, + Some("no local Claude Code or Codex CLI was detected on this machine".to_string()), + ) + .await; + }; + + let branch = turn.input.get("branch").and_then(Value::as_str); + let sandbox_provider_kind = turn + .input + .get("sandboxProviderKind") + .and_then(Value::as_str); + match db.rt_pin_harness_if_unset_fenced( + &fence, + proposed_harness_id.wire_id(), + sandbox_provider_kind, + branch, + ) { + Ok(true) => {} + Ok(false) => { + tracing::warn!( + workflow_id = claimed.workflow_id, + "native chat turn lost its thread generation while pinning its harness" + ); + return false; + } + Err(error) => { + return finish_after_user( + &run, + db, + &claimed, + &turn.cancellation, + Some(format!("could not pin the thread's local harness: {error}")), + ) + .await; + } + } + let effective_harness_id = match db.rt_harness_id_fenced(&fence) { + Ok(Some(Some(harness_id))) => harness_id, + Ok(Some(None)) => { + return finish_after_user( + &run, + db, + &claimed, + &turn.cancellation, + Some("the thread's local harness could not be pinned".to_string()), + ) + .await; + } + Ok(None) => { + tracing::warn!( + workflow_id = claimed.workflow_id, + "native chat turn lost its thread generation after harness pinning" + ); + return false; + } + Err(error) => { + return finish_after_user( + &run, + db, + &claimed, + &turn.cancellation, + Some(format!( + "could not verify the thread's local harness: {error}" + )), + ) + .await; + } + }; + let Some(harness_id) = harness::HarnessId::from_wire_id(&effective_harness_id) else { + return finish_after_user( + &run, + db, + &claimed, + &turn.cancellation, + Some(format!( + "thread is pinned to unsupported local harness {effective_harness_id:?}" + )), + ) + .await; + }; + tracing::info!( + target: "decocms.native.chat.latency", + workflow_id = claimed.workflow_id, + organization_id = claimed.fence.organization_id, + thread_id = claimed.fence.thread_id, + harness_id = harness_id.wire_id(), + selection = ?selection, + harness_resolution_ms = elapsed_millis(harness_resolution_started), + "native chat harness resolved" + ); + if pinned_harness_id + .as_deref() + .is_some_and(|pinned| explicit_harness_id.is_some_and(|requested| requested != pinned)) + { + tracing::info!( + workflow_id = claimed.workflow_id, + pinned_harness_id = effective_harness_id, + requested_harness_id = explicit_harness_id, + "ignored a harness override for an already-locked native thread" + ); + } + let resume_session_id = match db.rt_last_assistant_session_fenced(&fence, harness_id.wire_id()) + { + Ok(Some((_, session_id))) => Some(session_id), + Ok(None) => None, + Err(error) => { + return finish_after_user( + &run, + db, + &claimed, + &turn.cancellation, + Some(format!( + "could not read the thread's local harness session: {error}" + )), + ) + .await; + } + }; + if let Err(spool_error) = publish_run_status(&run, NativeRunStatusStage::PreparingTools).await { + return terminate_for_spool_failure(&run, db, &claimed, &turn.cancellation, &spool_error) + .await; + } + let sandbox_ensure_started = Instant::now(); + let cwd = resolve_send_message_cwd(state, &turn.input, &claimed.fence, db).await; + tracing::info!( + target: "decocms.native.chat.latency", + workflow_id = claimed.workflow_id, + organization_id = claimed.fence.organization_id, + thread_id = claimed.fence.thread_id, + sandbox_ensure_ms = elapsed_millis(sandbox_ensure_started), + "native chat sandbox resolved" + ); + if turn.cancellation.is_requested() { + return finish_after_user(&run, db, &claimed, &turn.cancellation, None).await; + } + // Tell the agent where the organization filesystem is — nothing else + // does, since the desktop CLIs get no system prompt of their own. Gated on + // the view directory actually EXISTING: naming a path that is not there is + // the failure this whole plan exists to avoid, and a sandbox whose org + // could not be resolved or mounted simply has no `org` sibling. + // One read serves both the org-filesystem prompt (which names the user's + // memory file) and the agent MCP (which is keyed by the agent, not the + // thread). + let thread_row = db.rt_get_thread_for_fence(&claimed.fence).ok().flatten(); + // Where the org filesystem sits relative to this run: a git-backed agent + // has it BESIDE its worktree, while a gitless agent is already standing + // in it. + let org_dir = if crate::sandbox::org_view::org_mount_root( + &state.app_root, + &claimed.fence.organization_id, + ) + .is_some_and(|root| root == cwd) + { + Some(cwd.clone()) + } else { + cwd.parent().map(|sandbox| sandbox.join("org")) + }; + let append_system_prompt = match org_dir { + Some(org_dir) if tokio::fs::metadata(&org_dir).await.is_ok() => { + Some(crate::sandbox::org_prompt::build( + &org_dir, + &claimed.fence.thread_id, + thread_row.as_ref().map(|thread| thread.created_by.as_str()), + )) + } + _ => None, + }; + // The agent's own MCP, so the CLI can call the agent's tools. Points at + // THIS process — `/mcp/*` proxies upstream with the Keychain-backed token + // — so nothing has to be minted per run. The embedded guard wants the + // control cookie AND the exact Origin, so both travel with it. + let mcp = thread_row + .as_ref() + .zip(crate::sandbox::org_mount::local_credentials()) + .map(|(thread, creds)| harness::run::McpEndpoint { + // ORG-SCOPED, not the cluster's `/mcp/virtual-mcp/`. + // That legacy shape works upstream only because the per-run key + // minted there carries the organization; this request + // authenticates with the session cookie, which does not, so the + // server answers `400 Organization context is required`. The + // `/api/:org/...` form is also the one new code is required to + // use. + url: format!( + "{}/api/{}/mcp/{}", + creds.base_url, claimed.fence.organization_id, thread.virtual_mcp_id + ), + cookie: creds.cookie.clone(), + origin: creds.origin.clone(), + }); + match mcp.as_ref() { + Some(endpoint) => tracing::info!( + target: "decocms.native.mcp", + url = %endpoint.url, + harness = ?harness_id, + "agent MCP wired into the harness" + ), + None => tracing::warn!( + target: "decocms.native.mcp", + has_thread = thread_row.is_some(), + has_credentials = crate::sandbox::org_mount::local_credentials().is_some(), + "agent MCP NOT wired — the CLI will have no agent tools" + ), + } + let spec = harness::run::RunSpec { + harness: harness_id, + cwd: Some(cwd), + append_system_prompt, + mcp, + prompt: harness::run::extract_user_text(&turn.user_message), + resume_session_id, + model_id: model_id_for(harness_id, turn.input.get("tier").and_then(Value::as_str)), + tool_approval: harness::run::ToolApproval::from_wire( + turn.input + .get("toolApprovalLevel") + .and_then(Value::as_str) + .unwrap_or("auto"), + ), + plan_mode: turn.input.get("mode").and_then(Value::as_str) == Some("plan"), + }; + let argv = match harness::run::build_argv(&spec) { + Ok(argv) => argv, + Err(error) => { + return finish_after_user( + &run, + db, + &claimed, + &turn.cancellation, + Some(format!("could not start {}: {error}", harness_id.wire_id())), + ) + .await; + } + }; + if let Err(spool_error) = + publish_run_status(&run, NativeRunStatusStage::StartingAssistant).await + { + return terminate_for_spool_failure(&run, db, &claimed, &turn.cancellation, &spool_error) + .await; + } + run_harness_and_stream( + run, + db, + HarnessRunRequest { + claimed, + harness_id, + spec, + argv, + child_lifetime_lock_path: state.tasks.child_lifetime_lock_path().to_path_buf(), + cancellation: turn.cancellation, + }, + ) + .await +} + +/// `tier` (`SimpleModeTier`: `"fast"|"smart"|"thinking"|"image"| +/// "web_search"|"deep_research"`) → the composite model id +/// `harness::tiers::tiers_for` resolves for `harness_id`. Only the first +/// three map onto this crate's fast/smart/thinking tier table (map §3.4 — +/// tier LABELS are client-side, no network call, so exact fidelity here +/// only matters for which CLI model flag gets used); the other three +/// (image/web_search/deep_research — cloud-only modes with no local-CLI +/// equivalent in v1) fall back to `"smart"` rather than erroring, since an +/// unresolvable id should reach the CLI's own `--model` validation instead +/// of crashing this resolver (same philosophy as `tiers.rs`'s own +/// passthrough-on-unknown doc comment). +fn model_id_for(harness_id: harness::HarnessId, tier: Option<&str>) -> String { + let idx = match tier { + Some("fast") => 0, + Some("thinking") => 2, + _ => 1, + }; + harness::tiers::tiers_for(harness_id)[idx].id.to_string() +} + +struct HarnessRunRequest { + claimed: RtTurnQueueItem, + harness_id: harness::HarnessId, + spec: harness::run::RunSpec, + argv: Vec, + child_lifetime_lock_path: std::path::PathBuf, + cancellation: Arc, +} + +async fn run_harness_and_stream( + run: Arc, + db: &'static ThreadsDb, + request: HarnessRunRequest, +) -> bool { + let HarnessRunRequest { + claimed, + harness_id, + spec, + argv, + child_lifetime_lock_path, + cancellation, + } = request; + let spawn_started = Instant::now(); + let mut handle = + harness::run::start_with_child_lifetime_lock(argv, &spec, child_lifetime_lock_path).await; + if cancellation.install(handle.cancel_handle()) { + // Cancellation may have won during `start`; installing the detached + // handle closes that race before we read a single event. + handle.cancel().await; + } + + let mut accumulator = harness::parts::PartsAccumulator::new(); + let mut error_message: Option = None; + let mut spool_failure: Option = None; + // A finish chunk is the UI's permission to reconcile/refetch. Publishing + // it before the assistant row commits creates exactly the transient + // duplicate/reordering bug this queue fixes, so every terminal chunk waits + // below until persistence + status are complete. + let mut terminal_chunks = Vec::new(); + let mut observed_meaningful_event = false; + while let Some(event) = handle.recv().await { + match event { + harness::run::RunEvent::SessionId { session_id } => { + match db.rt_checkpoint_claimed_turn_session( + &claimed, + harness_id.wire_id(), + &session_id, + ) { + Ok(true) => {} + Ok(false) => { + error_message = Some( + "the chat turn lost its durable session checkpoint fence".to_string(), + ); + } + Err(error) => { + error_message = Some(format!( + "could not checkpoint the local harness session: {error}" + )); + } + } + if let Some(message) = &error_message { + if let Err(error) = run + .push(frame(&json!({"type": "error", "errorText": message}))) + .await + { + spool_failure = Some(error); + } + cancellation.request().await; + while handle.recv().await.is_some() {} + break; + } + } + harness::run::RunEvent::Chunk(chunk) => { + if !observed_meaningful_event && is_meaningful_harness_chunk(&chunk) { + observed_meaningful_event = true; + tracing::info!( + target: "decocms.native.chat.latency", + workflow_id = claimed.workflow_id, + organization_id = claimed.fence.organization_id, + thread_id = claimed.fence.thread_id, + harness_id = harness_id.wire_id(), + event_type = chunk + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + spawn_to_first_stream_event_ms = elapsed_millis(spawn_started), + "native chat received its first meaningful harness event" + ); + } + // The server already opened this assistant turn before any + // fallible pre-harness work. A second harness-owned `start` + // would create another assistant substream and could replace + // the stable id reserved by SQLite. + if chunk.get("type").and_then(Value::as_str) == Some("start") { + continue; + } + accumulator.feed(&chunk); + if chunk.get("type").and_then(Value::as_str) == Some("finish") { + terminal_chunks.push(chunk); + } else if let Err(error) = run.push(frame(&chunk)).await { + spool_failure = Some(error); + // Retention is part of the protocol contract. Continuing a + // paid, side-effecting harness after its output can no + // longer be replayed would silently lose the generation. + cancellation.request().await; + break; + } + } + harness::run::RunEvent::FatalError { message } => { + if !observed_meaningful_event { + observed_meaningful_event = true; + tracing::info!( + target: "decocms.native.chat.latency", + workflow_id = claimed.workflow_id, + organization_id = claimed.fence.organization_id, + thread_id = claimed.fence.thread_id, + harness_id = harness_id.wire_id(), + event_type = "fatal-error", + spawn_to_first_stream_event_ms = elapsed_millis(spawn_started), + "native chat received its first meaningful harness event" + ); + } + error_message = Some(message.clone()); + if let Err(error) = run + .push(frame(&json!({"type": "error", "errorText": message}))) + .await + { + spool_failure = Some(error); + } + // A fatal wire frame is terminal from the UI's perspective, but + // a buggy CLI can emit it and keep running. Reap that process + // group before committing/promoting the queued successor: these + // harnesses can perform non-idempotent filesystem actions, so a + // one-second overlap is still data loss territory. + cancellation.request().await; + while handle.recv().await.is_some() {} + break; + } + } + } + if !observed_meaningful_event { + tracing::warn!( + target: "decocms.native.chat.latency", + workflow_id = claimed.workflow_id, + organization_id = claimed.fence.organization_id, + thread_id = claimed.fence.thread_id, + harness_id = harness_id.wire_id(), + harness_lifetime_ms = elapsed_millis(spawn_started), + "native chat harness exited without a meaningful stream event" + ); + } + if let Some(error) = spool_failure.as_deref() { + // Reap the cancelled process before returning the thread's dispatch + // slot. Otherwise a spool failure could overlap this side-effecting + // generation with the queued successor. + while handle.recv().await.is_some() {} + return terminate_for_spool_failure(&run, db, &claimed, &cancellation, error).await; + } + let cancelled = cancellation.is_requested(); + let session_id = handle.session_id().await.and_then(|session_id| { + let session_id = session_id.trim(); + (!session_id.is_empty()).then(|| session_id.to_string()) + }); + if error_message.is_none() && !cancelled && session_id.is_none() { + let message = format!( + "{} completed without reporting a resumable session id", + harness_id.wire_id() + ); + if let Err(error) = run + .push(frame(&json!({"type": "error", "errorText": message}))) + .await + { + return terminate_for_spool_failure(&run, db, &claimed, &cancellation, &error).await; + } + error_message = Some(message); + } + if error_message.is_some() || cancelled { + // An error/cancellation can race a nominal finish from the harness. + // Normalize the reason before resolving status so the SSE terminal, + // assistant metadata, SQLite row, and watch event cannot disagree. + terminal_chunks.clear(); + terminal_chunks.push(json!({"type": "finish", "finishReason": "error"})); + } + + let assistant_parts = + accumulator.finish(session_id.as_deref().map(|sid| (harness_id.wire_id(), sid))); + + if terminal_chunks.is_empty() { + terminal_chunks.push(json!({"type": "finish", "finishReason": "stop"})); + } + let status = + resolve_terminal_status(terminal_finish_reason(&terminal_chunks), &assistant_parts); + let assistant_metadata = assistant_finish_metadata(&terminal_chunks, status); + let mut terminal_frame = Vec::new(); + for chunk in terminal_chunks { + terminal_frame.extend_from_slice(&frame(&chunk)); + } + finalize_with_terminal_frame( + &run, + db, + &claimed, + &Value::Array(assistant_parts), + Some(&assistant_metadata), + status, + Bytes::from(terminal_frame), + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::routes::intercept::test_state; + use futures::StreamExt; + use tokio::sync::{mpsc, Semaphore}; + + fn test_scope() -> RtAccountScope { + RtAccountScope::new("test.invalid", "local-desktop-user").unwrap() + } + + fn queued_turn(org: &str, thread_id: &str, message_id: &str, at: u64) -> QueuedTurn { + let user_message = json!({ + "id": message_id, + "role": "user", + "parts": [{"type": "text", "text": format!("text-{message_id}")}], + }); + QueuedTurn { + key: ThreadKey::new(org, thread_id), + fence: RtThreadFence { + account_scope: test_scope().storage_key(), + organization_id: org.to_string(), + thread_id: thread_id.to_string(), + generation: "test-generation".to_string(), + }, + workflow_id: format!("thread-run:{thread_id}:{message_id}"), + message_id: message_id.to_string(), + input: json!({}), + text: queue_text(&user_message), + has_attachments: false, + user_message, + enqueued_at: at, + cancellation: TurnCancellation::new(), + } + } + + #[test] + fn model_id_for_maps_known_tiers() { + let h = harness::HarnessId::ClaudeCode; + assert_eq!(model_id_for(h, Some("fast")), "claude-code:haiku"); + assert_eq!(model_id_for(h, Some("smart")), "claude-code:sonnet"); + assert_eq!(model_id_for(h, Some("thinking")), "claude-code:opus-1m"); + // Cloud-only modes fall back to "smart" rather than erroring. + assert_eq!(model_id_for(h, Some("image")), "claude-code:sonnet"); + assert_eq!(model_id_for(h, None), "claude-code:sonnet"); + } + + #[test] + fn durable_harness_pin_wins_over_a_later_request_override() { + assert_eq!( + harness_selection(Some("codex"), Some("claude-code")).unwrap(), + HarnessSelection::Pinned(harness::HarnessId::Codex) + ); + assert_eq!( + harness_selection(None, Some("claude-code")).unwrap(), + HarnessSelection::Requested(harness::HarnessId::ClaudeCode) + ); + assert_eq!( + harness_selection(None, Some("decopilot")).unwrap(), + HarnessSelection::Detect + ); + assert!(harness_selection(Some("decopilot"), Some("codex")).is_err()); + } + + #[test] + fn native_run_status_chunks_match_the_hosted_control_shape() { + for (stage, wire_id) in [ + (NativeRunStatusStage::StartingRun, "starting-run"), + (NativeRunStatusStage::GatheringContext, "gathering-context"), + (NativeRunStatusStage::PreparingTools, "preparing-tools"), + ( + NativeRunStatusStage::StartingAssistant, + "starting-assistant", + ), + ] { + assert_eq!( + run_status_chunk(stage), + json!({ + "type": "data-run-status", + "id": "run-status", + "data": {"stage": wire_id}, + }) + ); + } + } + + #[tokio::test] + async fn pre_harness_failure_opens_the_reserved_assistant_before_status_and_error() { + let dir = tempfile::tempdir().unwrap(); + let db = ThreadsDb::open_in_memory().unwrap(); + let thread_id = format!("pre-harness-failure-{}", uuid::Uuid::new_v4()); + let message_id = "pre-harness-user"; + let user_message = json!({ + "id": message_id, + "role": "user", + "parts": [{"type": "text", "text": "hello"}], + }); + db.rt_create_thread(Some(&thread_id), "acme", "", None, "vm", None, "user") + .unwrap(); + let fence = db + .rt_thread_fence_in_org("acme", &thread_id) + .unwrap() + .unwrap(); + db.rt_enqueue_turn_in_org( + "acme", + &thread_id, + &RtTurnEnqueueInput { + message_id: message_id.to_string(), + workflow_id: "pre-harness-workflow".to_string(), + task_id: "pre-harness-task".to_string(), + normalized_input: json!({}), + user_message: user_message.clone(), + enqueued_at: 1, + }, + ) + .unwrap(); + let claimed = match db.rt_claim_turn_queue_head_fenced(&fence).unwrap().unwrap() { + RtTurnClaimOutcome::Ready(claimed) => claimed, + RtTurnClaimOutcome::Malformed(error) => panic!("unexpected malformed claim: {error:?}"), + RtTurnClaimOutcome::Completed { workflow_id } => { + panic!("unexpected completed claim: {workflow_id}") + } + }; + assert!(matches!( + db.rt_begin_claimed_turn(&claimed).unwrap(), + RtTurnBeginOutcome::Begun(_) + )); + let run = Arc::new(DecopilotRun { + key: ThreadKey::new("acme", &thread_id), + spool: RunSpool::open(dir.path().join("early-failure.sse")) + .await + .unwrap(), + has_frames: AtomicBool::new(false), + finished: AtomicBool::new(false), + finish_lock: AsyncMutex::new(()), + }); + let cancellation = TurnCancellation::new(); + + run.push(frame(&json!({ + "type": "data-user-message", + "data": user_message, + }))) + .await + .unwrap(); + run.push(frame(&assistant_start_chunk(&claimed))) + .await + .unwrap(); + publish_run_status(&run, NativeRunStatusStage::StartingRun) + .await + .unwrap(); + assert!( + finish_after_user( + &run, + &db, + &claimed, + &cancellation, + Some("synthetic pre-harness failure".to_string()), + ) + .await + ); + + let mut subscription = run.subscribe().await.unwrap(); + let replay = String::from_utf8(subscription.take_replay().to_vec()).unwrap(); + let chunks = replay + .split("\n\n") + .filter_map(|wire_frame| { + wire_frame + .lines() + .find_map(|line| line.strip_prefix("data: ")) + .map(|data| serde_json::from_str::(data).unwrap()) + }) + .collect::>(); + assert_eq!( + chunks + .iter() + .filter_map(|chunk| chunk.get("type").and_then(Value::as_str)) + .collect::>(), + vec![ + "data-user-message", + "start", + "data-run-status", + "error", + "finish", + ] + ); + assert_eq!( + chunks[1]["messageId"], + reserved_assistant_message_id(&claimed) + ); + assert_eq!(chunks[3]["errorText"], "synthetic pre-harness failure"); + assert_eq!(chunks[4]["finishReason"], "error"); + cleanup_run(&run).await; + } + + #[tokio::test] + async fn run_status_spool_failure_is_returned_to_the_dispatcher() { + let dir = tempfile::tempdir().unwrap(); + let run = Arc::new(DecopilotRun { + key: ThreadKey::new("acme", "status-spool-failure"), + spool: RunSpool::open_with_cap(dir.path().join("status-failure.sse"), 1) + .await + .unwrap(), + has_frames: AtomicBool::new(false), + finished: AtomicBool::new(false), + finish_lock: AsyncMutex::new(()), + }); + run.push(Bytes::from_static(b"x")).await.unwrap(); + + let error = publish_run_status(&run, NativeRunStatusStage::StartingAssistant) + .await + .expect_err("status retention failure must abort before spawning the harness"); + assert!(error.contains("capacity exceeded"), "{error}"); + cleanup_run(&run).await; + } + + #[test] + fn meaningful_stream_timing_skips_only_protocol_bookkeeping() { + for chunk_type in ["start", "start-step", "finish-step"] { + assert!(!is_meaningful_harness_chunk(&json!({"type": chunk_type}))); + } + for chunk_type in ["text-delta", "reasoning-delta", "finish"] { + assert!(is_meaningful_harness_chunk(&json!({"type": chunk_type}))); + } + } + + #[test] + fn assistant_metadata_preserves_the_actual_final_finish_reason() { + assert_eq!( + assistant_finish_metadata( + &[ + json!({"type": "finish", "finishReason": "stop"}), + json!({"type": "finish", "finishReason": "length"}), + ], + RtTurnTerminalStatus::Completed, + ), + json!({"finishReason": "length"}) + ); + assert_eq!( + assistant_finish_metadata(&[], RtTurnTerminalStatus::Failed), + json!({"finishReason": "error"}) + ); + assert_eq!( + assistant_finish_metadata(&[], RtTurnTerminalStatus::RequiresAction), + json!({"finishReason": "stop"}) + ); + } + + #[test] + fn terminal_status_matches_production_finish_reason_and_parts_policy() { + assert_eq!( + resolve_terminal_status(Some("stop"), &[]), + RtTurnTerminalStatus::Completed + ); + assert_eq!( + resolve_terminal_status( + Some("stop"), + &[json!({"type": "text", "text": "Do you want another change?"})], + ), + RtTurnTerminalStatus::RequiresAction + ); + assert_eq!( + resolve_terminal_status( + Some("stop"), + &[json!({ + "type": "text", + "text": "See https://example.com/page?q=1 for details." + })], + ), + RtTurnTerminalStatus::Completed, + "a URL query string is not a question" + ); + assert_eq!( + resolve_terminal_status( + Some("stop"), + &[json!({ + "type": "text", + "text": "See https://example.com/page?q=1 — does that help?" + })], + ), + RtTurnTerminalStatus::RequiresAction + ); + assert_eq!( + resolve_terminal_status( + Some("tool-calls"), + &[json!({ + "type": "tool-user_ask", + "state": "input-available" + })], + ), + RtTurnTerminalStatus::RequiresAction + ); + assert_eq!( + resolve_terminal_status( + Some("tool-calls"), + &[json!({"type": "tool-Bash", "state": "approval-requested"})], + ), + RtTurnTerminalStatus::RequiresAction + ); + assert_eq!( + resolve_terminal_status( + Some("tool-calls"), + &[json!({"type": "tool-user_ask", "state": "output-available"})], + ), + RtTurnTerminalStatus::Completed + ); + for reason in [Some("length"), Some("error"), None] { + assert_eq!( + resolve_terminal_status(reason, &[]), + RtTurnTerminalStatus::Failed + ); + } + } + + // -- git_sandbox_config_from_input --------------------------------------- + + #[test] + fn no_sandbox_block_is_not_git_backed() { + let input = json!({"messages": []}); + assert!(git_sandbox_config_from_input(&input).is_none()); + } + + #[test] + fn sandbox_block_without_clone_url_is_not_git_backed() { + let input = json!({"sandbox": {"virtualMcpId": "vm1", "repo": {}}}); + assert!(git_sandbox_config_from_input(&input).is_none()); + } + + #[test] + fn full_sandbox_block_parses_repo_and_workload() { + let input = json!({ + "sandbox": { + "virtualMcpId": "vm1", + "repo": {"cloneUrl": "https://github.com/acme/widgets.git", "branch": "feature-x"}, + "workload": {"runtime": "node", "packageManager": "npm", "packageManagerPath": "apps/web"}, + }, + }); + let cfg = git_sandbox_config_from_input(&input).expect("git-backed"); + assert_eq!(cfg.virtual_mcp_id, "vm1"); + assert_eq!(cfg.clone_url, "https://github.com/acme/widgets.git"); + assert_eq!(cfg.branch.as_deref(), Some("feature-x")); + assert_eq!(cfg.runtime.as_deref(), Some("node")); + assert_eq!(cfg.package_manager.as_deref(), Some("npm")); + assert_eq!(cfg.package_manager_path.as_deref(), Some("apps/web")); + } + + #[test] + fn sandbox_block_without_workload_omits_it() { + let input = json!({ + "sandbox": { + "virtualMcpId": "vm1", + "repo": {"cloneUrl": "https://github.com/acme/widgets.git"}, + }, + }); + let cfg = git_sandbox_config_from_input(&input).expect("git-backed"); + assert!(cfg.branch.is_none()); + assert!(cfg.runtime.is_none()); + assert!(cfg.package_manager.is_none()); + } + + #[test] + fn queue_is_fifo_dedupes_message_ids_and_matches_production_list_shape() { + let queue = ThreadQueue::new(); + let first = queued_turn("acme", "fifo-thread", "m1", 10); + let second = queued_turn("acme", "fifo-thread", "m2", 20); + + let EnqueueOutcome::Enqueued { first: start } = queue.enqueue(first.clone()) else { + panic!("first enqueue must be new"); + }; + assert_eq!(start.unwrap().message_id, "m1"); + let EnqueueOutcome::Enqueued { first: start } = queue.enqueue(second) else { + panic!("second enqueue must be new"); + }; + assert!(start.is_none(), "only the head may receive a worker token"); + let EnqueueOutcome::Duplicate = queue.enqueue(first) else { + panic!("same active/queued message id must dedupe"); + }; + + assert_eq!( + queue.list(), + vec![ + json!({ + "workflowId": "thread-run:fifo-thread:m1", + "messageId": "m1", + "status": "running", + "enqueuedAt": 10, + "source": "user-message", + "text": "text-m1", + "hasAttachments": false, + }), + json!({ + "workflowId": "thread-run:fifo-thread:m2", + "messageId": "m2", + "status": "queued", + "enqueuedAt": 20, + "source": "user-message", + "text": "text-m2", + "hasAttachments": false, + }), + ] + ); + } + + #[tokio::test] + async fn drain_never_executes_second_turn_until_first_has_fully_returned() { + let queue = ThreadQueue::new(); + let EnqueueOutcome::Enqueued { first: Some(first) } = + queue.enqueue(queued_turn("acme", "serialized", "m1", 1)) + else { + panic!("first turn must own worker token"); + }; + assert!(matches!( + queue.enqueue(queued_turn("acme", "serialized", "m2", 2)), + EnqueueOutcome::Enqueued { first: None } + )); + + let (started_tx, mut started_rx) = mpsc::unbounded_channel(); + let release_first = Arc::new(Semaphore::new(0)); + let release_for_worker = release_first.clone(); + let queue_for_worker = queue.clone(); + let worker = tokio::spawn(async move { + drain_thread_queue(queue_for_worker, *first, move |turn| { + let started_tx = started_tx.clone(); + let release = release_for_worker.clone(); + async move { + started_tx.send(turn.message_id.clone()).unwrap(); + if turn.message_id == "m1" { + release.acquire().await.unwrap().forget(); + } + } + }) + .await; + }); + + assert_eq!(started_rx.recv().await.as_deref(), Some("m1")); + assert!( + started_rx.try_recv().is_err(), + "queued turn started while head callback was still blocked" + ); + assert_eq!(queue.list()[1]["status"], "queued"); + release_first.add_permits(1); + assert_eq!(started_rx.recv().await.as_deref(), Some("m2")); + worker.await.unwrap(); + assert!(queue.is_idle()); + } + + #[tokio::test] + async fn queue_cancel_removes_queued_and_requests_active_cancellation() { + let queue = ThreadQueue::new(); + let first = queued_turn("acme", "cancel-thread", "m1", 1); + let active_cancel = first.cancellation.clone(); + queue.enqueue(first); + queue.enqueue(queued_turn("acme", "cancel-thread", "m2", 2)); + + assert!(matches!( + queue.cancel_workflow("thread-run:cancel-thread:m2"), + QueueCancelOutcome::Queued + )); + assert_eq!(queue.list().len(), 1); + let QueueCancelOutcome::Active(cancellation) = + queue.cancel_workflow("thread-run:cancel-thread:m1") + else { + panic!("head cancellation must target active controller"); + }; + cancellation.request().await; + assert!(active_cancel.is_requested()); + assert!(matches!( + queue.cancel_workflow("thread-run:cancel-thread:missing"), + QueueCancelOutcome::NotFound + )); + } + + #[tokio::test] + async fn shutdown_uses_one_deadline_for_every_thread_queue() { + let first = ThreadQueue::new(); + let second = ThreadQueue::new(); + assert!(matches!( + first.enqueue(queued_turn("shutdown", "first", "m1", 1)), + EnqueueOutcome::Enqueued { first: Some(_) } + )); + assert!(matches!( + second.enqueue(queued_turn("shutdown", "second", "m2", 2)), + EnqueueOutcome::Enqueued { first: Some(_) } + )); + + // Neither synthetic worker will call `stop_worker`, so both waits are + // intentionally stuck. Two per-queue timeouts would take roughly twice + // this budget; the production helper must return after one shared bound. + let started = tokio::time::Instant::now(); + assert!( + !stop_thread_queues(vec![first, second], std::time::Duration::from_millis(30)).await + ); + assert!( + started.elapsed() < std::time::Duration::from_millis(100), + "shutdown deadline was multiplied by the number of queues" + ); + } + + #[tokio::test] + async fn shutdown_fails_for_a_reaped_worker_with_no_durable_terminal_commit() { + let queue = ThreadQueue::new(); + assert!(matches!( + queue.enqueue(queued_turn("shutdown-terminal-failure", "thread", "m1", 1,)), + EnqueueOutcome::Enqueued { first: Some(_) } + )); + + // Models the exact production branch after the harness has been reaped + // but `rt_finalize_claimed_turn` did not commit. + queue.stop_worker_after_durable_terminal_failure(); + assert!(!queue.inner.lock().unwrap().worker_running); + assert!(queue.durable_terminal_failed()); + assert!( + !stop_thread_queues(vec![queue], Duration::from_secs(1)).await, + "a stopped process is not a clean shutdown when its durable terminal fence was lost" + ); + } + + #[test] + fn durable_terminal_failure_latch_survives_later_worker_state_changes() { + let queue = ThreadQueue::new(); + queue.stop_worker_after_durable_terminal_failure(); + + assert!(queue.start_worker_if_idle()); + queue.stop_worker(); + let _ = queue.stop_and_drain_tail(); + + assert!( + queue.durable_terminal_failed(), + "only next-boot durable recovery may retire this process-lifetime failure evidence" + ); + assert!( + !queue.is_idle(), + "registry cleanup must retain the sticky failure for shutdown reporting" + ); + } + + #[tokio::test] + async fn shutdown_stops_recovered_worker_before_it_claims_a_durable_tail() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let db: &'static ThreadsDb = Box::leak(Box::new(ThreadsDb::open_in_memory().unwrap())); + let org = "recovered-stop-org"; + let thread_id = format!("recovered-stop-{}", uuid::Uuid::new_v4()); + db.rt_create_thread(Some(&thread_id), org, "", None, "vm", None, "user") + .unwrap(); + db.rt_enqueue_turn_in_org( + org, + &thread_id, + &RtTurnEnqueueInput { + message_id: "recovered-tail".to_string(), + workflow_id: "recovered-tail-workflow".to_string(), + task_id: thread_id.clone(), + normalized_input: json!({}), + user_message: json!({ + "id": "recovered-tail", + "role": "user", + "parts": [], + }), + enqueued_at: 1, + }, + ) + .unwrap(); + let fence = db.rt_thread_fence_in_org(org, &thread_id).unwrap().unwrap(); + let key = ThreadKey::from_fence(&fence); + let lifecycle_gate = thread_lifecycle_gate_for_key(&key); + let lifecycle_guard = lifecycle_gate.lock().await; + let queue = ThreadQueue::new(); + assert!(queue.start_worker_if_idle()); + + let worker = { + let state = state.clone(); + let key = key.clone(); + let fence = fence.clone(); + let queue = queue.clone(); + tokio::spawn(async move { + drain_durable_thread_queue(&state, db, key, fence, queue).await; + }) + }; + tokio::task::yield_now().await; + + let (active, removed) = queue.stop_and_drain_tail(); + assert!(active.is_none(), "no durable claim has been mirrored yet"); + assert!(removed.is_empty(), "the recovered RAM mirror starts empty"); + assert!( + queue.inner.lock().unwrap().worker_running, + "empty RAM must not falsely mark a recovered worker stopped" + ); + let waiter = { + let queue = queue.clone(); + tokio::spawn(async move { queue.wait_worker_stopped().await }) + }; + + drop(lifecycle_guard); + tokio::time::timeout(Duration::from_secs(1), worker) + .await + .expect("worker must observe the stop fence before claiming") + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), waiter) + .await + .expect("waiter must not lose the worker-stopped notification") + .unwrap(); + + let durable = db.rt_list_turn_queue_in_org(org, &thread_id).unwrap(); + assert_eq!(durable.len(), 1); + assert_eq!(durable[0].state, RtTurnQueueState::Queued); + assert!(durable[0].claim_token.is_none()); + assert!(db + .rt_list_messages_in_org(org, &thread_id, 100, 0, false) + .unwrap() + .0 + .is_empty()); + } + + #[tokio::test] + async fn queue_registry_is_tenant_scoped_and_evicts_after_drain() { + let thread_id = "same-local-thread-id"; + let acme = ThreadKey::new("queue-tenant-acme", thread_id); + let other = ThreadKey::new("queue-tenant-other", thread_id); + let (queue, outcome) = + enqueue_thread_turn(&acme, queued_turn(&acme.org, thread_id, "tenant-m1", 1)); + let EnqueueOutcome::Enqueued { first: Some(first) } = outcome else { + panic!("first turn must own worker token"); + }; + + assert!( + !thread_queues().lock().unwrap().contains_key(&other), + "another organization must not resolve the same thread id's runtime queue" + ); + assert_eq!( + queue.list().len(), + 1, + "other org must not mutate acme queue" + ); + + drain_thread_queue(queue, *first, |_| async {}).await; + assert!( + !thread_queues().lock().unwrap().contains_key(&acme), + "empty per-thread queue entries must be evicted" + ); + } + + #[tokio::test] + async fn worker_panic_guard_latches_durable_failure_and_blocks_clean_shutdown() { + let key = ThreadKey::new("panic-org", "panic-thread"); + let queue = ThreadQueue::new(); + assert!(matches!( + queue.enqueue(queued_turn(&key.org, &key.thread_id, "panic-message", 1)), + EnqueueOutcome::Enqueued { first: Some(_) } + )); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe({ + let key = key.clone(); + let queue = queue.clone(); + move || { + let _guard = WorkerPanicGuard::new(key, queue); + panic!("synthetic worker panic"); + } + })); + + assert!(result.is_err()); + assert!(!queue.inner.lock().unwrap().worker_running); + assert!(queue.durable_terminal_failed()); + assert!( + !stop_thread_queues(vec![queue], Duration::from_secs(1)).await, + "panic without a durable terminal commit must fail shutdown quiescence" + ); + } + + #[tokio::test] + async fn stream_holds_open_for_a_thread_that_was_never_dispatched() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + // Inverted from this test's old name/assertion + // (`stream_is_204_for_a_thread_that_was_never_dispatched`): 204 here + // used to mean "nothing will ever arrive, stop reconnecting" to the + // real client's `runSseLoop` — correct for the real backend's + // degraded-JetStream case, wrong for this desktop reimplementation's + // ordinary "no dispatch yet" idle thread. See `DecopilotRun`'s "Idle + // placeholder" doc section. + let key = ThreadKey::new("acme", "never-dispatched"); + let res = stream(&state, &test_scope(), &key.org, &key.thread_id).await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers().get(header::CONTENT_TYPE).unwrap(), + "text/event-stream" + ); + drop(res); + let run = registry().lock().unwrap().remove(&key).unwrap(); + cleanup_run(&run).await; + } + + #[tokio::test] + async fn a_later_dispatch_reuses_the_idle_placeholder_a_stream_call_created() { + let thread_id = "idle-placeholder-reuse"; + let key = ThreadKey::new("acme", thread_id); + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + // Simulates the GET .../stream call that arrives before any dispatch. + let _res = stream(&state, &test_scope(), "acme", thread_id).await; + let placeholder = registry().lock().unwrap().get(&key).cloned().unwrap(); + assert!(placeholder.is_idle_placeholder()); + + // Simulates `send_message`'s run-selection logic directly rather + // than calling `send_message` itself, which would reach + // `harness::detect::ensure_detected()` — a real subprocess probe, + // unsafe for a unit test (see this module's other tests' notes on + // why the full dispatch path is E2E-only). + let selected = run_for_turn(&state, &key).await.unwrap(); + assert!( + Arc::ptr_eq(&placeholder, &selected), + "expected the same Arc so the GET .../stream subscriber sees this turn's frames live" + ); + registry().lock().unwrap().remove(&key); + cleanup_run(&placeholder).await; + } + + #[tokio::test] + async fn lagged_stream_keeps_registry_and_reconnect_replays_every_frame() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let key = ThreadKey::new("acme", "lag-reconnect"); + let spool = RunSpool::open_with_test_limits(dir.path().join("lag-reconnect.sse"), 1024, 1) + .await + .unwrap(); + let run = Arc::new(DecopilotRun { + key: key.clone(), + spool, + has_frames: AtomicBool::new(false), + finished: AtomicBool::new(false), + finish_lock: AsyncMutex::new(()), + }); + registry().lock().unwrap().insert(key.clone(), run.clone()); + + // `stream` subscribes synchronously before returning its body. Do not + // poll that body until two frames have crossed a capacity-one channel: + // its first recv must report lag and terminate without registry cleanup. + let lagged = stream(&state, &test_scope(), &key.org, &key.thread_id).await; + let first = frame(&json!({"type": "text-delta", "delta": "one"})); + let second = frame(&json!({"type": "text-delta", "delta": "two"})); + run.push(first.clone()).await.unwrap(); + run.push(second.clone()).await.unwrap(); + let lagged_body = axum::body::to_bytes(lagged.into_body(), 1024) + .await + .unwrap(); + assert!(lagged_body.is_empty()); + assert!(Arc::ptr_eq( + registry().lock().unwrap().get(&key).unwrap(), + &run + )); + + run.finish().await; + let replayed = stream(&state, &test_scope(), &key.org, &key.thread_id).await; + let replayed_body = axum::body::to_bytes(replayed.into_body(), 4096) + .await + .unwrap(); + let mut expected = first.to_vec(); + expected.extend_from_slice(&second); + assert_eq!(replayed_body.as_ref(), expected); + assert!(!registry().lock().unwrap().contains_key(&key)); + } + + #[tokio::test] + async fn cancel_is_idempotent_for_an_unknown_thread() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let res = cancel(&state, &test_scope(), "acme", "never-dispatched-either").await; + assert_eq!(res.status(), StatusCode::ACCEPTED); + } + + #[tokio::test] + async fn old_stream_cleanup_keeps_new_registry_run_but_removes_old_spool() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let acme = ThreadKey::new("acme", "shared-id"); + let other = ThreadKey::new("other", "shared-id"); + let old = DecopilotRun::open(&state, acme.clone()).await.unwrap(); + let current = DecopilotRun::open(&state, acme.clone()).await.unwrap(); + let other_run = DecopilotRun::open(&state, other.clone()).await.unwrap(); + let old_path = old.spool.path().to_path_buf(); + { + let mut runs = registry().lock().unwrap(); + runs.insert(acme.clone(), current.clone()); + runs.insert(other.clone(), other_run.clone()); + } + + cleanup_run(&old).await; + assert_eq!( + tokio::fs::metadata(old_path).await.unwrap_err().kind(), + std::io::ErrorKind::NotFound + ); + assert!(Arc::ptr_eq( + registry().lock().unwrap().get(&acme).unwrap(), + ¤t + )); + cleanup_run(¤t).await; + assert!(Arc::ptr_eq( + registry().lock().unwrap().get(&other).unwrap(), + &other_run + )); + cleanup_run(&other_run).await; + } + + #[tokio::test] + async fn startup_cleanup_removes_only_direct_owned_sse_spools() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let spool_dir = dir.path().join(".decocms/run-streams"); + let nested = spool_dir.join("nested"); + tokio::fs::create_dir_all(&nested).await.unwrap(); + let stale = spool_dir.join("stale.sse"); + let unrelated = spool_dir.join("keep.txt"); + let nested_sse = nested.join("keep.sse"); + tokio::fs::write(&stale, b"stale").await.unwrap(); + tokio::fs::write(&unrelated, b"keep").await.unwrap(); + tokio::fs::write(&nested_sse, b"keep").await.unwrap(); + + cleanup_stale_run_spools(&state).await.unwrap(); + + assert_eq!( + tokio::fs::metadata(stale).await.unwrap_err().kind(), + std::io::ErrorKind::NotFound + ); + assert!(tokio::fs::metadata(unrelated).await.is_ok()); + assert!(tokio::fs::metadata(nested_sse).await.is_ok()); + } + + #[tokio::test] + async fn spool_capacity_failure_cancels_and_persists_failed_terminal() { + let dir = tempfile::tempdir().unwrap(); + let db = ThreadsDb::open_in_memory().unwrap(); + let thread_id = format!("spool-failure-{}", uuid::Uuid::new_v4()); + let message_id = "spool-user"; + db.rt_create_thread(Some(&thread_id), "acme", "", None, "vm", None, "user") + .unwrap(); + let fence = db + .rt_thread_fence_in_org("acme", &thread_id) + .unwrap() + .unwrap(); + db.rt_enqueue_turn_in_org( + "acme", + &thread_id, + &RtTurnEnqueueInput { + message_id: message_id.to_string(), + workflow_id: "spool-workflow".to_string(), + task_id: "spool-task".to_string(), + normalized_input: json!({}), + user_message: json!({ + "id": message_id, + "role": "user", + "parts": [{"type": "text", "text": "hello"}], + }), + enqueued_at: 1, + }, + ) + .unwrap(); + let claimed = match db.rt_claim_turn_queue_head_fenced(&fence).unwrap().unwrap() { + RtTurnClaimOutcome::Ready(claimed) => claimed, + RtTurnClaimOutcome::Malformed(error) => panic!("unexpected malformed claim: {error:?}"), + RtTurnClaimOutcome::Completed { workflow_id } => { + panic!("unexpected completed claim: {workflow_id}") + } + }; + assert!(matches!( + db.rt_begin_claimed_turn(&claimed).unwrap(), + RtTurnBeginOutcome::Begun(_) + )); + let key = ThreadKey::new("acme", &thread_id); + let spool = RunSpool::open_with_cap(dir.path().join("bounded.sse"), 4) + .await + .unwrap(); + let run = Arc::new(DecopilotRun { + key, + spool, + has_frames: AtomicBool::new(false), + finished: AtomicBool::new(false), + finish_lock: AsyncMutex::new(()), + }); + run.push(Bytes::from_static(b"1234")).await.unwrap(); + let error = run.push(Bytes::from_static(b"5")).await.unwrap_err(); + let cancellation = TurnCancellation::new(); + + assert!(terminate_for_spool_failure(&run, &db, &claimed, &cancellation, &error).await); + + assert!(cancellation.is_requested()); + let assistant = db + .rt_get_message_in_org("acme", &reserved_assistant_message_id(&claimed)) + .unwrap() + .unwrap(); + assert_eq!(assistant.role, "assistant"); + assert_eq!(assistant.parts[0]["text"], STREAM_FAILURE_RESPONSE); + assert_eq!( + assistant.metadata.as_ref().unwrap()["errorCode"], + "local_stream_failure" + ); + assert_eq!( + db.rt_get_thread_in_org("acme", &thread_id) + .unwrap() + .unwrap() + .status, + "failed" + ); + assert!(db + .rt_list_turn_queue_in_org("acme", &thread_id) + .unwrap() + .is_empty()); + cleanup_run(&run).await; + } + + #[tokio::test] + async fn active_malformed_head_publishes_the_committed_failed_thread() { + let db = ThreadsDb::open_in_memory().unwrap(); + let account = test_scope(); + let org = format!("malformed-watch-org-{}", uuid::Uuid::new_v4()); + let thread_id = format!("malformed-watch-thread-{}", uuid::Uuid::new_v4()); + db.rt_create_thread_scoped( + &account, + Some(&thread_id), + &org, + "Malformed local thread", + None, + "vir_local", + Some("feature/native"), + &account.user_id, + ) + .unwrap(); + db.rt_enqueue_turn_scoped( + &account, + &org, + &thread_id, + &RtTurnEnqueueInput { + message_id: "malformed-watch-message".to_string(), + workflow_id: "malformed-watch-workflow".to_string(), + task_id: thread_id.clone(), + normalized_input: json!({"messages": []}), + user_message: json!({ + "id": "malformed-watch-message", + "role": "user", + "parts": [{"type": "text", "text": "accepted"}], + }), + enqueued_at: 1, + }, + ) + .unwrap(); + db.execute_batch_for_test( + "UPDATE native_scoped_turn_queue SET normalized_input_json = '{' \ + WHERE workflow_id = 'malformed-watch-workflow'", + ) + .unwrap(); + let fence = db + .rt_thread_fence_in_scope(&account, &org, &thread_id) + .unwrap() + .unwrap(); + let malformed = match db.rt_claim_turn_queue_head_fenced(&fence).unwrap().unwrap() { + RtTurnClaimOutcome::Malformed(orphan) => orphan, + other => panic!("expected malformed head, got {other:?}"), + }; + + let response = watch::get(&account, &org, Some("types=decopilot.thread.status")); + let mut stream = response.into_body().into_data_stream(); + let connected = tokio::time::timeout(Duration::from_secs(1), stream.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(String::from_utf8_lossy(&connected).contains("event: connected")); + + finalize_active_malformed_orphan(&db, &malformed).unwrap(); + + let status = tokio::time::timeout(Duration::from_secs(1), stream.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let status = String::from_utf8(status.to_vec()).unwrap(); + let data = status + .lines() + .find_map(|line| line.strip_prefix("data: ")) + .unwrap(); + let event: Value = serde_json::from_str(data).unwrap(); + assert_eq!(event["subject"], thread_id); + assert_eq!(event["data"]["status"], "failed"); + assert_eq!(event["data"]["title"], "Malformed local thread"); + assert_eq!(event["data"]["virtual_mcp_id"], "vir_local"); + assert_eq!(event["data"]["created_by"], account.user_id); + assert_eq!(event["data"]["branch"], "feature/native"); + assert!(event["data"].get("message_id").is_none()); + } + + #[tokio::test] + async fn send_rejects_a_thread_already_inside_delete_lifecycle() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let thread_id = format!("closing-{}", uuid::Uuid::new_v4()); + let scope = test_scope(); + mark_thread_closing(&scope, "acme", &thread_id); + let body = Bytes::from( + serde_json::to_vec(&json!({ + "messages": [{ + "id": "closing-message", + "role": "user", + "parts": [{"type": "text", "text": "must not enqueue"}], + }], + })) + .unwrap(), + ); + + let response = send_message(&state, &scope, "acme", &thread_id, &body).await; + clear_thread_closing(&scope, "acme", &thread_id); + + assert_eq!(response.status(), StatusCode::CONFLICT); + assert!(shared_db(&state) + .unwrap() + .rt_get_thread_in_org("acme", &thread_id) + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn send_rejects_reserved_assistant_namespace_before_implicit_create() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let scope = test_scope(); + let thread_id = format!("reserved-id-{}", uuid::Uuid::new_v4()); + let body = Bytes::from( + serde_json::to_vec(&json!({ + "messages": [{ + "id": "native-assistant:v99:caller-controlled", + "role": "user", + "parts": [{"type": "text", "text": "must be rejected"}], + }], + })) + .unwrap(), + ); + + let response = send_message(&state, &scope, "acme", &thread_id, &body).await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!(shared_db(&state) + .unwrap() + .rt_get_thread_in_scope(&scope, "acme", &thread_id) + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn delete_quiesce_timeout_keeps_durable_tail_closed_and_resumable() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let scope = test_scope(); + let org = "delete-timeout-org"; + let thread_id = format!("delete-timeout-{}", uuid::Uuid::new_v4()); + let database = shared_db(&state).unwrap(); + database + .rt_create_thread_scoped( + &scope, + Some(&thread_id), + org, + "", + None, + "vmcp", + None, + &scope.user_id, + ) + .unwrap(); + let mut durable_items = Vec::new(); + for (message_id, at) in [("timeout-head", 1), ("timeout-tail", 2)] { + let input = RtTurnEnqueueInput { + message_id: message_id.to_string(), + workflow_id: format!("workflow-{message_id}"), + task_id: thread_id.clone(), + normalized_input: json!({}), + user_message: json!({ + "id": message_id, + "role": "user", + "parts": [{"type": "text", "text": message_id}], + }), + enqueued_at: at, + }; + let item = match database + .rt_enqueue_turn_scoped(&scope, org, &thread_id, &input) + .unwrap() + { + RtTurnEnqueueOutcome::Inserted(item) => item, + other => panic!("expected insertion, got {other:?}"), + }; + durable_items.push(item); + } + let fence = database + .rt_thread_fence_in_scope(&scope, org, &thread_id) + .unwrap() + .unwrap(); + assert!(database.rt_mark_thread_delete_pending(&fence).unwrap()); + mark_thread_closing(&scope, org, &thread_id); + let key = ThreadKey::from_fence(&fence); + for item in durable_items { + let _ = enqueue_thread_turn(&key, QueuedTurn::from_durable(item)); + } + + let error = quiesce_thread_for_delete_with_timeout(&state, &fence, Duration::ZERO) + .await + .unwrap_err(); + assert_eq!(error.status, StatusCode::CONFLICT); + assert!(database.rt_thread_delete_pending(&fence).unwrap()); + assert_eq!( + database + .rt_list_turn_queue_scoped(&scope, org, &thread_id) + .unwrap() + .len(), + 2, + "timeout must not discard either already-accepted row" + ); + assert!(database + .rt_claim_turn_queue_head_fenced(&fence) + .unwrap() + .is_none()); + + if let Some(queue) = thread_queues().lock().unwrap().remove(&key) { + queue.stop_worker(); + } + clear_thread_closing(&scope, org, &thread_id); + } + + #[tokio::test] + async fn orphan_finalization_failure_keeps_account_recovery_retryable() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let scope = RtAccountScope::new( + "retryable-recovery.invalid", + format!("recovery-user-{}", uuid::Uuid::new_v4()), + ) + .unwrap(); + let org = "retryable-recovery-org"; + let thread_id = format!("retryable-recovery-{}", uuid::Uuid::new_v4()); + let database = shared_db(&state).unwrap(); + database + .rt_create_thread_scoped( + &scope, + Some(&thread_id), + org, + "retryable recovery", + None, + "vmcp", + None, + &scope.user_id, + ) + .unwrap(); + let input = RtTurnEnqueueInput { + message_id: "retryable-user".to_string(), + workflow_id: "retryable-workflow".to_string(), + task_id: thread_id.clone(), + normalized_input: json!({}), + user_message: json!({ + "id": "retryable-user", + "role": "user", + "parts": [{"type": "text", "text": "accepted"}], + }), + enqueued_at: 1, + }; + database + .rt_enqueue_turn_scoped(&scope, org, &thread_id, &input) + .unwrap(); + let fence = database + .rt_thread_fence_in_scope(&scope, org, &thread_id) + .unwrap() + .unwrap(); + database + .rt_claim_turn_queue_head_fenced(&fence) + .unwrap() + .unwrap(); + let untouched_thread = format!("untouched-recovery-{}", uuid::Uuid::new_v4()); + database + .rt_create_thread_scoped( + &scope, + Some(&untouched_thread), + org, + "must not launch on failed recovery", + None, + "vmcp", + None, + &scope.user_id, + ) + .unwrap(); + database + .rt_enqueue_turn_scoped( + &scope, + org, + &untouched_thread, + &RtTurnEnqueueInput { + message_id: "untouched-user".to_string(), + workflow_id: "untouched-workflow".to_string(), + task_id: untouched_thread.clone(), + normalized_input: json!({}), + user_message: json!({ + "id": "untouched-user", + "role": "user", + "parts": [{"type": "text", "text": "do not launch"}], + }), + enqueued_at: 2, + }, + ) + .unwrap(); + let untouched_fence = database + .rt_thread_fence_in_scope(&scope, org, &untouched_thread) + .unwrap() + .unwrap(); + + database + .execute_batch_for_test( + "CREATE TRIGGER fail_orphan_cleanup \ + BEFORE DELETE ON native_scoped_turn_queue \ + WHEN OLD.workflow_id = 'retryable-workflow' \ + BEGIN SELECT RAISE(ABORT, 'fail once'); END", + ) + .unwrap(); + let first = ensure_account_recovered(&state, &scope).await.unwrap_err(); + assert!(first.contains("left 1 orphaned turn(s) retryable")); + let recovery_key = format!("{}\0{}", state.app_root.display(), scope.storage_key()); + assert!(!recovered_accounts().lock().unwrap().contains(&recovery_key)); + assert!( + !thread_queues() + .lock() + .unwrap() + .contains_key(&ThreadKey::from_fence(&untouched_fence)), + "failed recovery must not launch an unrelated queued harness" + ); + assert!(matches!( + database + .rt_cancel_turn_scoped(&scope, org, &untouched_thread, "untouched-workflow",) + .unwrap(), + RtTurnCancelOutcome::QueuedDeleted(_) + )); + + database + .execute_batch_for_test("DROP TRIGGER fail_orphan_cleanup") + .unwrap(); + ensure_account_recovered(&state, &scope).await.unwrap(); + ensure_account_recovered(&state, &scope).await.unwrap(); + assert!(database + .rt_list_turn_queue_scoped(&scope, org, &thread_id) + .unwrap() + .is_empty()); + let messages = database + .rt_list_messages_in_scope(&scope, org, &thread_id, 100, 0, false) + .unwrap() + .0; + assert_eq!( + messages + .iter() + .map(|message| message.role.as_str()) + .collect::>(), + ["user", "assistant"] + ); + } + + #[tokio::test] + async fn send_waiting_on_thread_gate_is_rejected_when_shutdown_wins() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let scope = test_scope(); + let org = "shutdown-admission-org"; + let thread_id = "shutdown-admission-thread"; + let lifecycle_gate = thread_lifecycle_gate(&scope, org, thread_id); + let lifecycle_guard = lifecycle_gate.lock().await; + let body = Bytes::from( + serde_json::to_vec(&json!({ + "messages": [{ + "id": "must-not-enqueue", + "role": "user", + "parts": [{"type": "text", "text": "too late"}], + }], + })) + .unwrap(), + ); + let sender = { + let state = state.clone(); + let scope = scope.clone(); + let body = body.clone(); + tokio::spawn(async move { send_message(&state, &scope, org, thread_id, &body).await }) + }; + tokio::task::yield_now().await; + + state.shutdown.run().await; + drop(lifecycle_guard); + let response = sender.await.unwrap(); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert!(shared_db(&state) + .unwrap() + .rt_get_thread_in_scope(&scope, org, thread_id) + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn delayed_send_to_a_retired_thread_is_gone_and_never_enqueues() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let scope = test_scope(); + let org = "retired-send-org"; + let thread_id = format!("retired-send-{}", uuid::Uuid::new_v4()); + let database = shared_db(&state).unwrap(); + database + .rt_create_thread_scoped( + &scope, + Some(&thread_id), + org, + "deleted", + None, + "vmcp", + None, + &scope.user_id, + ) + .unwrap(); + let fence = database + .rt_thread_fence_in_scope(&scope, org, &thread_id) + .unwrap() + .unwrap(); + assert!(database + .rt_delete_thread_in_org_if_generation(&fence) + .unwrap()); + let body = Bytes::from( + serde_json::to_vec(&json!({ + "messages": [{ + "id": "delayed-old-message", + "role": "user", + "parts": [{"type": "text", "text": "must not resurrect"}], + }], + })) + .unwrap(), + ); + + let response = send_message(&state, &scope, org, &thread_id, &body).await; + + assert_eq!(response.status(), StatusCode::GONE); + assert!(database + .rt_list_turn_queue_scoped(&scope, org, &thread_id) + .unwrap() + .is_empty()); + assert!(database + .rt_get_thread_in_scope(&scope, org, &thread_id) + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn send_rejects_multiple_non_system_messages() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let body = Bytes::from( + serde_json::to_vec(&json!({ + "messages": [ + {"role": "user", "parts": [{"type": "text", "text": "one"}]}, + {"role": "user", "parts": [{"type": "text", "text": "two"}]}, + ], + })) + .unwrap(), + ); + + let response = send_message(&state, &test_scope(), "acme", "multi-user", &body).await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn send_rejects_assistant_only_continuation() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let body = Bytes::from( + serde_json::to_vec(&json!({ + "messages": [{ + "role": "assistant", + "parts": [{"type": "text", "text": "continue"}], + }], + })) + .unwrap(), + ); + + let response = send_message(&state, &test_scope(), "acme", "assistant-only", &body).await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn unrecognized_decopilot_subpaths_404_locally() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let res = dispatch( + &state, + &test_scope(), + "acme", + &Method::GET, + &["some", "future", "route"], + &Bytes::new(), + ) + .await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn send_message_rejects_a_body_with_no_user_message() { + // Safe at the unit tier: this returns before `send_message` ever + // reaches `harness::detect`/spawn — see this file's + // `run_harness_and_stream`'s sibling coverage note below for why + // the full happy path is deliberately NOT unit-tested here. + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let body = Bytes::from(json!({"messages": []}).to_string()); + let res = send_message(&state, &test_scope(), "acme", "t1", &body).await; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + } + + // `send_message`'s full happy path (202 + background harness spawn + + // SSE stream + persistence) is deliberately NOT unit-tested in this + // file: it calls `harness::detect::ensure_detected()`, which does a + // REAL `PATH` lookup (and, absent an env override, a real `claude`/ + // `codex` subprocess probe) — on a developer machine that actually has + // either CLI installed, a plain `cargo test` run would detect it and + // then `harness::run::start` would spawn the REAL binary with a + // synthetic prompt, an unpredictable (and potentially costly/hanging) + // side effect for a unit test. This is exactly why + // `apps/native/crates/harness`'s own module doc keeps `routes/ + // dispatch.rs`'s real-harness streaming as E2E-only (`LOCAL_API_CLAUDE_BIN` + // pointed at the deterministic stub, spawned as a SEPARATE OS process + // with a controlled environment) rather than unit-tested — this + // family's real-dispatch coverage follows the same pattern: see + // `apps/native/e2e/real-ui-interception.e2e.test.ts`. +} diff --git a/apps/native/crates/local-api/src/routes/intercept/link_current.rs b/apps/native/crates/local-api/src/routes/intercept/link_current.rs new file mode 100644 index 0000000000..e234d9979e --- /dev/null +++ b/apps/native/crates/local-api/src/routes/intercept/link_current.rs @@ -0,0 +1,48 @@ +//! `POST /api/:org/tools/LINK_CURRENT_GET` — CLI/tier availability, via the +//! pre-existing "linked desktop" tool rather than a bespoke endpoint. Wire +//! contract: the native interception contract §3.4. +//! +//! Answered ENTIRELY locally (never forwarded, and never even consulted for +//! whether a real link session exists upstream — a desktop app running +//! this process IS the local machine, so `online` is unconditionally +//! `true`): `capabilities` reflects `harness::detect`'s SAME grow-only CLI +//! probe `routes/models.rs`'s (now-superseded, see that file's own doc +//! comment update) `/models` endpoint used, so this can never disagree with +//! what the local dispatch actually runs. + +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; + +pub async fn get() -> Response { + let cache = harness::detect::ensure_detected().await; + let mut capabilities = Vec::new(); + if cache.get(harness::HarnessId::ClaudeCode) { + capabilities.push("claude-code"); + } + if cache.get(harness::HarnessId::Codex) { + capabilities.push("codex"); + } + + Json(json!({ + "online": true, + "capabilities": capabilities, + })) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::to_bytes; + + #[tokio::test] + async fn always_reports_online_with_a_capabilities_array() { + let res = get().await; + assert_eq!(res.status(), axum::http::StatusCode::OK); + let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["online"], true); + assert!(body["capabilities"].is_array()); + } +} diff --git a/apps/native/crates/local-api/src/routes/intercept/mod.rs b/apps/native/crates/local-api/src/routes/intercept/mod.rs new file mode 100644 index 0000000000..98f0244970 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/intercept/mod.rs @@ -0,0 +1,335 @@ +//! ONE canonical interception table for `routes/upstream.rs`'s app-API +//! proxy — the owner course-correction's central ask: the desktop app boots +//! the REAL production web shell (`apps/web/src`), and this module is +//! where local-api answers, LOCALLY, the specific narrow set of routes that +//! shell's thread/chat/model surfaces need served from local SQLite + the +//! local harness CLI instead of the (unmodified, possibly-production) +//! upstream mesh. Every route below cites +//! the native interception contract (the wire-contract recon) +//! for the exact shape it emulates — this module is that recon turned into +//! code, not a reinterpretation of it. +//! +//! [`try_intercept`] is [`crate::routes::upstream::proxy`]'s FIRST decision, +//! before the `/api/auth/*` cookie-relay branch or the ordinary +//! bearer-forwarding branch ever run: intercepted routes need neither — they +//! never talk to upstream at all. Anything this module doesn't recognize +//! returns `None`, and the caller falls through to the unmodified proxy +//! behavior (bearer + persisted-cookie forwarding). See each submodule's own +//! doc comment for its slice of the table: +//! +//! | Route(s) | Map section | Submodule | +//! | --- | --- | --- | +//! | `POST /api/:org/tools/COLLECTION_THREADS_LIST` | §3.1 | [`thread_tools`] | +//! | `POST /api/:org/tools/COLLECTION_THREADS_GET` | §3.1 | [`thread_tools`] | +//! | `POST /api/:org/tools/COLLECTION_THREADS_CREATE` | §3.1 | [`thread_tools`] | +//! | `POST /api/:org/tools/COLLECTION_THREADS_UPDATE` | §3.1 | [`thread_tools`] | +//! | `POST /api/:org/tools/COLLECTION_THREADS_DELETE` | §3.1 | [`thread_tools`] | +//! | `POST /api/:org/tools/COLLECTION_THREAD_MESSAGES_LIST` | §3.1 | [`thread_tools`] | +//! | `POST /api/:org/tools/LINK_CURRENT_GET` | §3.4 | [`link_current`] | +//! | `GET /api/:org/watch` | local thread lifecycle SSE | [`watch`] | +//! | `POST /api/:org/sandbox/:virtualMcpId/:branch/{read,write,unlink,mkdir,rename,glob,grep}` | native sandbox filesystem bridge | [`sandbox_fs`] | +//! | `POST /api/:org/decopilot/threads/:threadId/messages` | §3.2 | [`decopilot`] | +//! | `GET /api/:org/decopilot/threads/:threadId/stream` | §3.2 | [`decopilot`] | +//! | `POST /api/:org/decopilot/cancel/:threadId` | §3.2 | [`decopilot`] | +//! | `GET /api/:org/decopilot/queue/:threadId` | §3.2 | [`decopilot`] | +//! | `POST /api/:org/decopilot/queue/:threadId/cancel/:workflowId` | §3.2 | [`decopilot`] | +//! | any other `/api/:org/decopilot/*` | §3.2 backstop | [`decopilot`] (local 404, never forwarded) | +//! | any other `/api/:org/tools/:toolName` | — | not intercepted (`None`) | +//! +//! Native `GET /api/:org/watch` is intentionally local-only. It never opens, +//! forwards, or merges the upstream watch stream: local SQLite is the source +//! of truth for native thread changes, and upstream has no copy of those rows. +//! +//! ## Why `/api/:org/decopilot/*` is a 100%-intercepted family, not a +//! per-route table +//! +//! Every OTHER row above is matched by exact tool name / exact path shape — +//! an unrecognized tool name or an unrecognized top-level `/api/:org/*` +//! route family falls through to the ordinary upstream proxy, which is +//! correct (org CRUD, connections, members, roles, settings, task board, +//! etc. are genuinely meant to proxy unchanged — map §2.3). `/decopilot/*` +//! is different: per map §3.2, its cloud implementation enqueues onto a +//! DBOS workflow and tails NATS JetStream, neither reachable from this +//! process, so ANY sub-path under it must be intercepted, never +//! selectively forwarded by payload — this is the backstop for the +//! frontend's own `pendingAgentOption` default risk (map §2.4): even if a +//! request under this prefix somehow named `harnessId: "decopilot"` or +//! omitted it, this module still answers locally (running the +//! locally-preferred CLI, or a clear local error) rather than ever letting +//! it reach the real upstream decopilot route. +//! +//! ## `:org` is opaque +//! +//! Neither this module nor its submodules validate the `:org` path segment +//! against a real organization — it is stamped verbatim as +//! `organization_id` on locally-created rows. Map §3.1 makes the same +//! point about `virtual_mcp_id`: these are opaque strings scoping purely +//! local data, never round-tripped against upstream. + +mod agent_sessions; +pub mod decopilot; +pub mod link_current; +mod preview_invoke; +pub(crate) mod run_spool; +mod sandbox_events; +mod sandbox_fs; +mod sandbox_lifecycle; +mod sandbox_ops; +pub mod thread_tools; + +pub(crate) use sandbox_lifecycle::set_preview_port; +pub(crate) mod watch; + +use axum::body::Bytes; +use axum::http::Method; +use axum::response::{IntoResponse, Response}; + +use crate::error::ApiError; +use crate::state::AppState; + +/// The single entry point `routes/upstream.rs::proxy` calls before its +/// ordinary `/api/auth/*` / bearer-forwarding branches. `path` is the bare +/// request path (e.g. `/api/acme/tools/COLLECTION_THREADS_LIST` or +/// `/api/acme/decopilot/threads/t1/messages` — no `/upstream` prefix to +/// strip anymore) with its query string supplied separately for `/watch`'s +/// optional `types` filter. Other intercepted routes do not read query +/// parameters — `COLLECTION_THREADS_LIST`'s pagination/filter fields all ride +/// in the POST body, per `tools-rest.ts`'s "body = tool arguments verbatim" +/// contract, map §3.1. +/// Whether `` in `/api//` is really an organization. +/// +/// NOT every second path segment is one: `/api/config`, `/api/auth/*` and the +/// instance-level `/api/_admin/*` namespace all sit at the same position. An +/// earlier version treated them as orgs and mounted a full set of volumes for +/// each — four bogus organizations, sixteen rclone processes. +/// +/// The org-scoped tool surface is the reliable signal: it is where the +/// webview's org-scoped traffic actually goes, and nothing instance-level +/// lives under it. The underscore check is the second layer, matching the +/// repo's documented convention for instance-level namespaces. +fn is_org_scoped(segment: &str, rest: &[&str]) -> bool { + !segment.starts_with('_') && rest.first() == Some(&"tools") +} + +pub async fn try_intercept( + state: &AppState, + method: &Method, + path: &str, + query: Option<&str>, + body: &Bytes, +) -> Option { + let mut segs = path.trim_start_matches('/').split('/'); + if segs.next()? != "api" { + return None; + } + let org = segs.next().filter(|s| !s.is_empty())?; + let rest: Vec<&str> = segs.collect(); + + // Start warming this organization's filesystem. A genuinely org-scoped + // request here is exactly "the app booted into this org" or "the user + // switched to it", so the mounts come up while the user is still + // navigating to an agent instead of on the sandbox-provisioning path. + // Idempotent and O(1) — an org that is ready, in flight, or cooling off + // returns immediately, which matters because this runs on every request. + if is_org_scoped(org, &rest) { + crate::sandbox::org_mount::warm(&state.app_root, org); + } + + if let Some(response) = sandbox_fs::try_dispatch(state, method, &rest, body).await { + return Some(response); + } + + if let Some(response) = sandbox_events::try_dispatch(state, method, &rest).await { + return Some(response); + } + + if let Some(response) = preview_invoke::try_dispatch(state, method, &rest, body).await { + return Some(response); + } + + if let Some(response) = sandbox_ops::try_dispatch(state, method, &rest, query, body).await { + return Some(response); + } + + if let Some(response) = agent_sessions::try_dispatch(state, method, path, &rest, query).await { + return Some(response); + } + + if let Some(response) = sandbox_lifecycle::try_dispatch(state, method, org, &rest, body).await { + return Some(response); + } + + match rest.first().copied() { + Some("watch") if rest.len() == 1 && *method == Method::GET => { + let scope = match thread_tools::current_account_scope_result().await { + Ok(Some(scope)) => scope, + Ok(None) => return Some(ApiError::unauthorized().into_response()), + Err(error) => { + tracing::warn!(%error, "native watch account scope storage unavailable"); + return Some( + ApiError::new( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "native session storage is temporarily unavailable", + ) + .into_response(), + ); + } + }; + if let Err(error) = decopilot::ensure_account_recovered(state, &scope).await { + return Some( + ApiError::internal(format!("native chat queue recovery failed: {error}")) + .into_response(), + ); + } + Some(watch::get(&scope, org, query)) + } + Some("tools") if rest.len() == 2 && *method == Method::POST => match rest[1] { + "LINK_CURRENT_GET" => Some(link_current::get().await), + tool_name @ ("COLLECTION_THREADS_LIST" + | "COLLECTION_THREADS_GET" + | "COLLECTION_THREADS_CREATE" + | "COLLECTION_THREADS_UPDATE" + | "COLLECTION_THREADS_DELETE" + | "COLLECTION_THREAD_MESSAGES_LIST") => { + let Some(scope) = thread_tools::current_account_scope().await else { + return Some(ApiError::unauthorized().into_response()); + }; + if let Err(error) = decopilot::ensure_account_recovered(state, &scope).await { + return Some( + ApiError::internal(format!("native chat queue recovery failed: {error}")) + .into_response(), + ); + } + thread_tools::dispatch_scoped(state, &scope, org, tool_name, body).await + } + _ => None, + }, + Some("decopilot") => { + let Some(scope) = thread_tools::current_account_scope().await else { + return Some(ApiError::unauthorized().into_response()); + }; + if let Err(error) = decopilot::ensure_account_recovered(state, &scope).await { + return Some( + ApiError::internal(format!("native chat queue recovery failed: {error}")) + .into_response(), + ); + } + Some(decopilot::dispatch(state, &scope, org, method, &rest[1..], body).await) + } + _ => None, + } +} + +/// Test-only `AppState` fixture, shared by this module's own tests AND +/// every sibling submodule's tests (`thread_tools`, `decopilot`, +/// `link_current`) — one place to keep in sync with `AppState`'s fields +/// rather than duplicating this boilerplate per file. +#[cfg(test)] +pub(crate) fn test_state(root: &std::path::Path) -> AppState { + use crate::state::ApiMode; + use std::sync::Arc; + + let config = Arc::new(crate::config::ConfigStore::new()); + let logs = Arc::new(crate::log_store::LogStore::new(root.join("logs"))); + let tasks = Arc::new(crate::tasks::TaskRegistry::new(logs)); + let broadcaster = Arc::new(crate::events::Broadcaster::new()); + let repo_dir = root.join("repo"); + let setup = crate::setup::SetupOrchestrator::new( + repo_dir.clone(), + repo_dir.clone(), + config.clone(), + tasks.clone(), + broadcaster.clone(), + ); + AppState { + token: Arc::from("test-token"), + boot_id: Arc::from("boot-1"), + sandbox_manager: crate::sandbox::SandboxManager::new(root.to_path_buf()), + app_root: root.to_path_buf(), + repo_dir, + mode: ApiMode::Strict, + config, + tasks, + broadcaster, + shutdown: Arc::new(crate::shutdown::ShutdownCoordinator::new()), + setup, + } +} + +#[cfg(test)] +mod tests { + use super::is_org_scoped; + + /// The regression: `/api/config`, `/api/auth/*` and `/api/_admin/*` sit at + /// the same path position as an org slug. Treating them as organizations + /// mounted a full set of volumes for each — 16 rclone processes for one + /// real org. + #[test] + fn instance_level_paths_are_not_organizations() { + assert!(!is_org_scoped("config", &[])); + assert!(!is_org_scoped("auth", &["callback"])); + assert!(!is_org_scoped("_admin", &["tools", "ANYTHING"])); + assert!(!is_org_scoped("deco", &["decopilot", "threads"])); + } + + #[test] + fn the_org_scoped_tool_surface_is_an_organization() { + assert!(is_org_scoped("deco", &["tools", "SANDBOX_START"])); + assert!(is_org_scoped("gimenes-guarana-works", &["tools", "X"])); + } + + use super::*; + + #[tokio::test] + async fn unrecognized_tool_names_are_not_intercepted() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let res = try_intercept( + &state, + &Method::POST, + "/api/acme/tools/SOME_OTHER_TOOL", + None, + &Bytes::from_static(b"{}"), + ) + .await; + assert!(res.is_none()); + } + + #[tokio::test] + async fn non_org_scoped_paths_are_not_intercepted() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + assert!( + try_intercept(&state, &Method::GET, "/api/config", None, &Bytes::new(),) + .await + .is_none() + ); + assert!(try_intercept( + &state, + &Method::GET, + "/api/auth/get-session", + None, + &Bytes::new(), + ) + .await + .is_none()); + } + + #[tokio::test] + async fn every_decopilot_subpath_is_intercepted_even_when_unrecognized() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path()); + let res = try_intercept( + &state, + &Method::GET, + "/api/acme/decopilot/some-future-route", + None, + &Bytes::new(), + ) + .await; + assert!( + res.is_some(), + "the entire /decopilot/* family must be intercepted, never forwarded upstream" + ); + } +} diff --git a/apps/native/crates/local-api/src/routes/intercept/preview_invoke.rs b/apps/native/crates/local-api/src/routes/intercept/preview_invoke.rs new file mode 100644 index 0000000000..8b32f1dee5 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/intercept/preview_invoke.rs @@ -0,0 +1,226 @@ +//! `POST /api/:org/sandbox/:virtualMcpId/:branch/preview-invoke` — resolve one +//! Deco loader/action against THIS machine's dev server. +//! +//! Byte-parity target: `apps/api/src/api/routes/sandbox-proxy.ts`'s +//! `preview-invoke` handler, which resolves the sandbox's preview URL and +//! POSTs `{...props}` to `/deco/invoke/`. +//! +//! ## Why this has to be intercepted +//! +//! Without it the request falls through to the upstream proxy, which looks for +//! a CLOUD sandbox that does not exist for a desktop-run agent and answers +//! `502 Preview not available`. The dev server it needs is a child process on +//! this machine; upstream cannot reach it, and never could. +//! +//! The invoke is sent straight to the sandbox's dev port rather than through +//! the preview proxy: this is a server-side call with no browser involved, so +//! there is no reason to route it back out through a listener whose whole job +//! is attaching preview cookies and rewriting a browser's view of the origin. + +use axum::body::{Body, Bytes}; +use axum::http::{header, HeaderValue, Method, StatusCode}; +use axum::response::{IntoResponse, Response}; +use serde_json::{Map, Value}; + +use super::sandbox_fs::decode_identity_segment; +use crate::error::ApiError; +use crate::sandbox::SandboxManager; +use crate::state::AppState; + +/// Cap on the invoke body, mirroring `PREVIEW_INVOKE_MAX_BODY_BYTES`. +const MAX_BODY_BYTES: usize = 1024 * 1024; + +pub(super) async fn try_dispatch( + state: &AppState, + method: &Method, + rest: &[&str], + body: &Bytes, +) -> Option { + let ["sandbox", encoded_virtual_mcp_id, encoded_branch, "preview-invoke"] = rest else { + return None; + }; + if *method != Method::POST { + return Some( + ApiError::new( + StatusCode::METHOD_NOT_ALLOWED, + format!("method not allowed for preview-invoke: {method}"), + ) + .into_response(), + ); + } + if body.len() > MAX_BODY_BYTES { + return Some( + ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "Payload too large").into_response(), + ); + } + + let virtual_mcp_id = match decode_identity_segment("virtualMcpId", encoded_virtual_mcp_id) { + Ok(value) => value, + Err(error) => return Some(error.into_response()), + }; + let branch = match decode_identity_segment("branch", encoded_branch) { + Ok(value) => value, + Err(error) => return Some(error.into_response()), + }; + Some(invoke(state, &virtual_mcp_id, &branch, body).await) +} + +async fn invoke(state: &AppState, virtual_mcp_id: &str, branch: &str, body: &Bytes) -> Response { + let Some(parsed) = serde_json::from_slice::(body) + .ok() + .and_then(|value| parse_invoke(&value)) + else { + // Two distinct upstream errors collapse here only when the body is not + // a JSON object at all; the resolveType case is reported separately + // below by `parse_invoke` returning None for a present-but-invalid one. + return ApiError::bad_request("Invalid or missing __resolveType").into_response(); + }; + + let handle = SandboxManager::compute_handle(virtual_mcp_id, branch); + let Some(port) = state + .sandbox_manager + .get(&handle) + .and_then(|sandbox| crate::routes::proxy::sandbox_dev_port(&sandbox)) + else { + return ApiError::new(StatusCode::BAD_GATEWAY, "Preview not available").into_response(); + }; + + let url = format!( + "http://127.0.0.1:{port}/deco/invoke/{}", + // RAW in the path, slashes intact: the deco runtime routes + // `/deco/invoke/*` on the UN-decoded path, so a percent-encoded key + // never matches a loader. `parse_invoke` has already rejected + // anything that could escape the path. + parsed.resolve_type + ); + let payload = match serde_json::to_vec(&parsed.payload) { + Ok(bytes) => bytes, + Err(_) => { + return ApiError::internal("could not re-encode the invoke payload").into_response() + } + }; + + let response = reqwest::Client::new() + .post(&url) + .header(header::CONTENT_TYPE, "application/json") + .body(payload) + .send() + .await; + let Ok(response) = response else { + return ApiError::new(StatusCode::BAD_GATEWAY, "Preview unreachable").into_response(); + }; + + let status = response.status(); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .cloned() + .unwrap_or_else(|| HeaderValue::from_static("application/json")); + let Ok(text) = response.bytes().await else { + return ApiError::new(StatusCode::BAD_GATEWAY, "Preview unreachable").into_response(); + }; + + let mut res = Response::new(Body::from(text)); + *res.status_mut() = StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + res.headers_mut().insert(header::CONTENT_TYPE, content_type); + res +} + +struct Invoke { + resolve_type: String, + payload: Map, +} + +/// `{ __resolveType, ...props }` split into the single-invoke target Deco +/// expects. `None` for a missing or unsafe `__resolveType`. +fn parse_invoke(body: &Value) -> Option { + let object = body.as_object()?; + let resolve_type = object.get("__resolveType")?.as_str()?; + if !is_valid_resolve_type(resolve_type) { + return None; + } + let mut payload = object.clone(); + payload.remove("__resolveType"); + Some(Invoke { + resolve_type: resolve_type.to_string(), + payload, + }) +} + +/// `^[\w./@-]+$` plus no `..` — the port of `isValidLoaderResolveType`. +/// +/// This value goes RAW into a URL path, so the pattern is what stops it from +/// climbing out of `/deco/invoke/` or smuggling a query string. +fn is_valid_resolve_type(resolve_type: &str) -> bool { + !resolve_type.is_empty() + && !resolve_type.contains("..") + && resolve_type + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '@' | '-')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splits_the_resolve_type_from_its_props() { + let body = serde_json::json!({ + "__resolveType": "vtex/loaders/intelligentSearch/productList.ts", + "query": "shoes", + "count": 10, + }); + let parsed = parse_invoke(&body).expect("parsed"); + assert_eq!( + parsed.resolve_type, + "vtex/loaders/intelligentSearch/productList.ts" + ); + // `__resolveType` is the routing key, never a prop. + assert!(!parsed.payload.contains_key("__resolveType")); + assert_eq!(parsed.payload.get("query").unwrap(), "shoes"); + assert_eq!(parsed.payload.len(), 2); + } + + /// The resolveType is interpolated RAW into a URL path, so this is the + /// only thing standing between a caller and `/deco/invoke/../../…`. + #[test] + fn refuses_a_resolve_type_that_could_escape_the_invoke_path() { + for bad in [ + "../../etc/passwd", + "a/../../b", + "loader.ts?x=1", + "loader.ts#frag", + "with space.ts", + "back\\slash.ts", + "", + ] { + assert!( + parse_invoke(&serde_json::json!({ "__resolveType": bad })).is_none(), + "accepted {bad:?}" + ); + } + } + + #[test] + fn accepts_the_characters_real_resolve_types_use() { + for good in [ + "vtex/loaders/intelligentSearch/productList.ts", + "site/sections/Hero.tsx", + "@deco/std/loaders/x.ts", + "a-b_c.ts", + ] { + assert!( + parse_invoke(&serde_json::json!({ "__resolveType": good })).is_some(), + "rejected {good:?}" + ); + } + } + + #[test] + fn a_body_without_a_resolve_type_is_refused() { + assert!(parse_invoke(&serde_json::json!({ "query": "x" })).is_none()); + assert!(parse_invoke(&serde_json::json!([1, 2, 3])).is_none()); + assert!(parse_invoke(&serde_json::json!("nope")).is_none()); + assert!(parse_invoke(&serde_json::json!({ "__resolveType": 42 })).is_none()); + } +} diff --git a/apps/native/crates/local-api/src/routes/intercept/run_spool.rs b/apps/native/crates/local-api/src/routes/intercept/run_spool.rs new file mode 100644 index 0000000000..b82cea7bc3 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/intercept/run_spool.rs @@ -0,0 +1,751 @@ +//! Disk-backed, bounded replay for one native Decopilot SSE run. +//! +//! A [`RunSpool`] deliberately keeps no replay `Vec` in memory. Complete SSE +//! frames are appended to one per-run file and flushed before they enter the +//! bounded live broadcast channel. A late subscriber atomically takes a file +//! snapshot and subscribes to future frames under the same mutex used by +//! [`RunSpool::append`], so a frame can never fall into a snapshot/subscribe +//! gap (or appear in both halves of that handoff). +//! +//! Unlike the terminal [`crate::log_store::LogStore`], this store must never +//! rotate: removing the beginning of an SSE transcript can leave a reconnect +//! without its protocol prefix. Crossing the hard cap therefore returns +//! [`RunSpoolError::CapacityExceeded`] before either the file or live channel +//! changes. The caller can then cancel the harness and emit/record a bounded +//! terminal failure through its normal control path. + +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use bytes::Bytes; +use thiserror::Error; +use tokio::fs::{self, File, OpenOptions}; +use tokio::io::{AsyncReadExt, AsyncWriteExt, BufWriter}; +use tokio::sync::{broadcast, Mutex}; + +/// Maximum retained bytes for one run. The limit is intentionally hard: a +/// frame that would cross it is rejected whole, never rotated or truncated. +pub(crate) const DEFAULT_RUN_SPOOL_CAP_BYTES: u64 = 10 * 1024 * 1024; + +/// Only live fan-out occupies retained RAM. This is bounded independently of +/// the disk cap; a slow reader receives an explicit lag error and must reconnect +/// for a fresh atomic replay instead of silently skipping frames. +const DEFAULT_LIVE_CHANNEL_CAPACITY: usize = 1024; + +#[derive(Debug, Error)] +pub(crate) enum RunSpoolError { + #[error("run spool at {path:?} is finished")] + Finished { path: PathBuf }, + + #[error("run spool at {path:?} was removed")] + Removed { path: PathBuf }, + + #[error("run spool at {path:?} already has a staged frame")] + StagedFramePending { path: PathBuf }, + + #[error("run spool staged-frame token does not match at {path:?}")] + StagedFrameMismatch { path: PathBuf }, + + #[error( + "run spool capacity exceeded at {path:?}: {current_bytes} retained + \ + {frame_bytes} frame bytes exceeds {cap_bytes} byte cap" + )] + CapacityExceeded { + path: PathBuf, + cap_bytes: u64, + current_bytes: u64, + frame_bytes: usize, + }, + + #[error("run spool file already exceeds its cap at {path:?}: {current_bytes} > {cap_bytes}")] + ExistingFileExceedsCapacity { + path: PathBuf, + cap_bytes: u64, + current_bytes: u64, + }, + + #[error("run spool {operation} failed at {path:?}: {source}")] + Io { + operation: &'static str, + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +/// A live subscriber falling behind is a recoverable protocol event, not +/// permission to discard bytes. The subscription is invalidated after this +/// error; reconnecting through [`RunSpool::subscribe`] produces a new complete +/// disk replay followed by a fresh live receiver. +#[derive(Debug, Error, PartialEq, Eq)] +pub(crate) enum RunSpoolRecvError { + #[error("run spool live subscriber lagged by {skipped} frame(s); reconnect for replay")] + Lagged { skipped: u64 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + Active, + Finished, + Removed, +} + +struct Inner { + writer: Option>, + /// Physical bytes flushed to the file, including a staged terminal frame. + written: u64, + /// Prefix safe for replay. Equal to `written` except while a terminal frame + /// is staged across the SQLite finalization transaction. + visible: u64, + staged: Option, + sender: Option>, + state: State, +} + +struct PendingFrame { + start: u64, + frame: Bytes, +} + +/// Ownership token for one flushed-but-not-yet-visible terminal frame. +pub(crate) struct StagedFrame { + start: u64, + end: u64, +} + +/// One file-backed SSE transcript and its transient live fan-out. +pub(crate) struct RunSpool { + path: PathBuf, + cap_bytes: u64, + inner: Mutex, +} + +impl RunSpool { + /// Opens (or creates) a per-run spool at `path` with the production cap. + /// Existing bytes are retained and become replayable, which lets a caller + /// reconstruct a run registry after a process restart. + pub(crate) async fn open(path: PathBuf) -> Result, RunSpoolError> { + Self::open_with_limits( + path, + DEFAULT_RUN_SPOOL_CAP_BYTES, + DEFAULT_LIVE_CHANNEL_CAPACITY, + ) + .await + } + + #[cfg(test)] + pub(crate) fn path(&self) -> &Path { + &self.path + } + + #[cfg(test)] + pub(crate) async fn open_with_cap( + path: PathBuf, + cap_bytes: u64, + ) -> Result, RunSpoolError> { + Self::open_with_limits(path, cap_bytes, DEFAULT_LIVE_CHANNEL_CAPACITY).await + } + + #[cfg(test)] + pub(crate) async fn open_with_test_limits( + path: PathBuf, + cap_bytes: u64, + live_capacity: usize, + ) -> Result, RunSpoolError> { + Self::open_with_limits(path, cap_bytes, live_capacity).await + } + + async fn open_with_limits( + path: PathBuf, + cap_bytes: u64, + live_capacity: usize, + ) -> Result, RunSpoolError> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .await + .map_err(|source| Self::io("create parent directory", &path, source))?; + } + + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .await + .map_err(|source| Self::io("open", &path, source))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .await + .map_err(|source| Self::io("set private permissions", &path, source))?; + } + let written = file + .metadata() + .await + .map_err(|source| Self::io("read metadata", &path, source))? + .len(); + if written > cap_bytes { + return Err(RunSpoolError::ExistingFileExceedsCapacity { + path, + cap_bytes, + current_bytes: written, + }); + } + + // `tokio::sync::broadcast::channel(0)` panics. Tests inject a small + // positive capacity to exercise lag; production always uses 1024. + let (sender, _receiver) = broadcast::channel(live_capacity.max(1)); + Ok(Arc::new(Self { + path, + cap_bytes, + inner: Mutex::new(Inner { + writer: Some(BufWriter::new(file)), + written, + visible: written, + staged: None, + sender: Some(sender), + state: State::Active, + }), + })) + } + + /// Durably appends one complete SSE frame, then fans it out live. + /// + /// The file flush and broadcast happen while holding the same mutex used by + /// [`Self::subscribe`]. Therefore a subscriber observes the frame either in + /// its replay prefix or from its live receiver, exactly once at the handoff. + pub(crate) async fn append(&self, frame: Bytes) -> Result<(), RunSpoolError> { + // An empty body carries no SSE event and cannot be represented in a + // later byte replay. Treat it consistently as a no-op rather than + // delivering a live-only phantom frame. + if frame.is_empty() { + return Ok(()); + } + let mut inner = self.inner.lock().await; + match inner.state { + State::Finished => { + return Err(RunSpoolError::Finished { + path: self.path.clone(), + }); + } + State::Removed => { + return Err(RunSpoolError::Removed { + path: self.path.clone(), + }); + } + State::Active => {} + } + if inner.staged.is_some() { + return Err(RunSpoolError::StagedFramePending { + path: self.path.clone(), + }); + } + + let next_len = inner + .written + .checked_add(frame.len() as u64) + .filter(|next| *next <= self.cap_bytes) + .ok_or_else(|| RunSpoolError::CapacityExceeded { + path: self.path.clone(), + cap_bytes: self.cap_bytes, + current_bytes: inner.written, + frame_bytes: frame.len(), + })?; + + let writer = inner + .writer + .as_mut() + .expect("an active run spool always owns its writer"); + writer + .write_all(&frame) + .await + .map_err(|source| Self::io("append", &self.path, source))?; + writer + .flush() + .await + .map_err(|source| Self::io("flush", &self.path, source))?; + inner.written = next_len; + inner.visible = next_len; + + if let Some(sender) = &inner.sender { + // No receiver is ordinary: the durable file is the late-reader + // path. A lagged receiver is reported by `RunSubscription::recv`. + let _ = sender.send(frame); + } + Ok(()) + } + + /// Flushes a terminal frame without making it replayable or live yet. The + /// caller commits its SQLite terminal transaction, then calls + /// [`Self::commit_staged`]. If that transaction fails it must call + /// [`Self::rollback_staged`]. Only one frame may be staged at a time. + pub(crate) async fn stage(&self, frame: Bytes) -> Result { + let mut inner = self.inner.lock().await; + match inner.state { + State::Finished => { + return Err(RunSpoolError::Finished { + path: self.path.clone(), + }); + } + State::Removed => { + return Err(RunSpoolError::Removed { + path: self.path.clone(), + }); + } + State::Active => {} + } + if inner.staged.is_some() { + return Err(RunSpoolError::StagedFramePending { + path: self.path.clone(), + }); + } + let start = inner.written; + let end = start + .checked_add(frame.len() as u64) + .filter(|next| *next <= self.cap_bytes) + .ok_or_else(|| RunSpoolError::CapacityExceeded { + path: self.path.clone(), + cap_bytes: self.cap_bytes, + current_bytes: inner.written, + frame_bytes: frame.len(), + })?; + let writer = inner + .writer + .as_mut() + .expect("an active run spool always owns its writer"); + writer + .write_all(&frame) + .await + .map_err(|source| Self::io("stage", &self.path, source))?; + writer + .flush() + .await + .map_err(|source| Self::io("stage flush", &self.path, source))?; + inner.written = end; + inner.staged = Some(PendingFrame { start, frame }); + Ok(StagedFrame { start, end }) + } + + /// Makes a previously flushed terminal frame atomically visible to replay + /// and live subscribers. No filesystem I/O remains at this point, so once + /// the SQLite terminal transaction commits this step cannot fail for disk + /// or capacity reasons. + pub(crate) async fn commit_staged(&self, staged: StagedFrame) -> Result<(), RunSpoolError> { + let mut inner = self.inner.lock().await; + let matches = inner + .staged + .as_ref() + .is_some_and(|pending| pending.start == staged.start && inner.written == staged.end); + if !matches { + return Err(RunSpoolError::StagedFrameMismatch { + path: self.path.clone(), + }); + } + let pending = inner.staged.take().expect("staged frame checked above"); + inner.visible = inner.written; + if let Some(sender) = &inner.sender { + let _ = sender.send(pending.frame); + } + Ok(()) + } + + /// Discards a staged frame after SQLite terminal finalization failed. The + /// visible replay prefix never included these bytes. + pub(crate) async fn rollback_staged(&self, staged: StagedFrame) -> Result<(), RunSpoolError> { + let mut inner = self.inner.lock().await; + let matches = inner + .staged + .as_ref() + .is_some_and(|pending| pending.start == staged.start && inner.written == staged.end); + if !matches { + return Err(RunSpoolError::StagedFrameMismatch { + path: self.path.clone(), + }); + } + let writer = inner + .writer + .as_mut() + .expect("an active run spool always owns its writer"); + writer + .flush() + .await + .map_err(|source| Self::io("rollback flush", &self.path, source))?; + writer + .get_ref() + .set_len(staged.start) + .await + .map_err(|source| Self::io("rollback truncate", &self.path, source))?; + inner.written = staged.start; + inner.visible = staged.start; + inner.staged = None; + Ok(()) + } + + /// Atomically captures every retained byte and subscribes to future frames. + /// The returned replay is transient request memory, not retained run state. + pub(crate) async fn subscribe(&self) -> Result { + let inner = self.inner.lock().await; + if inner.state == State::Removed { + return Err(RunSpoolError::Removed { + path: self.path.clone(), + }); + } + + // Subscribe BEFORE reading, while append is excluded by this mutex. + // Once the mutex is released every subsequent append is live-only; + // everything before it is bounded by `written` and read below. + let receiver = inner.sender.as_ref().map(|sender| sender.subscribe()); + let replay_len = usize::try_from(inner.visible).map_err(|source| { + Self::io( + "size replay buffer", + &self.path, + std::io::Error::new(ErrorKind::InvalidData, source), + ) + })?; + let mut replay = vec![0; replay_len]; + if replay_len > 0 { + let mut reader = File::open(&self.path) + .await + .map_err(|source| Self::io("open replay", &self.path, source))?; + reader + .read_exact(&mut replay) + .await + .map_err(|source| Self::io("read replay", &self.path, source))?; + } + + Ok(RunSubscription { + replay: Bytes::from(replay), + receiver, + }) + } + + /// Ends live delivery after already-buffered receiver frames drain. The + /// file remains available for late replay until [`Self::remove`] is called. + pub(crate) async fn finish(&self) -> Result<(), RunSpoolError> { + let mut inner = self.inner.lock().await; + match inner.state { + State::Removed => { + return Err(RunSpoolError::Removed { + path: self.path.clone(), + }); + } + State::Finished => return Ok(()), + State::Active => {} + } + if inner.staged.is_some() { + return Err(RunSpoolError::StagedFramePending { + path: self.path.clone(), + }); + } + if let Some(writer) = inner.writer.as_mut() { + writer + .flush() + .await + .map_err(|source| Self::io("finish flush", &self.path, source))?; + } + inner.sender.take(); + inner.state = State::Finished; + Ok(()) + } + + /// Closes the writer and asynchronously deletes the retained transcript. + /// Idempotent after a successful removal. + pub(crate) async fn remove(&self) -> Result<(), RunSpoolError> { + let mut inner = self.inner.lock().await; + if inner.state == State::Removed { + return Ok(()); + } + if let Some(mut writer) = inner.writer.take() { + writer + .flush() + .await + .map_err(|source| Self::io("cleanup flush", &self.path, source))?; + } + inner.sender.take(); + inner.state = State::Finished; + match fs::remove_file(&self.path).await { + Ok(()) => {} + Err(source) if source.kind() == ErrorKind::NotFound => {} + Err(source) => return Err(Self::io("remove", &self.path, source)), + } + inner.state = State::Removed; + inner.written = 0; + inner.visible = 0; + inner.staged = None; + Ok(()) + } + + fn io(operation: &'static str, path: &Path, source: std::io::Error) -> RunSpoolError { + RunSpoolError::Io { + operation, + path: path.to_path_buf(), + source, + } + } +} + +/// Atomic replay prefix plus the live continuation for one connection. +pub(crate) struct RunSubscription { + replay: Bytes, + receiver: Option>, +} + +impl RunSubscription { + pub(crate) fn take_replay(&mut self) -> Bytes { + std::mem::take(&mut self.replay) + } + + /// Receives the next live frame. `Ok(None)` means the producer finished. + /// A lag error invalidates this subscription so consumers cannot + /// accidentally continue after silently losing frames. + pub(crate) async fn recv(&mut self) -> Result, RunSpoolRecvError> { + let Some(receiver) = self.receiver.as_mut() else { + return Ok(None); + }; + match receiver.recv().await { + Ok(frame) => Ok(Some(frame)), + Err(broadcast::error::RecvError::Closed) => { + self.receiver = None; + Ok(None) + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + self.receiver = None; + Err(RunSpoolRecvError::Lagged { skipped }) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::Barrier; + + async fn spool(dir: &tempfile::TempDir, cap: u64, live_capacity: usize) -> Arc { + RunSpool::open_with_limits(dir.path().join("run.sse"), cap, live_capacity) + .await + .unwrap() + } + + #[tokio::test] + async fn late_subscriber_replays_the_complete_finished_transcript() { + let dir = tempfile::tempdir().unwrap(); + let spool = spool(&dir, DEFAULT_RUN_SPOOL_CAP_BYTES, 8).await; + spool + .append(Bytes::from_static(b"event: message\ndata: one\n\n")) + .await + .unwrap(); + spool + .append(Bytes::from_static(b"event: message\ndata: two\n\n")) + .await + .unwrap(); + spool.finish().await.unwrap(); + + let mut subscription = spool.subscribe().await.unwrap(); + assert_eq!( + subscription.take_replay(), + Bytes::from_static(b"event: message\ndata: one\n\nevent: message\ndata: two\n\n") + ); + assert_eq!(subscription.recv().await.unwrap(), None); + } + + #[tokio::test] + async fn concurrent_snapshot_boundary_has_no_loss_or_duplication() { + // Exercise both legal mutex orderings repeatedly: if append wins, B is + // in replay; if subscribe wins, B is live. It must never be in neither + // or both. + for iteration in 0..64 { + let dir = tempfile::tempdir().unwrap(); + let spool = spool(&dir, DEFAULT_RUN_SPOOL_CAP_BYTES, 8).await; + spool.append(Bytes::from_static(b"A")).await.unwrap(); + let barrier = Arc::new(Barrier::new(3)); + + let subscribe_spool = spool.clone(); + let subscribe_barrier = barrier.clone(); + let subscribe = tokio::spawn(async move { + subscribe_barrier.wait().await; + subscribe_spool.subscribe().await.unwrap() + }); + let append_spool = spool.clone(); + let append_barrier = barrier.clone(); + let append = tokio::spawn(async move { + append_barrier.wait().await; + append_spool.append(Bytes::from_static(b"B")).await.unwrap(); + }); + + barrier.wait().await; + let (subscription, append) = tokio::join!(subscribe, append); + let mut subscription = subscription.unwrap(); + append.unwrap(); + spool.finish().await.unwrap(); + + let mut observed = subscription.take_replay().to_vec(); + while let Some(frame) = subscription.recv().await.unwrap() { + observed.extend_from_slice(&frame); + } + assert_eq!(observed, b"AB", "failed at iteration {iteration}"); + } + } + + #[tokio::test] + async fn cap_rejects_the_whole_frame_without_changing_replay_or_live() { + let dir = tempfile::tempdir().unwrap(); + let spool = spool(&dir, 4, 8).await; + let mut subscription = spool.subscribe().await.unwrap(); + spool.append(Bytes::from_static(b"1234")).await.unwrap(); + let error = spool.append(Bytes::from_static(b"5")).await.unwrap_err(); + assert!(matches!( + error, + RunSpoolError::CapacityExceeded { + cap_bytes: 4, + current_bytes: 4, + frame_bytes: 1, + .. + } + )); + spool.finish().await.unwrap(); + + assert_eq!(subscription.take_replay(), Bytes::new()); + assert_eq!( + subscription.recv().await.unwrap(), + Some(Bytes::from_static(b"1234")) + ); + assert_eq!(subscription.recv().await.unwrap(), None); + let mut late = spool.subscribe().await.unwrap(); + assert_eq!(late.take_replay(), Bytes::from_static(b"1234")); + assert_eq!(late.recv().await.unwrap(), None); + } + + #[tokio::test] + async fn live_delivery_only_happens_after_the_frame_is_flushed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("run.sse"); + let spool = RunSpool::open_with_limits(path.clone(), 1024, 8) + .await + .unwrap(); + let mut subscription = spool.subscribe().await.unwrap(); + spool.append(Bytes::from_static(b"durable")).await.unwrap(); + assert_eq!( + subscription.recv().await.unwrap(), + Some(Bytes::from_static(b"durable")) + ); + assert_eq!(fs::read(path).await.unwrap(), b"durable"); + } + + #[tokio::test] + async fn staged_terminal_is_hidden_until_commit_then_replayable_and_live() { + let dir = tempfile::tempdir().unwrap(); + let spool = spool(&dir, 1024, 8).await; + spool.append(Bytes::from_static(b"prefix")).await.unwrap(); + + let staged = spool.stage(Bytes::from_static(b"terminal")).await.unwrap(); + let mut during = spool.subscribe().await.unwrap(); + assert_eq!(during.take_replay(), Bytes::from_static(b"prefix")); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(10), during.recv()) + .await + .is_err() + ); + + spool.commit_staged(staged).await.unwrap(); + assert_eq!( + during.recv().await.unwrap(), + Some(Bytes::from_static(b"terminal")) + ); + spool.finish().await.unwrap(); + + let mut late = spool.subscribe().await.unwrap(); + assert_eq!(late.take_replay(), Bytes::from_static(b"prefixterminal")); + assert_eq!(late.recv().await.unwrap(), None); + } + + #[tokio::test] + async fn rollback_discards_hidden_terminal_and_allows_a_new_append() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("run.sse"); + let spool = RunSpool::open_with_limits(path.clone(), 1024, 8) + .await + .unwrap(); + spool.append(Bytes::from_static(b"prefix")).await.unwrap(); + + let staged = spool.stage(Bytes::from_static(b"discarded")).await.unwrap(); + spool.rollback_staged(staged).await.unwrap(); + spool + .append(Bytes::from_static(b"replacement")) + .await + .unwrap(); + spool.finish().await.unwrap(); + + assert_eq!(fs::read(path).await.unwrap(), b"prefixreplacement"); + let mut late = spool.subscribe().await.unwrap(); + assert_eq!(late.take_replay(), Bytes::from_static(b"prefixreplacement")); + assert_eq!(late.recv().await.unwrap(), None); + } + + #[tokio::test] + async fn cleanup_removes_the_file_and_blocks_future_use() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested/run.sse"); + let spool = RunSpool::open_with_limits(path.clone(), 1024, 8) + .await + .unwrap(); + spool.append(Bytes::from_static(b"frame")).await.unwrap(); + + spool.remove().await.unwrap(); + assert_eq!( + fs::metadata(&path).await.unwrap_err().kind(), + ErrorKind::NotFound + ); + assert!(matches!( + spool.append(Bytes::from_static(b"late")).await, + Err(RunSpoolError::Removed { .. }) + )); + assert!(matches!( + spool.subscribe().await, + Err(RunSpoolError::Removed { .. }) + )); + spool.remove().await.unwrap(); + } + + #[tokio::test] + async fn broadcast_lag_is_explicit_and_invalidates_the_subscription() { + let dir = tempfile::tempdir().unwrap(); + let spool = spool(&dir, 1024, 1).await; + let mut subscription = spool.subscribe().await.unwrap(); + spool.append(Bytes::from_static(b"one")).await.unwrap(); + spool.append(Bytes::from_static(b"two")).await.unwrap(); + + assert_eq!( + subscription.recv().await.unwrap_err(), + RunSpoolRecvError::Lagged { skipped: 1 } + ); + assert_eq!(subscription.recv().await.unwrap(), None); + + let mut reconnected = spool.subscribe().await.unwrap(); + assert_eq!(reconnected.take_replay(), Bytes::from_static(b"onetwo")); + } + + #[tokio::test] + async fn reopening_preserves_a_previous_process_transcript() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("run.sse"); + let first = RunSpool::open_with_limits(path.clone(), 1024, 8) + .await + .unwrap(); + first + .append(Bytes::from_static(b"persisted")) + .await + .unwrap(); + first.finish().await.unwrap(); + drop(first); + + let reopened = RunSpool::open_with_limits(path, 1024, 8).await.unwrap(); + let mut subscription = reopened.subscribe().await.unwrap(); + assert_eq!(subscription.take_replay(), Bytes::from_static(b"persisted")); + } +} diff --git a/apps/native/crates/local-api/src/routes/intercept/sandbox_events.rs b/apps/native/crates/local-api/src/routes/intercept/sandbox_events.rs new file mode 100644 index 0000000000..cb0ff39a06 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/intercept/sandbox_events.rs @@ -0,0 +1,170 @@ +//! Native interception for the production shell's sandbox event stream. +//! +//! `GET /api/:org/sandbox/:virtualMcpId/:branch/events` is the single SSE +//! connection the real web shell opens per sandbox +//! (`sandbox-events-context.tsx`). In the browser studio serves it: it emits +//! `event: phase` for the pre-Ready claim lifecycle, then passes the in-pod +//! daemon's own `log|lifecycle|status|tasks|scripts|branch|reload` frames +//! straight through, plus a synthetic `event: gone` when the sandbox handle has +//! disappeared. +//! +//! Inside the desktop app there is no pod and no claim: this process owns the +//! sandbox, and [`crate::routes::events::events`] already emits exactly that +//! daemon frame vocabulary for a given handle (`?handle=`, which metadata- +//! adopts the sandbox and watches its process generation). So this intercept is +//! composition, not a reimplementation — resolve `(virtualMcpId, branch)` to a +//! handle and hand the request to that same stream. +//! +//! No `phase` frames are emitted. They describe cloud claim states (capacity +//! wait, image pull) that have no local analogue; the shell treats a null phase +//! as "no claim information", which is the truth here. Local provisioning +//! progress already reaches the UI through the `lifecycle` frames the setup +//! pipeline emits. +//! +//! ## Why an unknown handle is a `gone` FRAME, not a 404 +//! +//! `preview.tsx` self-heals an evicted sandbox by turning `event: gone` into a +//! fresh `SANDBOX_START`. That recovery only triggers from a frame on an open +//! stream — an HTTP 404 just makes `EventSource` retry the connection forever, +//! so the sandbox would never come back. `events()`'s own unknown-handle branch +//! answers 404 (correct for its daemon-parity callers), so this path checks the +//! registry first and answers with the frame the shell is waiting for. + +use axum::body::Body; +use axum::extract::{Query, State}; +use axum::http::{header, Method, StatusCode}; +use axum::response::{IntoResponse, Response}; + +use crate::error::ApiError; +use crate::routes::events::{events, EventsQuery}; +use crate::sandbox::SandboxManager; +use crate::state::AppState; + +use super::sandbox_fs::decode_identity_segment; + +/// One `event: gone` frame on a well-formed SSE response. +fn gone_stream() -> Response { + match Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/event-stream") + .header(header::CACHE_CONTROL, "no-cache") + .header(header::CONNECTION, "keep-alive") + .header("X-Accel-Buffering", "no") + .header(header::CONTENT_ENCODING, "identity") + .body(Body::from("event: gone\ndata: {}\n\n")) + { + Ok(response) => response, + Err(error) => { + tracing::error!(%error, "failed to build sandbox gone stream"); + ApiError::internal("failed to build sandbox event stream").into_response() + } + } +} + +pub(super) async fn try_dispatch( + state: &AppState, + method: &Method, + rest: &[&str], +) -> Option { + let ["sandbox", encoded_virtual_mcp_id, encoded_branch, "events"] = rest else { + return None; + }; + if *method != Method::GET { + return Some( + ApiError::new( + StatusCode::METHOD_NOT_ALLOWED, + format!("method not allowed for sandbox events: {method}"), + ) + .into_response(), + ); + } + + let virtual_mcp_id = match decode_identity_segment("virtualMcpId", encoded_virtual_mcp_id) { + Ok(value) => value, + Err(error) => return Some(error.into_response()), + }; + let branch = match decode_identity_segment("branch", encoded_branch) { + Ok(value) => value, + Err(error) => return Some(error.into_response()), + }; + + let handle = SandboxManager::compute_handle(&virtual_mcp_id, &branch); + match state.sandbox_manager.is_registered(&handle) { + Ok(true) => {} + // Never provisioned, or reaped: tell the shell so it can re-start it. + Ok(false) => return Some(gone_stream()), + Err(error) => return Some(ApiError::internal(error).into_response()), + } + + Some( + events( + State(state.clone()), + Query(EventsQuery { + handle: Some(handle), + }), + ) + .await, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn get() -> Method { + Method::GET + } + + #[tokio::test] + async fn ignores_paths_that_are_not_the_events_stream() { + let root = tempfile::tempdir().expect("tempdir"); + let state = super::super::test_state(root.path()); + + // Filesystem operations belong to `sandbox_fs`, not here. + assert!( + try_dispatch(&state, &get(), &["sandbox", "vm", "main", "read"]) + .await + .is_none() + ); + // A different family entirely. + assert!(try_dispatch(&state, &get(), &["tools", "SANDBOX_START"]) + .await + .is_none()); + } + + #[tokio::test] + async fn unknown_handle_answers_a_gone_frame_not_a_404() { + let root = tempfile::tempdir().expect("tempdir"); + let state = super::super::test_state(root.path()); + + let response = try_dispatch(&state, &get(), &["sandbox", "vm", "main", "events"]) + .await + .expect("the events path is intercepted"); + + // A 404 would leave EventSource retrying forever; the shell needs the + // frame to run its self-heal SANDBOX_START. + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/event-stream") + ); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .expect("body"); + assert_eq!(String::from_utf8_lossy(&body), "event: gone\ndata: {}\n\n"); + } + + #[tokio::test] + async fn rejects_non_get_methods() { + let root = tempfile::tempdir().expect("tempdir"); + let state = super::super::test_state(root.path()); + + let response = try_dispatch(&state, &Method::POST, &["sandbox", "vm", "main", "events"]) + .await + .expect("still intercepted, so it cannot silently proxy upstream"); + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + } +} diff --git a/apps/native/crates/local-api/src/routes/intercept/sandbox_fs.rs b/apps/native/crates/local-api/src/routes/intercept/sandbox_fs.rs new file mode 100644 index 0000000000..311cbb73bc --- /dev/null +++ b/apps/native/crates/local-api/src/routes/intercept/sandbox_fs.rs @@ -0,0 +1,411 @@ +//! Native interception for the production shell's org-scoped sandbox +//! filesystem routes. +//! +//! The shared web UI addresses filesystem operations through +//! `/api/:org/sandbox/:virtualMcpId/:branch/:operation`. In the browser that +//! route belongs to mesh, which resolves a hosted or `deco link` sandbox. In +//! the native app the same request first reaches local-api's app-API fallback; +//! forwarding it upstream would ask the cluster for a claim that cannot see +//! this Mac's durable sandbox registry. +//! +//! This module terminates the file family locally. Identity comes only from +//! the URL's `(virtualMcpId, branch)` pair: compute the native handle, look it +//! up in SQLite, and metadata-adopt its worktree after a process restart. An +//! unknown identity never falls back to the active or process-global sandbox, +//! because a filesystem mutation against the wrong chat is worse than a loud +//! 404. + +use std::path::Path; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::{Method, StatusCode}; +use axum::response::{IntoResponse, Response}; + +use crate::error::ApiError; +use crate::sandbox::manager::Sandbox; +use crate::state::AppState; + +const OPERATIONS: &[&str] = &["read", "write", "unlink", "mkdir", "rename", "glob", "grep"]; + +pub(super) async fn try_dispatch( + state: &AppState, + method: &Method, + rest: &[&str], + body: &Bytes, +) -> Option { + let ["sandbox", encoded_virtual_mcp_id, encoded_branch, operation] = rest else { + return None; + }; + if !OPERATIONS.contains(operation) { + return None; + } + if *method != Method::POST { + return Some( + ApiError::new( + StatusCode::METHOD_NOT_ALLOWED, + format!("method not allowed for sandbox filesystem operation: {method}"), + ) + .into_response(), + ); + } + + let virtual_mcp_id = match decode_identity_segment("virtualMcpId", encoded_virtual_mcp_id) { + Ok(value) => value, + Err(error) => return Some(error.into_response()), + }; + let branch = match decode_identity_segment("branch", encoded_branch) { + Ok(value) => value, + Err(error) => return Some(error.into_response()), + }; + if *operation == "read" { + if let Some(error) = reject_absolute_read(body) { + return Some(error.into_response()); + } + } + let handle = crate::sandbox::SandboxManager::compute_handle(&virtual_mcp_id, &branch); + + let record = match state.sandbox_manager.registry_record(&handle) { + Ok(Some(record)) => record, + Ok(None) => { + return Some( + ApiError::not_found(format!("sandbox not found: {handle}")).into_response(), + ) + } + Err(error) => return Some(ApiError::internal(error).into_response()), + }; + if record.observed_status == "absent" + || !crate::routes::git::is_git_repo(&record.workdir_path).await + { + return Some( + ApiError::not_found(format!("sandbox worktree not found: {handle}")).into_response(), + ); + } + + let sandbox = match state.sandbox_manager.adopt(&handle).await { + Ok(Some(sandbox)) => sandbox, + Ok(None) => { + return Some( + ApiError::not_found(format!("sandbox not found: {handle}")).into_response(), + ) + } + Err(error) => { + return Some( + ApiError::internal(format!("failed to adopt sandbox {handle}: {error}")) + .into_response(), + ) + } + }; + let target_state = state_for_sandbox(state, &sandbox); + let body = body.clone(); + + Some(match *operation { + "read" => crate::routes::fs::read(State(target_state), body).await, + "write" => crate::routes::fs::write(State(target_state), body).await, + "unlink" => crate::routes::fs::unlink(State(target_state), body).await, + "mkdir" => crate::routes::fs::mkdir(State(target_state), body).await, + "rename" => crate::routes::fs::rename(State(target_state), body).await, + "glob" => crate::routes::fs::glob(State(target_state), body).await, + "grep" => crate::routes::fs::grep(State(target_state), body).await, + _ => unreachable!("operation was checked against OPERATIONS"), + }) +} + +pub(super) fn decode_identity_segment(label: &str, encoded: &str) -> Result { + let decoded = urlencoding::decode(encoded) + .map_err(|error| ApiError::bad_request(format!("invalid {label}: {error}")))?; + if decoded.is_empty() { + return Err(ApiError::bad_request(format!("{label} is required"))); + } + Ok(decoded.into_owned()) +} + +fn reject_absolute_read(body: &Bytes) -> Option { + let value: serde_json::Value = serde_json::from_slice(body).ok()?; + let path = value.get("path")?.as_str()?; + if Path::new(path).is_absolute() { + return Some(ApiError::bad_request( + "absolute paths are not allowed through the native sandbox bridge", + )); + } + None +} + +pub(super) fn state_for_sandbox(state: &AppState, sandbox: &Sandbox) -> AppState { + let mut target = state.clone(); + // Keep daemon-compat `/_sandbox/*` semantics unchanged, but narrow the + // production-shell bridge to this handle's own managed root. Leaving the + // process-wide app root here would let `../..//repo/...` + // pass the lexical clamp in `fs::safe_path`. + target.app_root = sandbox + .workdir + .parent() + .unwrap_or(&sandbox.workdir) + .to_path_buf(); + target.repo_dir = sandbox.workdir.clone(); + target.config = sandbox.config.clone(); + target.tasks = sandbox.tasks.clone(); + target.broadcaster = sandbox.broadcaster.clone(); + target.setup = sandbox.setup.clone(); + target +} + +#[cfg(test)] +mod tests { + use axum::body::to_bytes; + use serde_json::{json, Value}; + + use super::*; + use crate::sandbox::GitSandboxConfig; + + fn git(dir: &Path, args: &[&str]) { + let output = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git must spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + async fn ensure_sandbox( + state: &AppState, + virtual_mcp_id: &str, + branch: &str, + ) -> std::sync::Arc { + let source = tempfile::tempdir().expect("source tempdir"); + let bare = source.path().join("origin.git"); + let author = source.path().join("author"); + std::fs::create_dir_all(&bare).unwrap(); + std::fs::create_dir_all(&author).unwrap(); + git(&bare, &["init", "--bare", "-q"]); + git(&author, &["init", "-q", "-b", "main"]); + git(&author, &["config", "user.name", "Native Test"]); + git(&author, &["config", "user.email", "native@example.com"]); + std::fs::write(author.join("README.md"), "fixture\n").unwrap(); + git(&author, &["add", "."]); + git(&author, &["commit", "-q", "-m", "initial"]); + if branch != "main" { + git(&author, &["checkout", "-q", "-b", branch]); + } + let bare_string = bare.to_string_lossy().into_owned(); + git(&author, &["remote", "add", "origin", &bare_string]); + git(&author, &["push", "-q", "-u", "origin", branch]); + if branch == "main" { + git(&bare, &["symbolic-ref", "HEAD", "refs/heads/main"]); + } + + state + .sandbox_manager + .ensure(&GitSandboxConfig { + virtual_mcp_id: virtual_mcp_id.to_string(), + clone_url: bare_string, + branch: Some(branch.to_string()), + ..Default::default() + }) + .await + .expect("sandbox ensure") + } + + async fn response_json(response: Response) -> Value { + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body"); + serde_json::from_slice(&bytes).expect("JSON response") + } + + #[tokio::test] + async fn encoded_identity_writes_only_to_the_exact_sandbox_worktree() { + let root = tempfile::tempdir().unwrap(); + let global_repo = root.path().join("repo"); + std::fs::create_dir_all(&global_repo).unwrap(); + let state = super::super::test_state(root.path()); + let sandbox = ensure_sandbox(&state, "vir_blocks", "feature/blocks").await; + + let response = try_dispatch( + &state, + &Method::POST, + &["sandbox", "vir_blocks", "feature%2Fblocks", "write"], + &Bytes::from( + serde_json::to_vec(&json!({ + "path": ".deco/blocks/home.json", + "content": "{\"name\":\"Home\"}" + })) + .unwrap(), + ), + ) + .await + .expect("filesystem route is intercepted"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + std::fs::read_to_string(sandbox.workdir.join(".deco/blocks/home.json")).unwrap(), + "{\"name\":\"Home\"}" + ); + assert!(!global_repo.join(".deco/blocks/home.json").exists()); + } + + #[tokio::test] + async fn persisted_sandbox_is_metadata_adopted_for_writes_after_restart() { + let root = tempfile::tempdir().unwrap(); + let first_state = super::super::test_state(root.path()); + let sandbox = ensure_sandbox(&first_state, "vir_restart", "main").await; + let workdir = sandbox.workdir.clone(); + let handle = sandbox.handle.clone(); + first_state + .sandbox_manager + .stop_registered(&handle) + .await + .expect("stop registered sandbox"); + drop(sandbox); + drop(first_state); + + let restarted_state = super::super::test_state(root.path()); + assert!( + restarted_state.sandbox_manager.get(&handle).is_none(), + "fresh manager starts with an empty live-object cache" + ); + let response = try_dispatch( + &restarted_state, + &Method::POST, + &["sandbox", "vir_restart", "main", "write"], + &Bytes::from( + serde_json::to_vec(&json!({ + "path": ".deco/blocks/restarted.json", + "content": "{\"survived\":true}" + })) + .unwrap(), + ), + ) + .await + .expect("filesystem route is intercepted"); + + assert_eq!(response.status(), StatusCode::OK); + assert!( + restarted_state.sandbox_manager.get(&handle).is_some(), + "the durable sandbox was metadata-adopted" + ); + assert_eq!( + std::fs::read_to_string(workdir.join(".deco/blocks/restarted.json")).unwrap(), + "{\"survived\":true}" + ); + } + + #[tokio::test] + async fn unknown_identity_is_a_404_and_never_falls_back_to_global_repo() { + let root = tempfile::tempdir().unwrap(); + let global_repo = root.path().join("repo"); + std::fs::create_dir_all(&global_repo).unwrap(); + let state = super::super::test_state(root.path()); + + let response = try_dispatch( + &state, + &Method::POST, + &["sandbox", "never_seen", "main", "write"], + &Bytes::from( + serde_json::to_vec(&json!({ + "path": "wrong-target.json", + "content": "must not be written" + })) + .unwrap(), + ), + ) + .await + .expect("filesystem route is intercepted"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(response_json(response).await["error"] + .as_str() + .is_some_and(|message| message.starts_with("sandbox not found:"))); + assert!(!global_repo.join("wrong-target.json").exists()); + } + + #[tokio::test] + async fn traversal_cannot_cross_into_a_sibling_sandbox() { + let root = tempfile::tempdir().unwrap(); + let state = super::super::test_state(root.path()); + let first = ensure_sandbox(&state, "vir_first", "main").await; + let second = ensure_sandbox(&state, "vir_second", "main").await; + let protected = second.workdir.join("protected.json"); + std::fs::write(&protected, "original").unwrap(); + let cross_sandbox_path = format!("../../{}/repo/protected.json", second.handle); + + let response = try_dispatch( + &state, + &Method::POST, + &["sandbox", "vir_first", "main", "write"], + &Bytes::from( + serde_json::to_vec(&json!({ + "path": cross_sandbox_path, + "content": "overwritten" + })) + .unwrap(), + ), + ) + .await + .expect("filesystem route is intercepted"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(std::fs::read_to_string(protected).unwrap(), "original"); + assert!(!first.workdir.join("protected.json").exists()); + } + + #[tokio::test] + async fn bridge_rejects_absolute_reads_without_changing_daemon_compat_route() { + let root = tempfile::tempdir().unwrap(); + let global_repo = root.path().join("repo"); + std::fs::create_dir_all(&global_repo).unwrap(); + let secret = root.path().join("host-secret.txt"); + std::fs::write(&secret, "host secret").unwrap(); + let state = super::super::test_state(root.path()); + let _sandbox = ensure_sandbox(&state, "vir_read", "main").await; + let body = Bytes::from( + serde_json::to_vec(&json!({ + "path": secret.to_string_lossy(), + "full": true + })) + .unwrap(), + ); + + let bridged = try_dispatch( + &state, + &Method::POST, + &["sandbox", "vir_read", "main", "read"], + &body, + ) + .await + .expect("filesystem route is intercepted"); + assert_eq!(bridged.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response_json(bridged).await["error"], + "absolute paths are not allowed through the native sandbox bridge" + ); + + // The direct daemon-parity endpoint still uses fs::resolve_read_path's + // absolute-path contract. Only the production-shell bridge is tighter. + let direct = crate::routes::fs::read(State(state), body).await; + assert_eq!(direct.status(), StatusCode::OK); + assert!(response_json(direct).await["content"] + .as_str() + .is_some_and(|content| content.contains("host secret"))); + } + + #[tokio::test] + async fn recognized_filesystem_operation_rejects_the_wrong_method_locally() { + let root = tempfile::tempdir().unwrap(); + let state = super::super::test_state(root.path()); + let response = try_dispatch( + &state, + &Method::GET, + &["sandbox", "vir_blocks", "main", "write"], + &Bytes::new(), + ) + .await + .expect("filesystem route is intercepted"); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + } +} diff --git a/apps/native/crates/local-api/src/routes/intercept/sandbox_lifecycle.rs b/apps/native/crates/local-api/src/routes/intercept/sandbox_lifecycle.rs new file mode 100644 index 0000000000..ff2fa2ee7a --- /dev/null +++ b/apps/native/crates/local-api/src/routes/intercept/sandbox_lifecycle.rs @@ -0,0 +1,616 @@ +//! Native interception for the production shell's sandbox lifecycle tools. +//! +//! `POST /api/:org/tools/SANDBOX_START` is what the real web shell calls to +//! provision the sandbox behind a thread. In the browser that reaches mesh, +//! which provisions either a cloud `agent-sandbox` or — for a `bunx decocms +//! link` user — the `user-desktop` runtime on their machine. Inside the desktop +//! app THIS process is that machine, so the same call has to resolve against +//! the local [`SandboxManager`] instead of a cluster. +//! +//! The frontend does not tell us which to use, and deliberately so: the desktop +//! build defaults its agent option to a local CLI, which pins +//! `sandbox_provider_kind = "user-desktop"` through `AGENT_OPTION_PINS`, and +//! `local-api` answers every request from the webview anyway. So this module +//! answers unconditionally and reports `user-desktop` back — the value is +//! honest ("runs on the user's machine"); only the transport differs from the +//! link daemon, and the shell cannot tell the difference. +//! +//! ## Where the git config comes from +//! +//! [`SandboxManager::ensure`] needs a [`GitSandboxConfig`] — clone URL, branch, +//! runtime. That used to ride along on the chat dispatch payload (the web +//! bundle's `build-sandbox-block.ts`), which meant the frontend carried desktop +//! -specific plumbing. It no longer does, so we resolve it here from the +//! cluster-side virtual-MCP registry via [`upstream::call_org_tool`] — the one +//! deliberate upstream read in an otherwise strictly-local table (see that +//! function's doc comment). Field mapping mirrors what the dispatch block used +//! to send, so the resulting sandbox is byte-identical to the one the old path +//! produced. + +use std::sync::atomic::{AtomicU16, Ordering}; + +use axum::body::Bytes; +use axum::http::{Method, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::{json, Value}; + +use crate::error::ApiError; +use crate::routes::upstream; +use crate::sandbox::GitSandboxConfig; +use crate::state::AppState; + +/// The preview listener's port, published by [`crate::start`] once it binds. +/// +/// A process global rather than an `AppState` field because the preview +/// listener binds AFTER the state is constructed, and because one process +/// serves exactly one preview listener — the same reasoning behind +/// `upstream::global()`. `0` means "not bound yet", which reports no preview +/// URL instead of a wrong one. +static PREVIEW_PORT: AtomicU16 = AtomicU16::new(0); + +pub(crate) fn set_preview_port(port: u16) { + PREVIEW_PORT.store(port, Ordering::Relaxed); +} + +/// `SANDBOX_START`'s `previewUrl`: the local preview listener, which proxies to +/// whichever sandbox is active. `None` before the listener binds. +fn preview_url() -> Option { + match PREVIEW_PORT.load(Ordering::Relaxed) { + 0 => None, + port => Some(format!("http://localhost:{port}/")), + } +} + +/// Mirrors `resolveDesktopSandboxBranch` in the retired dispatch block, and +/// `SandboxManager::ensure`'s own missing/empty-branch normalization. +const DEFAULT_BRANCH: &str = "main"; + +/// `metadata.runtime.selected` names a package manager; `application.runtime` +/// wants the JS runtime that drives it. Byte-parity with +/// `packages/shared/src/runtime-defaults.ts::PACKAGE_MANAGER_CONFIG[pm].runtime`. +fn runtime_for_package_manager(package_manager: &str) -> Option<&'static str> { + match package_manager { + "npm" | "pnpm" | "yarn" => Some("node"), + "bun" => Some("bun"), + "deno" => Some("deno"), + _ => None, + } +} + +/// Build the sandbox config from a virtual MCP's `metadata`, or `None` when it +/// has no clonable git repo attached (the plain-directory case — there is +/// nothing to provision a git sandbox from). +/// +/// Pure so the mapping is unit-testable without an upstream or a filesystem, +/// per TESTING.md. +pub(crate) fn config_from_virtual_mcp( + virtual_mcp_id: &str, + branch: Option<&str>, + metadata: &Value, + org_slug: Option<&str>, +) -> Option { + let clone_url = metadata + .get("githubRepo")? + .get("url")? + .as_str() + .filter(|url| !url.is_empty())?; + + let runtime_meta = metadata.get("runtime"); + let package_manager = runtime_meta + .and_then(|r| r.get("selected")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()); + let package_manager_path = runtime_meta + .and_then(|r| r.get("path")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()); + + Some(GitSandboxConfig { + org_slug: org_slug.map(str::to_string), + virtual_mcp_id: virtual_mcp_id.to_string(), + clone_url: clone_url.to_string(), + branch: Some( + branch + .filter(|b| !b.is_empty()) + .unwrap_or(DEFAULT_BRANCH) + .to_string(), + ), + runtime: package_manager + .and_then(runtime_for_package_manager) + .map(str::to_string), + package_manager: package_manager.map(str::to_string), + package_manager_path: package_manager_path.map(str::to_string), + git_user_name: None, + git_user_email: None, + }) +} + +/// `SANDBOX_START` / `SANDBOX_DELETE`, or `None` for any other tool so the +/// caller falls through to the ordinary upstream proxy. +pub(crate) async fn try_dispatch( + state: &AppState, + method: &Method, + org: &str, + rest: &[&str], + body: &Bytes, +) -> Option { + let ["tools", tool_name] = rest else { + return None; + }; + if *method != Method::POST { + return None; + } + match *tool_name { + "SANDBOX_START" => Some(start(state, org, body).await), + "SANDBOX_DELETE" => Some(delete(state, body).await), + "COLLECTION_VIRTUAL_MCP_GET" | "COLLECTION_VIRTUAL_MCP_LIST" => { + Some(virtual_mcp_read(state, org, tool_name, body).await) + } + _ => None, + } +} + +/// `COLLECTION_VIRTUAL_MCP_GET`, with this machine's sandboxes published into +/// the agent's `metadata.sandboxMap`. +/// +/// The shell decides whether a sandbox exists — and what URL to preview — by +/// reading `sandboxMap[userId][branch][kind]`, which the CLOUD writes when it +/// provisions a VM (`agent-shell-layout`'s `shouldConnect` and `previewUrl` +/// both come from it). A desktop sandbox lives only on this machine, so that +/// map stays empty and the shell concludes there is nothing to show: the event +/// stream is never opened (leaving the terminal blank) and the preview has no +/// URL to load, however healthy the local sandbox actually is. +/// +/// Rather than teach the frontend a second source of truth, answer the question +/// it already asks: proxy the tool upstream unchanged, then merge in an entry +/// per locally-registered sandbox for this agent. Entries are keyed +/// `user-desktop`, so they sit alongside any hosted ones rather than replacing +/// them. +/// Both the single-item read and the collection list. LIST matters as much as +/// GET: the collection hooks seed the per-item cache from it, so an un-enriched +/// list entry is what the shell would read for the active agent. +async fn virtual_mcp_read(state: &AppState, org: &str, tool_name: &str, body: &Bytes) -> Response { + let input: Value = match serde_json::from_slice(body) { + Ok(value) => value, + Err(error) => { + return ApiError::bad_request(format!("invalid JSON body: {error}")).into_response() + } + }; + + let mut answer = match upstream::call_org_tool(org, tool_name, &input).await { + Ok(value) => value, + Err(error) => { + return ApiError::new(StatusCode::BAD_GATEWAY, error).into_response(); + } + }; + + let Some(user_id) = super::thread_tools::current_account_scope() + .await + .map(|scope| scope.user_id) + else { + // Not signed in: nothing to attribute sandboxes to, and the shell has + // nothing to render anyway. Pass upstream's answer through untouched. + return Json(answer).into_response(); + }; + + // `{ items: [ … ] }`, `{ item: … }`, or a bare entity. Resolve which + // BEFORE borrowing mutably, so there is only ever one live borrow. + let envelope = if answer.get("items").and_then(Value::as_array).is_some() { + "items" + } else if answer.get("item").is_some() { + "item" + } else { + "" + }; + + match envelope { + "items" => { + if let Some(items) = answer.get_mut("items").and_then(Value::as_array_mut) { + for entity in items.iter_mut() { + enrich_entity(state, entity, &user_id); + } + } + } + "item" => { + if let Some(item) = answer.get_mut("item") { + enrich_entity(state, item, &user_id); + } + } + _ => enrich_entity(state, &mut answer, &user_id), + } + Json(answer).into_response() +} + +/// Publish this machine's sandboxes for one virtual-MCP entity, in place. +fn enrich_entity(state: &AppState, entity: &mut Value, user_id: &str) { + let Some(id) = entity.get("id").and_then(Value::as_str).map(str::to_string) else { + return; + }; + let local = local_sandbox_entries(state, &id); + if !local.is_empty() { + merge_sandbox_map(entity, user_id, local); + } +} + +/// `(branch, record)` for every sandbox on this machine that belongs to +/// `virtual_mcp_id`. +/// +/// Reads the per-handle sidecars rather than the registry, because those carry +/// the `(virtual_mcp_id, branch)` the handle hashes away — there is no reverse +/// lookup from an agent to its handles. +/// This machine's sandboxes for `virtual_mcp_id`, in the +/// `agent-sandbox-sessions` wire shape. +/// +/// Unlike [`local_sandbox_entries`], which publishes only sandboxes live in +/// THIS process (an entry there means "a VM is serving this branch"), a +/// session is a durable record: a stopped or failed sandbox is exactly what +/// the shell needs to show, so this reads the registry rather than the live +/// map. +pub(super) fn local_sandbox_sessions(state: &AppState, virtual_mcp_id: &str) -> Vec { + if virtual_mcp_id.is_empty() { + return Vec::new(); + } + let preview = preview_url(); + let Ok(dir) = std::fs::read_dir(state.app_root.join("sandboxes")) else { + return Vec::new(); + }; + + let mut sessions = Vec::new(); + for handle in dir + .flatten() + .filter_map(|e| e.file_name().into_string().ok()) + { + let Some(config) = crate::sandbox::persist::read_sidecar(&state.app_root, &handle) else { + continue; + }; + if config.virtual_mcp_id != virtual_mcp_id { + continue; + } + let Ok(Some(record)) = state.sandbox_manager.registry_record(&handle) else { + // A sidecar with no registry row is a leftover directory, not a + // session — the same reasoning `local_sandbox_entries` applies. + continue; + }; + let branch = config + .branch + .as_deref() + .filter(|branch| !branch.is_empty()) + .unwrap_or(DEFAULT_BRANCH); + // A preview origin is only meaningful while something is serving it. + let preview_url = (record.observed_status == "running") + .then_some(preview.as_deref()) + .flatten(); + sessions.push(super::agent_sessions::session_json( + super::agent_sessions::LocalSession { + virtual_mcp_id, + branch, + handle: &handle, + preview_url, + desired_status: &record.desired_status, + observed_status: &record.observed_status, + failure_reason: record.error.as_deref(), + updated_at_rfc3339: crate::routes::threads::db::format_rfc3339( + std::time::Duration::from_secs(record.updated_at.max(0) as u64), + ), + }, + )); + } + sessions +} + +fn local_sandbox_entries(state: &AppState, virtual_mcp_id: &str) -> Vec<(String, Value)> { + if virtual_mcp_id.is_empty() { + return Vec::new(); + } + let preview = preview_url(); + let Ok(dir) = std::fs::read_dir(state.app_root.join("sandboxes")) else { + return Vec::new(); + }; + + let mut entries = Vec::new(); + for handle in dir + .flatten() + .filter_map(|e| e.file_name().into_string().ok()) + { + let Some(config) = crate::sandbox::persist::read_sidecar(&state.app_root, &handle) else { + continue; + }; + if config.virtual_mcp_id != virtual_mcp_id { + continue; + } + // Publish ONLY a sandbox that is live in this process — not merely one + // the registry still has a row for. + // + // An entry in `sandboxMap` means "a VM is serving this branch": the + // shell reads it as the preview origin, and its auto-start gate fires + // only when the branch has NO entry. Publishing a registered-but- + // stopped sandbox (every sandbox looks like that right after an app + // restart) therefore told the shell someone was already serving, which + // suppressed the very auto-start that would have revived it — the + // sandbox stayed dead and the UI sat on its provisioning label until a + // chat message happened to reach `ensure` through the dispatch path. + // + // Omitting a dead sandbox is self-correcting: the shell auto-starts, + // `SANDBOX_START` ensures/adopts it here, and the next read publishes + // it with a preview origin that is actually listening. + if state.sandbox_manager.get(&handle).is_none() { + continue; + } + let branch = config + .branch + .as_deref() + .filter(|branch| !branch.is_empty()) + .unwrap_or(DEFAULT_BRANCH) + .to_string(); + entries.push(( + branch, + json!({ + "sandboxHandle": handle, + "previewUrl": preview, + // For user-desktop the daemon IS the preview origin. + "sandboxApiUrl": preview, + "sandboxProviderKind": "user-desktop", + }), + )); + } + entries +} + +/// Merge `entries` into `entity.metadata.sandboxMap[user_id][branch]` under the +/// `user-desktop` key, creating the intermediate objects as needed and leaving +/// every other key untouched. +fn merge_sandbox_map(entity: &mut Value, user_id: &str, entries: Vec<(String, Value)>) { + let Some(object) = entity.as_object_mut() else { + return; + }; + let metadata = object + .entry("metadata") + .or_insert_with(|| json!({})) + .as_object_mut(); + let Some(metadata) = metadata else { + return; + }; + let by_user = metadata + .entry("sandboxMap") + .or_insert_with(|| json!({})) + .as_object_mut(); + let Some(by_user) = by_user else { + return; + }; + let by_branch = by_user + .entry(user_id.to_string()) + .or_insert_with(|| json!({})) + .as_object_mut(); + let Some(by_branch) = by_branch else { + return; + }; + for (branch, record) in entries { + let by_kind = by_branch + .entry(branch) + .or_insert_with(|| json!({})) + .as_object_mut(); + if let Some(by_kind) = by_kind { + by_kind.insert("user-desktop".to_string(), record); + } + } +} + +/// `SANDBOX_DELETE` — stop the local sandbox behind `(virtualMcpId, branch)`. +/// +/// Needs no upstream read: the handle is derived from the input alone. Deleting +/// an unknown handle is a success, matching the tool's idempotent `{ success }` +/// contract — the caller asked for it to be gone, and it is. +async fn delete(state: &AppState, body: &Bytes) -> Response { + let input: Value = match serde_json::from_slice(body) { + Ok(value) => value, + Err(error) => { + return ApiError::bad_request(format!("invalid JSON body: {error}")).into_response() + } + }; + let Some(virtual_mcp_id) = input.get("virtualMcpId").and_then(Value::as_str) else { + return ApiError::bad_request("virtualMcpId is required").into_response(); + }; + let Some(branch) = input + .get("branch") + .and_then(Value::as_str) + .filter(|b| !b.is_empty()) + else { + return ApiError::bad_request("branch is required").into_response(); + }; + + let handle = crate::sandbox::SandboxManager::compute_handle(virtual_mcp_id, branch); + match state.sandbox_manager.stop_registered(&handle).await { + Ok(_) => Json(json!({ "success": true })).into_response(), + Err(error) => { + ApiError::internal(format!("could not stop the local sandbox: {error}")).into_response() + } + } +} + +async fn start(state: &AppState, org: &str, body: &Bytes) -> Response { + let input: Value = match serde_json::from_slice(body) { + Ok(value) => value, + Err(error) => { + return ApiError::bad_request(format!("invalid JSON body: {error}")).into_response() + } + }; + let Some(virtual_mcp_id) = input.get("virtualMcpId").and_then(Value::as_str) else { + return ApiError::bad_request("virtualMcpId is required").into_response(); + }; + let branch = input.get("branch").and_then(Value::as_str); + + let virtual_mcp = match upstream::call_org_tool( + org, + "COLLECTION_VIRTUAL_MCP_GET", + &json!({ "id": virtual_mcp_id }), + ) + .await + { + Ok(value) => value, + Err(error) => { + return ApiError::new( + StatusCode::BAD_GATEWAY, + format!("could not resolve the agent's repository: {error}"), + ) + .into_response() + } + }; + + // `COLLECTION_VIRTUAL_MCP_GET` answers `{ item: … }`; tolerate a bare + // entity too rather than depending on the envelope shape alone. + let entity = virtual_mcp.get("item").unwrap_or(&virtual_mcp); + let metadata = entity.get("metadata").cloned().unwrap_or(Value::Null); + + let Some(config) = config_from_virtual_mcp(virtual_mcp_id, branch, &metadata, Some(org)) else { + return ApiError::bad_request( + "this agent has no git repository attached, so there is no sandbox to start", + ) + .into_response(); + }; + let resolved_branch = config + .branch + .clone() + .unwrap_or_else(|| DEFAULT_BRANCH.to_string()); + let handle = crate::sandbox::SandboxManager::compute_handle(virtual_mcp_id, &resolved_branch); + let existed = state + .sandbox_manager + .is_registered(&handle) + .unwrap_or(false); + + if let Err(error) = state.sandbox_manager.ensure(&config).await { + return ApiError::internal(format!("could not start the local sandbox: {error}")) + .into_response(); + } + + Json(json!({ + "previewUrl": preview_url(), + "sandboxHandle": handle, + "branch": resolved_branch, + "isNewVm": !existed, + "sandboxProviderKind": "user-desktop", + })) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_repo_and_runtime_the_way_the_dispatch_block_did() { + let metadata = json!({ + "githubRepo": { "url": "https://github.com/acme/site.git" }, + "runtime": { "selected": "pnpm", "path": "/usr/local/bin/pnpm" }, + }); + let cfg = config_from_virtual_mcp("vm-1", Some("feature"), &metadata, Some("acme")) + .expect("a repo-backed virtual MCP yields a config"); + + assert_eq!(cfg.virtual_mcp_id, "vm-1"); + assert_eq!(cfg.clone_url, "https://github.com/acme/site.git"); + assert_eq!(cfg.branch.as_deref(), Some("feature")); + // pnpm is a package manager; the runtime driving it is node. + assert_eq!(cfg.runtime.as_deref(), Some("node")); + assert_eq!(cfg.package_manager.as_deref(), Some("pnpm")); + assert_eq!( + cfg.package_manager_path.as_deref(), + Some("/usr/local/bin/pnpm") + ); + } + + #[test] + fn defaults_a_missing_or_empty_branch_to_main() { + let metadata = json!({ "githubRepo": { "url": "https://github.com/acme/site.git" } }); + for branch in [None, Some("")] { + let cfg = config_from_virtual_mcp("vm-1", branch, &metadata, Some("acme")).unwrap(); + assert_eq!(cfg.branch.as_deref(), Some("main")); + } + } + + #[test] + fn no_config_without_a_clonable_repo() { + // The plain-directory case: nothing to clone, so nothing to provision. + for metadata in [ + json!({}), + json!({ "githubRepo": {} }), + json!({ "githubRepo": { "url": "" } }), + Value::Null, + ] { + assert!( + config_from_virtual_mcp("vm-1", Some("main"), &metadata, Some("acme")).is_none() + ); + } + } + + #[test] + fn merge_sandbox_map_publishes_desktop_entries_without_disturbing_hosted_ones() { + // The shell reads sandboxMap[userId][branch][kind]; a desktop entry has + // to appear as a SIBLING of any hosted one, never replace it, or + // switching runtimes would lose the cloud sandbox. + let mut entity = json!({ + "id": "vm-1", + "metadata": { + "githubRepo": { "url": "https://github.com/acme/site" }, + "sandboxMap": { + "user-1": { "main": { "agent-sandbox": { "sandboxHandle": "cloud-1" } } } + } + } + }); + merge_sandbox_map( + &mut entity, + "user-1", + vec![( + "main".to_string(), + json!({ "sandboxHandle": "local-1", "previewUrl": "http://localhost:1/" }), + )], + ); + + let branch = &entity["metadata"]["sandboxMap"]["user-1"]["main"]; + assert_eq!(branch["user-desktop"]["sandboxHandle"], "local-1"); + assert_eq!(branch["agent-sandbox"]["sandboxHandle"], "cloud-1"); + // Unrelated metadata survives — this is a merge, not a replacement. + assert_eq!( + entity["metadata"]["githubRepo"]["url"], + "https://github.com/acme/site" + ); + } + + #[test] + fn merge_sandbox_map_creates_the_whole_path_when_absent() { + // The common desktop case: upstream has never provisioned anything, so + // there is no metadata.sandboxMap at all to merge into. + let mut entity = json!({ "id": "vm-1" }); + merge_sandbox_map( + &mut entity, + "user-1", + vec![("feature".to_string(), json!({ "sandboxHandle": "h" }))], + ); + assert_eq!( + entity["metadata"]["sandboxMap"]["user-1"]["feature"]["user-desktop"]["sandboxHandle"], + "h" + ); + } + + #[test] + fn preview_url_reports_the_listener_only_once_it_is_bound() { + // Ordering matters: an unbound listener must report no preview URL + // rather than a bogus `localhost:0`, which the shell would try to load. + set_preview_port(0); + assert_eq!(preview_url(), None); + + set_preview_port(51_234); + assert_eq!(preview_url().as_deref(), Some("http://localhost:51234/")); + set_preview_port(0); + } + + #[test] + fn unknown_package_manager_leaves_the_runtime_unset() { + let metadata = json!({ + "githubRepo": { "url": "https://github.com/acme/site.git" }, + "runtime": { "selected": "cargo" }, + }); + let cfg = config_from_virtual_mcp("vm-1", None, &metadata, Some("acme")).unwrap(); + assert_eq!(cfg.package_manager.as_deref(), Some("cargo")); + assert_eq!(cfg.runtime, None, "never guess a runtime we don't know"); + } +} diff --git a/apps/native/crates/local-api/src/routes/intercept/sandbox_ops.rs b/apps/native/crates/local-api/src/routes/intercept/sandbox_ops.rs new file mode 100644 index 0000000000..63f192b486 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/intercept/sandbox_ops.rs @@ -0,0 +1,260 @@ +//! The rest of `/api/:org/sandbox/:virtualMcpId/:branch/*` — config, git, +//! scripts, setup and preview-fetch — served from THIS machine. +//! +//! ## Why these are intercepted +//! +//! The webview calls the cloud's sandbox-proxy shape for everything. For a +//! desktop-run agent the sandbox is a set of child processes here, so any of +//! these that reaches upstream asks about a sandbox that does not exist and +//! comes back `502 Preview not available` — which is exactly how the missing +//! `preview-invoke` surfaced. Every route below already has a working local +//! implementation; it was simply never wired to the path shape the UI calls. +//! +//! ## One table, not one interceptor per route +//! +//! `sandbox_fs` established the mechanism (decode the identity segments, +//! `compute_handle`, `adopt`, then [`state_for_sandbox`] so the existing +//! handler resolves to THAT sandbox without a header). This module reuses it +//! for the remaining routes, so adding one is a match arm rather than a file — +//! and nothing can be silently forgotten the way `preview-invoke` was. +//! +//! The handlers themselves are called, never modified: `/_sandbox/setup/*` is +//! byte-parity-pinned by the daemon-parity suite, and these are simply a +//! second caller of the same code. + +use axum::body::Bytes; +use axum::extract::{Path as AxumPath, State}; +use axum::http::{HeaderMap, Method, StatusCode}; +use axum::response::{IntoResponse, Response}; + +use super::sandbox_fs::{decode_identity_segment, state_for_sandbox}; +use crate::error::ApiError; +use crate::sandbox::SandboxManager; +use crate::state::AppState; + +pub(super) async fn try_dispatch( + state: &AppState, + method: &Method, + rest: &[&str], + query: Option<&str>, + body: &Bytes, +) -> Option { + let (["sandbox", encoded_virtual_mcp_id, encoded_branch], tail) = rest.split_at_checked(3)? + else { + return None; + }; + if tail.is_empty() || !is_handled(tail) { + return None; + } + + let virtual_mcp_id = match decode_identity_segment("virtualMcpId", encoded_virtual_mcp_id) { + Ok(value) => value, + Err(error) => return Some(error.into_response()), + }; + let branch = match decode_identity_segment("branch", encoded_branch) { + Ok(value) => value, + Err(error) => return Some(error.into_response()), + }; + + let handle = SandboxManager::compute_handle(&virtual_mcp_id, &branch); + let sandbox = match state.sandbox_manager.adopt(&handle).await { + Ok(Some(sandbox)) => sandbox, + Ok(None) => { + return Some( + ApiError::not_found(format!("sandbox not found: {handle}")).into_response(), + ) + } + Err(error) => { + return Some( + ApiError::internal(format!("failed to adopt sandbox {handle}: {error}")) + .into_response(), + ) + } + }; + let target = state_for_sandbox(state, &sandbox); + Some(route(&target, method, tail, query, body.clone()).await) +} + +/// Whether this module owns `tail`. Checked BEFORE the sandbox is adopted, so +/// an unrelated route still falls through to the upstream proxy untouched +/// rather than 404ing on a sandbox it never needed. +fn is_handled(tail: &[&str]) -> bool { + matches!( + tail, + ["config"] + | ["preview-fetch"] + | ["git", _] + | ["setup", _] + | ["exec", _] + | ["exec", _, "kill"] + ) +} + +async fn route( + target: &AppState, + method: &Method, + tail: &[&str], + query: Option<&str>, + body: Bytes, +) -> Response { + let state = State(target.clone()); + // These handlers take the sandbox from headers; `state_for_sandbox` has + // already pointed this state's own target at it, so an empty map resolves + // to the right sandbox. + let headers = HeaderMap::new(); + + match (method, tail) { + (&Method::GET, ["config"]) => crate::routes::config::read(state).await.into_response(), + (&Method::PUT, ["config"]) => crate::routes::config::update(state, body).await, + + (&Method::GET, ["preview-fetch"]) => preview_fetch(target, query).await, + + (&Method::GET, ["git", "status"]) => { + crate::routes::git::status(state).await.into_response() + } + (&Method::POST, ["git", "status"]) => { + crate::routes::git::status(state).await.into_response() + } + (&Method::POST, ["git", "diff"]) => { + crate::routes::git::diff(state, body).await.into_response() + } + (&Method::POST, ["git", "publish"]) => crate::routes::git::publish(state, body) + .await + .into_response(), + (&Method::POST, ["git", "discard"]) => crate::routes::git::discard(state, body) + .await + .into_response(), + (&Method::POST, ["git", "rebase"]) => crate::routes::git::rebase(state, body) + .await + .into_response(), + + (&Method::POST, ["setup", "clone"]) => crate::routes::setup::clone(state, headers) + .await + .into_response(), + (&Method::POST, ["setup", "install"]) => crate::routes::setup::install(state, headers) + .await + .into_response(), + (&Method::POST, ["setup", "start"]) => crate::routes::setup::start(state, headers) + .await + .into_response(), + (&Method::POST, ["setup", "stop"]) => crate::routes::setup::stop(state, headers) + .await + .into_response(), + + (&Method::POST, ["exec", name]) => { + match crate::routes::scripts::exec(state, headers, AxumPath((*name).to_string()), body) + .await + { + Ok(response) => response, + Err(error) => error.into_response(), + } + } + (&Method::POST, ["exec", name, "kill"]) => { + crate::routes::scripts::exec_kill(state, headers, AxumPath((*name).to_string())) + .await + .into_response() + } + + // `is_handled` admitted the path but not this method. + _ => ApiError::new( + StatusCode::METHOD_NOT_ALLOWED, + format!("method not allowed for this sandbox route: {method}"), + ) + .into_response(), + } +} + +/// `GET …/preview-fetch?path=…` — read one path off the sandbox's dev server. +/// +/// Straight to the dev port: this is a server-side read with no browser +/// involved, so it needs none of the cookie/origin handling the preview +/// listener exists for. +async fn preview_fetch(target: &AppState, query: Option<&str>) -> Response { + let Some(path) = query.and_then(query_path) else { + return ApiError::bad_request("missing `path` query parameter").into_response(); + }; + let Some(port) = crate::routes::proxy::target_dev_port(target) else { + return ApiError::new(StatusCode::BAD_GATEWAY, "Preview not available").into_response(); + }; + + let url = format!("http://127.0.0.1:{port}/{}", path.trim_start_matches('/')); + let Ok(response) = reqwest::Client::new().get(&url).send().await else { + return ApiError::new(StatusCode::BAD_GATEWAY, "Preview unreachable").into_response(); + }; + let status = + StatusCode::from_u16(response.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let headers = response.headers().clone(); + let Ok(bytes) = response.bytes().await else { + return ApiError::new(StatusCode::BAD_GATEWAY, "Preview unreachable").into_response(); + }; + + let mut res = Response::new(axum::body::Body::from(bytes)); + *res.status_mut() = status; + if let Some(content_type) = headers.get(axum::http::header::CONTENT_TYPE) { + res.headers_mut() + .insert(axum::http::header::CONTENT_TYPE, content_type.clone()); + } + res +} + +/// The `path` value from a raw query string, percent-decoded. +fn query_path(query: &str) -> Option { + query.split('&').find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key == "path").then(|| percent_decode(value)) + }) +} + +fn percent_decode(value: &str) -> String { + urlencoding::decode(value) + .map(|decoded| decoded.into_owned()) + .unwrap_or_else(|_| value.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A path this module does not own MUST fall through, so the upstream + /// proxy still serves it — claiming it would 404 every cloud sandbox + /// route the desktop has no local answer for. + #[test] + fn only_claims_the_routes_it_implements() { + for owned in [ + vec!["config"], + vec!["preview-fetch"], + vec!["git", "status"], + vec!["setup", "clone"], + vec!["exec", "dev"], + vec!["exec", "dev", "kill"], + ] { + assert!(is_handled(&owned), "should own {owned:?}"); + } + for foreign in [ + vec!["events"], // sandbox_events owns it + vec!["read"], // sandbox_fs owns it + vec!["preview-invoke"], // preview_invoke owns it + vec!["claim"], // cloud-only + vec!["git"], // incomplete + vec!["exec", "a", "b"], // not `kill` + vec![], + ] { + assert!(!is_handled(&foreign), "should NOT own {foreign:?}"); + } + } + + #[test] + fn reads_the_path_parameter_out_of_a_raw_query() { + assert_eq!( + query_path("path=/index.html").as_deref(), + Some("/index.html") + ); + // Percent-encoded, and not the first parameter. + assert_eq!( + query_path("x=1&path=%2Fa%2Fb.json&y=2").as_deref(), + Some("/a/b.json") + ); + assert_eq!(query_path("nope=1"), None); + assert_eq!(query_path(""), None); + } +} diff --git a/apps/native/crates/local-api/src/routes/intercept/thread_tools.rs b/apps/native/crates/local-api/src/routes/intercept/thread_tools.rs new file mode 100644 index 0000000000..7313d8623c --- /dev/null +++ b/apps/native/crates/local-api/src/routes/intercept/thread_tools.rs @@ -0,0 +1,1815 @@ +//! Thread collection CRUD — `POST /api/:org/tools/COLLECTION_THREADS_*` and +//! `COLLECTION_THREAD_MESSAGES_LIST`. Every id-based operation is scoped to +//! the authenticated upstream/user account and raw `:org` path segment: an id +//! from any other scope is treated exactly like an unknown id. Wire contract: +//! the native interception contract §3.1. +//! +//! `apps/api/src/api/routes/tools-rest.ts`'s own contract is "body = tool +//! arguments verbatim JSON, response = raw tool output JSON, no envelope" — +//! every handler below mirrors that exactly: parse the POST body as the +//! tool's own input shape, return the tool's own output shape, `200 OK`, +//! no `{content,structuredContent}` wrapper (the real client, +//! `rest-self-client.ts`, synthesizes that wrapper itself — map §3.1). +//! +//! Backed by `routes::threads::db::ThreadsDb`'s `rt_*` methods (a +//! SEPARATE table pair from the mini-app's `threads`/`messages`/`runs` — +//! see that file's `SCHEMA` doc comment for why). + +use axum::body::Bytes; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::{json, Map, Value}; + +use crate::error::ApiError; +use crate::routes::threads::db::{ + DbError, RtAccountScope, RtThreadListOptions, RtThreadPatch, ThreadsDb, +}; +use crate::routes::threads::shared_db; +use crate::state::AppState; + +#[cfg(test)] +pub async fn dispatch( + state: &AppState, + org: &str, + tool_name: &str, + body: &Bytes, +) -> Option { + let scope = current_account_scope().await?; + dispatch_scoped(state, &scope, org, tool_name, body).await +} + +pub async fn dispatch_scoped( + state: &AppState, + scope: &RtAccountScope, + org: &str, + tool_name: &str, + body: &Bytes, +) -> Option { + match tool_name { + "COLLECTION_THREADS_LIST" => Some(list(state, scope, org, body)), + "COLLECTION_THREADS_GET" => Some(get(state, scope, org, body)), + "COLLECTION_THREADS_CREATE" => Some(create(state, scope, org, body).await), + "COLLECTION_THREADS_UPDATE" => Some(update(state, scope, org, body)), + "COLLECTION_THREADS_DELETE" => Some(delete(state, scope, org, body).await), + "COLLECTION_THREAD_MESSAGES_LIST" => Some(messages_list(state, scope, org, body)), + _ => None, + } +} + +// `ApiError`, not `Response`, is this pair's error type — clippy's +// `result_large_err` flags a bare `Response` (128+ bytes) as an oversized +// `Err` variant; `ApiError` (a small `{status, body: Value}`) is both the +// idiomatic type this crate's other handlers already use and small enough +// to pass the lint. Call sites convert via `.map_err(ApiError::into_response)`. +fn parse_json(body: &Bytes) -> Result { + if body.is_empty() { + return Ok(json!({})); + } + serde_json::from_slice(body) + .map_err(|e| ApiError::bad_request(format!("invalid JSON body: {e}"))) +} + +fn expect_object<'a>(value: &'a Value, path: &str) -> Result<&'a Map, ApiError> { + value + .as_object() + .ok_or_else(|| ApiError::bad_request(format!("{path} must be an object"))) +} + +fn optional_string<'a>( + object: &'a Map, + field: &str, + path: &str, +) -> Result, ApiError> { + match object.get(field) { + None => Ok(None), + Some(Value::String(value)) => Ok(Some(value)), + Some(_) => Err(ApiError::bad_request(format!( + "{path}.{field} must be a string" + ))), + } +} + +fn optional_bool( + object: &Map, + field: &str, + path: &str, +) -> Result, ApiError> { + match object.get(field) { + None => Ok(None), + Some(Value::Bool(value)) => Ok(Some(*value)), + Some(_) => Err(ApiError::bad_request(format!( + "{path}.{field} must be a boolean" + ))), + } +} + +fn optional_nullable_string<'a>( + object: &'a Map, + field: &str, + path: &str, +) -> Result>, ApiError> { + match object.get(field) { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(Value::String(value)) => Ok(Some(Some(value))), + Some(_) => Err(ApiError::bad_request(format!( + "{path}.{field} must be a string or null" + ))), + } +} + +fn pagination(input: &Map, default_limit: i64) -> Result<(i64, i64), ApiError> { + let limit = match input.get("limit") { + None => default_limit, + Some(value) => value + .as_i64() + .filter(|limit| (1..=1000).contains(limit)) + .ok_or_else(|| ApiError::bad_request("limit must be an integer between 1 and 1000"))?, + }; + let offset = match input.get("offset") { + None => 0, + Some(value) => value + .as_i64() + .filter(|offset| *offset >= 0) + .ok_or_else(|| ApiError::bad_request("offset must be a non-negative integer"))?, + }; + Ok((limit, offset)) +} + +fn validate_order_by(input: &Map) -> Result, ApiError> { + let Some(value) = input.get("orderBy") else { + return Ok(None); + }; + let entries = value + .as_array() + .ok_or_else(|| ApiError::bad_request("orderBy must be an array"))?; + for entry in entries { + let entry = expect_object(entry, "orderBy item")?; + let fields = entry + .get("field") + .and_then(Value::as_array) + .ok_or_else(|| ApiError::bad_request("orderBy item.field must be an array"))?; + if fields.iter().any(|field| !field.is_string()) { + return Err(ApiError::bad_request( + "orderBy item.field entries must be strings", + )); + } + let direction = entry + .get("direction") + .and_then(Value::as_str) + .ok_or_else(|| ApiError::bad_request("orderBy item.direction must be asc or desc"))?; + if !matches!(direction, "asc" | "desc") { + return Err(ApiError::bad_request( + "orderBy item.direction must be asc or desc", + )); + } + if let Some(nulls) = entry.get("nulls") { + if !matches!(nulls.as_str(), Some("first" | "last")) { + return Err(ApiError::bad_request( + "orderBy item.nulls must be first or last", + )); + } + } + } + Ok(entries + .first() + .and_then(Value::as_object) + .and_then(|entry| entry.get("direction")) + .and_then(Value::as_str)) +} + +fn validate_datetime(value: &str, path: &str) -> Result<(), ApiError> { + // `z.string().datetime()` (the production schema) accepts the UTC shape the + // real UI emits with `Date#toISOString`: YYYY-MM-DDTHH:mm:ss(.sss)Z. Keep + // this dependency-free parser deliberately strict so a malformed date is a + // 400, never a silently-ignored broad query. + let bytes = value.as_bytes(); + let fixed_digits = [0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18]; + let punctuation_ok = bytes.len() >= 20 + && bytes.get(4) == Some(&b'-') + && bytes.get(7) == Some(&b'-') + && bytes.get(10) == Some(&b'T') + && bytes.get(13) == Some(&b':') + && bytes.get(16) == Some(&b':') + && bytes.last() == Some(&b'Z') + && fixed_digits + .iter() + .all(|index| bytes.get(*index).is_some_and(u8::is_ascii_digit)); + let fraction_ok = match bytes.len() { + 20 => true, + length if length > 21 => { + bytes.get(19) == Some(&b'.') && bytes[20..length - 1].iter().all(u8::is_ascii_digit) + } + _ => false, + }; + let parse = |range: std::ops::Range| { + std::str::from_utf8(&bytes[range]) + .ok() + .and_then(|part| part.parse::().ok()) + }; + let year = parse(0..4); + let month = parse(5..7); + let day = parse(8..10); + let days_in_month = match (year, month) { + (Some(year), Some(2)) if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) => 29, + (Some(_), Some(2)) => 28, + (Some(_), Some(4 | 6 | 9 | 11)) => 30, + (Some(_), Some(1 | 3 | 5 | 7 | 8 | 10 | 12)) => 31, + _ => 0, + }; + let calendar_ok = punctuation_ok + && day.is_some_and(|day| (1..=days_in_month).contains(&day)) + && parse(11..13).is_some_and(|hour| hour <= 23) + && parse(14..16).is_some_and(|minute| minute <= 59) + && parse(17..19).is_some_and(|second| second <= 59); + if calendar_ok && fraction_ok { + Ok(()) + } else { + Err(ApiError::bad_request(format!( + "{path} must be an ISO UTC datetime" + ))) + } +} + +fn validate_thread_metadata(value: &Value) -> Result<(), ApiError> { + let metadata = expect_object(value, "data.metadata")?; + let Some(expanded_tools) = metadata.get("expanded_tools") else { + return Ok(()); + }; + let expanded_tools = expanded_tools + .as_array() + .ok_or_else(|| ApiError::bad_request("data.metadata.expanded_tools must be an array"))?; + for (index, expanded_tool) in expanded_tools.iter().enumerate() { + let path = format!("data.metadata.expanded_tools[{index}]"); + let expanded_tool = expect_object(expanded_tool, &path)?; + for field in ["toolName", "appId"] { + if optional_string(expanded_tool, field, &path)?.is_none() { + return Err(ApiError::bad_request(format!("{path}.{field} is required"))); + } + } + match expanded_tool.get("args") { + Some(Value::Object(_)) => {} + _ => { + return Err(ApiError::bad_request(format!( + "{path}.args must be an object" + ))) + } + } + let expanded_at = optional_string(expanded_tool, "expandedAt", &path)? + .ok_or_else(|| ApiError::bad_request(format!("{path}.expandedAt is required")))?; + validate_datetime(expanded_at, &format!("{path}.expandedAt"))?; + } + Ok(()) +} + +fn validate_where_expression(value: &Value) -> Result<(), ApiError> { + let expression = expect_object(value, "where")?; + let operator = expression + .get("operator") + .and_then(Value::as_str) + .ok_or_else(|| ApiError::bad_request("where.operator is required"))?; + match operator { + "and" | "or" | "not" => { + let conditions = expression + .get("conditions") + .and_then(Value::as_array) + .ok_or_else(|| ApiError::bad_request("where.conditions must be an array"))?; + for condition in conditions { + validate_where_expression(condition)?; + } + } + "eq" | "gt" | "gte" | "lt" | "lte" | "in" | "like" | "contains" => { + let fields = expression + .get("field") + .and_then(Value::as_array) + .ok_or_else(|| ApiError::bad_request("where.field must be an array"))?; + if fields.iter().any(|field| !field.is_string()) { + return Err(ApiError::bad_request("where.field entries must be strings")); + } + if !expression.contains_key("value") { + return Err(ApiError::bad_request("where.value is required")); + } + } + _ => return Err(ApiError::bad_request("where.operator is invalid")), + } + Ok(()) +} + +fn extract_thread_id_from_where(value: &Value) -> Option { + let expression = value.as_object()?; + let operator = expression.get("operator")?.as_str()?; + if operator == "eq" + && expression + .get("field")? + .as_array()? + .first() + .and_then(Value::as_str) + == Some("thread_id") + { + return expression.get("value").map(js_string); + } + if matches!(operator, "and" | "or") { + for condition in expression.get("conditions")?.as_array()? { + if let Some(thread_id) = extract_thread_id_from_where(condition) { + return Some(thread_id); + } + } + } + None +} + +fn js_string(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + Value::Null => "null".to_string(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::Array(values) => values + .iter() + .map(|value| match value { + Value::Null => String::new(), + value => js_string(value), + }) + .collect::>() + .join(","), + Value::Object(_) => "[object Object]".to_string(), + } +} + +fn db(state: &AppState) -> Result<&'static ThreadsDb, ApiError> { + shared_db(state) +} + +/// The user id local-api stamps on `created_by`/`updated_by`. +/// +/// MUST match the real signed-in user's id: the production shell's own +/// chat input (`components/chat/input.tsx`) renders read-only whenever +/// `task.created_by !== userId`, so any value other than the actual +/// signed-in user permanently locks every locally-created thread as +/// "viewing someone else's chat" — verified live against the real UI +/// (Gate C drive), which is what an earlier, always-opaque placeholder +/// value did. Resolved via `upstream::global()`'s signed-in session (the +/// SAME identity the real mesh backend's own `COLLECTION_THREADS_CREATE` +/// stamps `created_by` with server-side, per `apps/api/src/tools/thread/ +/// create.ts`). Signed-out requests are rejected before dispatch; they never +/// create placeholder-owned rows. +/// +/// `#[cfg(not(test))]`/`#[cfg(test)]` split, not a richer runtime branch: +/// `upstream::global()`'s `TokenStore` is the REAL macOS Keychain in every +/// COMPILED build of this crate (there is no test-only override reachable +/// from inside `routes/intercept/*` — unlike `routes/upstream.rs`'s own +/// tests, which construct a throwaway `UpstreamSession` over a +/// `MemoryTokenStore` instead of touching `upstream::global()` at all). +/// Querying it from a plain `cargo test` unit test measurably hung this +/// crate's test binary (a `keyring::Entry::new(..).get_password()` call +/// against a real, possibly-unlocked/no-session Keychain took far longer +/// than `KeychainTokenStore`'s own 4s async-level timeout bounds, because +/// that timeout only abandons the FUTURE — the underlying `spawn_blocking` +/// OS thread can still be stuck) — found empirically while writing this +/// module's own tests, and the reason this file's tests must never take +/// the `cfg(not(test))` branch below. `#[cfg(test)]` is a compile-time +/// swap (this crate's own `cargo test` binary never links the +/// Keychain-touching branch at all), not a runtime "are we testing" +/// check, so it carries none of this repo's usual "workaround with a +/// comment" smell. +#[cfg(not(test))] +pub(crate) async fn current_account_scope() -> Option { + current_account_scope_result().await.ok().flatten() +} + +#[cfg(not(test))] +pub(crate) async fn current_account_scope_result( +) -> Result, upstream::tokens::TokenStoreError> { + let session = upstream::global(); + let Some(user_id) = session.current_user_sub_result().await? else { + return Ok(None); + }; + Ok(RtAccountScope::new(session.host(), user_id)) +} + +#[cfg(test)] +pub(crate) async fn current_account_scope() -> Option { + RtAccountScope::new("test.invalid", "local-desktop-user") +} + +#[cfg(test)] +pub(crate) async fn current_account_scope_result( +) -> Result, upstream::tokens::TokenStoreError> { + Ok(current_account_scope().await) +} + +// --- COLLECTION_THREADS_LIST ------------------------------------------------- + +fn list(state: &AppState, scope: &RtAccountScope, org: &str, body: &Bytes) -> Response { + let input = match parse_json(body) { + Ok(v) => v, + Err(r) => return r.into_response(), + }; + let input = match expect_object(&input, "request body") { + Ok(input) => input, + Err(error) => return error.into_response(), + }; + let (limit, offset) = match pagination(input, 100) { + Ok(pagination) => pagination, + Err(error) => return error.into_response(), + }; + if let Err(error) = validate_order_by(input) { + return error.into_response(); + } + + let where_input = match input.get("where") { + None => None, + Some(value) => match expect_object(value, "where") { + Ok(value) => Some(value), + Err(error) => return error.into_response(), + }, + }; + let where_created_by = match where_input { + Some(where_input) => match optional_string(where_input, "created_by", "where") { + Ok(value) => value, + Err(error) => return error.into_response(), + }, + None => None, + }; + let user_id = match optional_string(input, "userId", "request body") { + Ok(value) => value, + Err(error) => return error.into_response(), + }; + let created_by = user_id.or_else(|| { + where_created_by.map(|value| { + if value == "me" { + scope.user_id.as_str() + } else { + value + } + }) + }); + let hidden = match where_input { + Some(where_input) => match optional_bool(where_input, "hidden", "where") { + Ok(value) => value.unwrap_or(false), + Err(error) => return error.into_response(), + }, + None => false, + }; + let search = match optional_string(input, "search", "request body") { + Ok(value) => value, + Err(error) => return error.into_response(), + }; + + let trigger_ids = match where_input.and_then(|where_input| where_input.get("trigger_ids")) { + None => None, + Some(Value::Array(values)) => { + let mut trigger_ids = Vec::with_capacity(values.len()); + for value in values { + let Some(value) = value.as_str() else { + return ApiError::bad_request("where.trigger_ids must contain only strings") + .into_response(); + }; + trigger_ids.push(value.to_string()); + } + Some(trigger_ids) + } + Some(_) => { + return ApiError::bad_request("where.trigger_ids must be an array").into_response() + } + }; + let virtual_mcp_id = match where_input { + Some(where_input) => match optional_string(where_input, "virtual_mcp_id", "where") { + Ok(value) => value, + Err(error) => return error.into_response(), + }, + None => None, + }; + let has_trigger = match where_input { + Some(where_input) => match optional_bool(where_input, "has_trigger", "where") { + Ok(value) => value, + Err(error) => return error.into_response(), + }, + None => None, + }; + let start_date = match optional_string(input, "startDate", "request body") { + Ok(value) => value, + Err(error) => return error.into_response(), + }; + if let Some(value) = start_date { + if let Err(error) = validate_datetime(value, "startDate") { + return error.into_response(); + } + } + let end_date = match optional_string(input, "endDate", "request body") { + Ok(value) => value, + Err(error) => return error.into_response(), + }; + if let Some(value) = end_date { + if let Err(error) = validate_datetime(value, "endDate") { + return error.into_response(); + } + } + let status = match optional_string(input, "status", "request body") { + Ok(value) => value, + Err(error) => return error.into_response(), + }; + let agent_id = match optional_string(input, "agentId", "request body") { + Ok(value) => value, + Err(error) => return error.into_response(), + }; + + let db = match db(state) { + Ok(d) => d, + Err(r) => return r.into_response(), + }; + + match db.rt_list_threads_scoped( + scope, + org, + RtThreadListOptions { + created_by, + hidden: Some(hidden), + search, + trigger_ids: trigger_ids.as_deref(), + virtual_mcp_id, + has_trigger, + start_date, + end_date, + status, + agent_id, + limit, + offset, + }, + ) { + Ok((items, total_count)) => { + let has_more = offset + limit < total_count; + Json(json!({ + "items": items, + "totalCount": total_count, + "hasMore": has_more, + })) + .into_response() + } + Err(e) => ApiError::internal(format!("thread database error: {e}")).into_response(), + } +} + +// --- COLLECTION_THREADS_GET -------------------------------------------------- + +fn get(state: &AppState, scope: &RtAccountScope, org: &str, body: &Bytes) -> Response { + let input = match parse_json(body) { + Ok(v) => v, + Err(r) => return r.into_response(), + }; + let db = match db(state) { + Ok(d) => d, + Err(r) => return r.into_response(), + }; + let Some(id) = input.get("id").and_then(Value::as_str) else { + return ApiError::bad_request("id is required").into_response(); + }; + match db.rt_get_thread_in_scope(scope, org, id) { + Ok(item) => Json(json!({ "item": item })).into_response(), + Err(e) => ApiError::internal(format!("thread database error: {e}")).into_response(), + } +} + +// --- COLLECTION_THREADS_CREATE ----------------------------------------------- + +async fn create(state: &AppState, scope: &RtAccountScope, org: &str, body: &Bytes) -> Response { + let input = match parse_json(body) { + Ok(v) => v, + Err(r) => return r.into_response(), + }; + let input = match expect_object(&input, "request body") { + Ok(input) => input, + Err(error) => return error.into_response(), + }; + let data = match input.get("data") { + Some(data) => match expect_object(data, "data") { + Ok(data) => data, + Err(error) => return error.into_response(), + }, + None => return ApiError::bad_request("data is required").into_response(), + }; + let virtual_mcp_id = match optional_string(data, "virtual_mcp_id", "data") { + Ok(Some(value)) => value, + Ok(None) => { + return ApiError::bad_request("data.virtual_mcp_id is required").into_response() + } + Err(error) => return error.into_response(), + }; + let id = match optional_string(data, "id", "data") { + Ok(value) => value, + Err(error) => return error.into_response(), + }; + let title = match optional_string(data, "title", "data") { + Ok(value) => value.unwrap_or("New chat"), + Err(error) => return error.into_response(), + }; + let description = match optional_nullable_string(data, "description", "data") { + Ok(value) => value.flatten(), + Err(error) => return error.into_response(), + }; + let branch = match optional_string(data, "branch", "data") { + Ok(Some("")) => { + return ApiError::bad_request("data.branch must not be empty").into_response() + } + Ok(value) => value, + Err(error) => return error.into_response(), + }; + let db = match db(state) { + Ok(d) => d, + Err(r) => return r.into_response(), + }; + let user = &scope.user_id; + + // An explicit id participates in the same create/delete lifecycle as a + // dispatch. DELETE drops its gate while it waits for the old harness to + // stop, but marks the id closing first; holding this gate through the + // insert and checking that marker prevents a concurrent create from + // returning the old incarnation. Once DELETE commits, schema-v5's durable + // tombstone rejects the id permanently in this account + org scope. + // Generated ids cannot collide with an in-flight delete and need no gate. + let lifecycle_gate = id.map(|id| super::decopilot::thread_lifecycle_gate(scope, org, id)); + let _lifecycle_guard = if let Some(gate) = &lifecycle_gate { + Some(gate.lock().await) + } else { + None + }; + if id.is_some_and(|id| super::decopilot::thread_is_closing(scope, org, id)) { + return ApiError::conflict("thread is being deleted").into_response(); + } + + match db.rt_create_thread_scoped( + scope, + id, + org, + title, + description, + virtual_mcp_id, + branch, + user, + ) { + Ok(item) => Json(json!({ "item": item })).into_response(), + // An explicit id already owned by another account or organization is + // neither returned nor described: expose only that this caller cannot + // use the id, not which tenant owns it. + Err(DbError::IdempotencyConflict { .. } | DbError::RetiredThreadId { .. }) => { + ApiError::conflict("thread id is unavailable").into_response() + } + Err(DbError::ThreadDeletePending { .. }) => { + ApiError::conflict("thread is being deleted").into_response() + } + Err(e) => ApiError::internal(format!("thread database error: {e}")).into_response(), + } +} + +// --- COLLECTION_THREADS_UPDATE ----------------------------------------------- + +fn update(state: &AppState, scope: &RtAccountScope, org: &str, body: &Bytes) -> Response { + let input = match parse_json(body) { + Ok(v) => v, + Err(r) => return r.into_response(), + }; + let input = match expect_object(&input, "request body") { + Ok(input) => input, + Err(error) => return error.into_response(), + }; + let id = match optional_string(input, "id", "request body") { + Ok(Some(value)) => value, + Ok(None) => return ApiError::bad_request("id is required").into_response(), + Err(error) => return error.into_response(), + }; + let data = match input.get("data") { + Some(data) => match expect_object(data, "data") { + Ok(data) => data, + Err(error) => return error.into_response(), + }, + None => return ApiError::bad_request("data is required").into_response(), + }; + let title = match optional_string(data, "title", "data") { + Ok(value) => value.map(String::from), + Err(error) => return error.into_response(), + }; + let description = match optional_nullable_string(data, "description", "data") { + Ok(value) => value.map(|value| value.map(String::from)), + Err(error) => return error.into_response(), + }; + let hidden = match optional_bool(data, "hidden", "data") { + Ok(value) => value, + Err(error) => return error.into_response(), + }; + let status = match optional_string(data, "status", "data") { + Ok(Some(value)) + if !matches!( + value, + "requires_action" | "failed" | "in_progress" | "completed" + ) => + { + return ApiError::bad_request("data.status is invalid").into_response() + } + Ok(value) => value.map(String::from), + Err(error) => return error.into_response(), + }; + let metadata = match data.get("metadata") { + None => None, + Some(value) => { + if let Err(error) = validate_thread_metadata(value) { + return error.into_response(); + } + Some(Some(value.clone())) + } + }; + let branch = match optional_nullable_string(data, "branch", "data") { + Ok(value) => value.map(|value| value.map(String::from)), + Err(error) => return error.into_response(), + }; + let virtual_mcp_id = match optional_string(data, "virtual_mcp_id", "data") { + Ok(value) => value.map(String::from), + Err(error) => return error.into_response(), + }; + let db = match db(state) { + Ok(d) => d, + Err(r) => return r.into_response(), + }; + let patch = RtThreadPatch { + title, + description, + hidden, + status, + metadata, + branch, + virtual_mcp_id, + }; + match db.rt_update_thread_in_scope(scope, org, id, &scope.user_id, &patch) { + Ok(Some(item)) => Json(json!({ "item": item })).into_response(), + Ok(None) => ApiError::not_found("thread not found").into_response(), + Err(DbError::ThreadDeletePending { .. }) => { + ApiError::conflict("thread is being deleted").into_response() + } + Err(e) => ApiError::internal(format!("thread database error: {e}")).into_response(), + } +} + +// --- COLLECTION_THREADS_DELETE ----------------------------------------------- + +async fn delete(state: &AppState, scope: &RtAccountScope, org: &str, body: &Bytes) -> Response { + let input = match parse_json(body) { + Ok(v) => v, + Err(r) => return r.into_response(), + }; + let db = match db(state) { + Ok(d) => d, + Err(r) => return r.into_response(), + }; + let Some(id) = input.get("id").and_then(Value::as_str) else { + return ApiError::bad_request("id is required").into_response(); + }; + + let lifecycle_gate = super::decopilot::thread_lifecycle_gate(scope, org, id); + let lifecycle_guard = lifecycle_gate.lock().await; + + // Read through the same organization predicate used by the delete. The + // collection binding returns the deleted entity, while an id owned by a + // different organization must be indistinguishable from an unknown id. + let item = match db.rt_get_thread_in_scope(scope, org, id) { + Ok(Some(item)) => item, + Ok(None) => return ApiError::not_found("thread not found").into_response(), + Err(e) => return ApiError::internal(format!("thread database error: {e}")).into_response(), + }; + let fence = match db.rt_thread_fence_in_scope(scope, org, id) { + Ok(Some(fence)) => fence, + Ok(None) => return ApiError::not_found("thread not found").into_response(), + Err(e) => return ApiError::internal(format!("thread database error: {e}")).into_response(), + }; + match db.rt_mark_thread_delete_pending(&fence) { + Ok(true) => {} + Ok(false) => return ApiError::not_found("thread not found").into_response(), + Err(e) => { + return ApiError::internal(format!("thread database error: {e}")).into_response(); + } + } + super::decopilot::mark_thread_closing(scope, org, id); + drop(lifecycle_guard); + + if let Err(error) = super::decopilot::quiesce_thread_for_delete(state, &fence).await { + // `delete_pending` and the process-local closing latch deliberately + // survive every failed quiesce. Accepted tails remain durable and a + // retry can resume DELETE, while send/claim paths stay closed. + return error.into_response(); + } + + // Reacquire the accept/delete gate for the final generation-fenced + // cascade. Sends observed `closing` while the active harness was reaped, + // so no accepted turn can fall into that interval. + let _lifecycle_guard = lifecycle_gate.lock().await; + match db.rt_delete_thread_in_org_if_generation(&fence) { + Ok(true) => { + super::decopilot::clear_thread_closing(scope, org, id); + Json(json!({ "item": item })).into_response() + } + // A concurrent deletion between the scoped read and delete is still a + // not-found result; never return a success envelope for a row this call + // did not remove. + Ok(false) => { + super::decopilot::clear_thread_closing(scope, org, id); + ApiError::not_found("thread not found").into_response() + } + Err(e) => { + // Keep the live row durably closed. The queued tail was never + // discarded and a later DELETE retries the same fenced cascade. + ApiError::internal(format!("thread database error: {e}")).into_response() + } + } +} + +// --- COLLECTION_THREAD_MESSAGES_LIST ----------------------------------------- + +fn messages_list(state: &AppState, scope: &RtAccountScope, org: &str, body: &Bytes) -> Response { + let input = match parse_json(body) { + Ok(v) => v, + Err(r) => return r.into_response(), + }; + let input = match expect_object(&input, "request body") { + Ok(input) => input, + Err(error) => return error.into_response(), + }; + let top_level_thread_id = match optional_string(input, "thread_id", "request body") { + Ok(value) => value.map(String::from), + Err(error) => return error.into_response(), + }; + let where_thread_id = match input.get("where") { + None => None, + Some(where_input) => { + if let Err(error) = validate_where_expression(where_input) { + return error.into_response(); + } + extract_thread_id_from_where(where_input) + } + }; + let Some(thread_id) = top_level_thread_id.or(where_thread_id) else { + return ApiError::bad_request( + "thread_id is required (provide as top-level param or in where clause)", + ) + .into_response(); + }; + let (limit, offset) = match pagination(input, 100) { + Ok(pagination) => pagination, + Err(error) => return error.into_response(), + }; + let direction = match validate_order_by(input) { + Ok(direction) => direction, + Err(error) => return error.into_response(), + }; + let db = match db(state) { + Ok(d) => d, + Err(r) => return r.into_response(), + }; + // Byte-parity with `list-messages.ts`: `sort = orderBy[0].direction ?? "asc"`. + // The chat sends `orderBy: [{ field: ["created_at"], direction: "desc" }]` + // to fetch the LATEST page; ignoring it (always ASC/oldest) dropped the + // newest turn out of the refetch window and mis-ordered the chat. + let desc = direction == Some("desc"); + + // Byte-parity with `list-messages.ts`'s own "unknown thread -> empty + // page, not an error" behavior (see that file's handler). + match db.rt_list_messages_in_scope(scope, org, &thread_id, limit, offset, desc) { + Ok((items, total_count)) => { + let has_more = offset + limit < total_count; + Json(json!({ + "items": items, + "totalCount": total_count, + "hasMore": has_more, + })) + .into_response() + } + Err(e) => ApiError::internal(format!("thread database error: {e}")).into_response(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::routes::intercept::test_state; + use axum::body::to_bytes; + use axum::http::StatusCode; + use std::sync::OnceLock; + + /// `shared_db` is process-wide, so its backing directory must live for the + /// whole test binary too. A per-test `TempDir` let the first completed test + /// unlink the database underneath every later test's still-open handle. + fn persistent_test_state() -> AppState { + static ROOT: OnceLock = OnceLock::new(); + test_state(ROOT.get_or_init(|| tempfile::tempdir().unwrap()).path()) + } + + async fn body_json(res: Response) -> Value { + let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() + } + + #[tokio::test] + async fn create_then_get_round_trips() { + let state = persistent_test_state(); + let create_body = Bytes::from( + json!({"data": {"title": "hello", "virtual_mcp_id": "vmcp-1"}}).to_string(), + ); + let res = dispatch(&state, "acme", "COLLECTION_THREADS_CREATE", &create_body) + .await + .unwrap(); + assert_eq!(res.status(), axum::http::StatusCode::OK); + let created = body_json(res).await; + let id = created["item"]["id"].as_str().unwrap().to_string(); + assert_eq!(created["item"]["organization_id"], "acme"); + assert_eq!(created["item"]["title"], "hello"); + assert_eq!(created["item"]["virtual_mcp_id"], "vmcp-1"); + assert_eq!(created["item"]["hidden"], false); + assert!(!created["item"] + .as_object() + .unwrap() + .contains_key("updated_by")); + assert!(!created["item"] + .as_object() + .unwrap() + .contains_key("metadata")); + + let get_body = Bytes::from(json!({"id": id}).to_string()); + let res = dispatch(&state, "acme", "COLLECTION_THREADS_GET", &get_body) + .await + .unwrap(); + assert_eq!(res.status(), axum::http::StatusCode::OK); + let fetched = body_json(res).await; + assert_eq!(fetched["item"]["id"], id); + } + + #[tokio::test] + async fn create_requires_virtual_mcp_id() { + let state = persistent_test_state(); + let body = Bytes::from(json!({"data": {"title": "x"}}).to_string()); + let res = dispatch(&state, "acme", "COLLECTION_THREADS_CREATE", &body) + .await + .unwrap(); + assert_eq!(res.status(), axum::http::StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn create_and_update_reject_schema_invalid_fields_instead_of_coercing_them() { + let state = persistent_test_state(); + let invalid_creates = [ + json!([]), + json!({}), + json!({"data": null}), + json!({"data": []}), + json!({"data": {"virtual_mcp_id": 7}}), + json!({"data": {"virtual_mcp_id": "v", "id": 7}}), + json!({"data": {"virtual_mcp_id": "v", "title": null}}), + json!({"data": {"virtual_mcp_id": "v", "description": false}}), + json!({"data": {"virtual_mcp_id": "v", "branch": null}}), + json!({"data": {"virtual_mcp_id": "v", "branch": ""}}), + ]; + for (index, input) in invalid_creates.into_iter().enumerate() { + let response = dispatch( + &state, + "thread-tools-invalid-create", + "COLLECTION_THREADS_CREATE", + &Bytes::from(input.to_string()), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "invalid create case {index} must be rejected" + ); + } + + let invalid_updates = [ + json!([]), + json!({}), + json!({"id": "t", "data": null}), + json!({"id": "t", "data": []}), + json!({"id": 7, "data": {}}), + json!({"id": "t", "data": {"title": null}}), + json!({"id": "t", "data": {"description": false}}), + json!({"id": "t", "data": {"hidden": "true"}}), + json!({"id": "t", "data": {"status": "expired"}}), + json!({"id": "t", "data": {"metadata": null}}), + json!({"id": "t", "data": {"metadata": []}}), + json!({"id": "t", "data": {"metadata": {"expanded_tools": {}}}}), + json!({ + "id": "t", + "data": { + "metadata": { + "expanded_tools": [{ + "toolName": "TOOL", + "appId": "app", + "args": {}, + "expandedAt": "not-a-date" + }] + } + } + }), + json!({"id": "t", "data": {"branch": 7}}), + json!({"id": "t", "data": {"virtual_mcp_id": null}}), + ]; + for (index, input) in invalid_updates.into_iter().enumerate() { + let response = dispatch( + &state, + "thread-tools-invalid-update", + "COLLECTION_THREADS_UPDATE", + &Bytes::from(input.to_string()), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "invalid update case {index} must be rejected before lookup" + ); + } + } + + #[tokio::test] + async fn update_accepts_every_production_typed_optional_field() { + let state = persistent_test_state(); + let org = "thread-tools-valid-update-fields"; + let create = dispatch( + &state, + org, + "COLLECTION_THREADS_CREATE", + &Bytes::from(json!({"data": {"virtual_mcp_id": "v"}}).to_string()), + ) + .await + .unwrap(); + let id = body_json(create).await["item"]["id"] + .as_str() + .unwrap() + .to_string(); + let response = dispatch( + &state, + org, + "COLLECTION_THREADS_UPDATE", + &Bytes::from( + json!({ + "id": id, + "data": { + "title": "updated", + "description": null, + "hidden": true, + "status": "requires_action", + "metadata": {"expanded_tools": []}, + "branch": null, + "virtual_mcp_id": "v2" + } + }) + .to_string(), + ), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let item = body_json(response).await["item"].clone(); + assert_eq!(item["title"], "updated"); + assert_eq!(item["description"], Value::Null); + assert_eq!(item["hidden"], true); + assert_eq!(item["status"], "requires_action"); + assert_eq!(item["metadata"], json!({"expanded_tools": []})); + assert_eq!(item["branch"], Value::Null); + assert_eq!(item["virtual_mcp_id"], "v2"); + assert_eq!(item["updated_by"], "local-desktop-user"); + + let empty_metadata = dispatch( + &state, + org, + "COLLECTION_THREADS_UPDATE", + &Bytes::from(json!({"id": id, "data": {"metadata": {}}}).to_string()), + ) + .await + .unwrap(); + let empty_metadata = body_json(empty_metadata).await; + assert!(!empty_metadata["item"] + .as_object() + .unwrap() + .contains_key("metadata")); + } + + #[tokio::test] + async fn explicit_id_create_is_rejected_while_that_thread_is_closing() { + let state = persistent_test_state(); + let org = "thread-tools-create-during-delete-org"; + let id = "thread-tools-create-during-delete"; + let body = Bytes::from( + json!({ + "data": { + "id": id, + "title": "must not appear early", + "virtual_mcp_id": "v", + }, + }) + .to_string(), + ); + + let scope = current_account_scope().await.unwrap(); + super::super::decopilot::mark_thread_closing(&scope, org, id); + let blocked = dispatch(&state, org, "COLLECTION_THREADS_CREATE", &body) + .await + .unwrap(); + super::super::decopilot::clear_thread_closing(&scope, org, id); + + assert_eq!(blocked.status(), axum::http::StatusCode::CONFLICT); + assert!(shared_db(&state) + .unwrap() + .rt_get_thread_in_org(org, id) + .unwrap() + .is_none()); + + let created = dispatch(&state, org, "COLLECTION_THREADS_CREATE", &body) + .await + .unwrap(); + assert_eq!(created.status(), axum::http::StatusCode::OK); + } + + #[tokio::test] + async fn explicit_id_create_maps_a_retired_id_to_conflict() { + let state = persistent_test_state(); + let scope = RtAccountScope::new("test.invalid", "local-desktop-user").unwrap(); + let org = "thread-tools-retired-create-org"; + let id = "thread-tools-retired-create"; + let database = shared_db(&state).unwrap(); + database + .rt_create_thread_scoped( + &scope, + Some(id), + org, + "deleted", + None, + "v", + None, + &scope.user_id, + ) + .unwrap(); + let fence = database + .rt_thread_fence_in_scope(&scope, org, id) + .unwrap() + .unwrap(); + assert!(database + .rt_delete_thread_in_org_if_generation(&fence) + .unwrap()); + + let body = Bytes::from( + json!({ + "data": { + "id": id, + "title": "must remain deleted", + "virtual_mcp_id": "v", + }, + }) + .to_string(), + ); + let response = dispatch(&state, org, "COLLECTION_THREADS_CREATE", &body) + .await + .unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::CONFLICT); + assert_eq!( + body_json(response).await["error"], + "thread id is unavailable" + ); + } + + #[tokio::test] + async fn list_filters_by_org_and_paginates() { + let state = persistent_test_state(); + // `shared_db` is a PROCESS-WIDE `OnceLock` (see its own doc comment + // — by design, for the single-`AppState`-per-real-process case), + // so every test in this binary shares ONE underlying SQLite + // instance regardless of each test's own (unused, for this reason) + // tempdir. A count-by-org assertion therefore needs organization + // ids no OTHER test in this file uses, or it would flakily observe + // other tests' rows too — everything else in this file asserts by + // exact (randomly-generated) thread id instead, which has no such + // collision risk. + for org in [ + "list-filters-test-org-a", + "list-filters-test-org-a", + "list-filters-test-org-b", + ] { + let body = + Bytes::from(json!({"data": {"title": "t", "virtual_mcp_id": "v"}}).to_string()); + dispatch(&state, org, "COLLECTION_THREADS_CREATE", &body) + .await + .unwrap(); + } + let list_body = Bytes::from(json!({"limit": 50, "offset": 0}).to_string()); + let res = dispatch( + &state, + "list-filters-test-org-a", + "COLLECTION_THREADS_LIST", + &list_body, + ) + .await + .unwrap(); + let listed = body_json(res).await; + assert_eq!(listed["totalCount"], 2); + assert_eq!(listed["items"].as_array().unwrap().len(), 2); + assert_eq!(listed["hasMore"], false); + } + + #[tokio::test] + async fn list_defaults_to_visible_threads_and_a_page_of_one_hundred() { + let state = persistent_test_state(); + let database = shared_db(&state).unwrap(); + let org = "thread-tools-default-visible-limit"; + for index in 0..101 { + database + .rt_create_thread( + Some(&format!("thread-tools-default-visible-{index}")), + org, + "visible", + None, + "v", + None, + "local-desktop-user", + ) + .unwrap(); + } + let hidden_id = "thread-tools-default-hidden"; + database + .rt_create_thread( + Some(hidden_id), + org, + "hidden", + None, + "v", + None, + "local-desktop-user", + ) + .unwrap(); + database + .rt_update_thread_in_org( + org, + hidden_id, + "local-desktop-user", + &RtThreadPatch { + hidden: Some(true), + ..RtThreadPatch::default() + }, + ) + .unwrap(); + + let visible = dispatch( + &state, + org, + "COLLECTION_THREADS_LIST", + &Bytes::from_static(b"{}"), + ) + .await + .unwrap(); + let visible = body_json(visible).await; + assert_eq!(visible["totalCount"], 101); + assert_eq!(visible["items"].as_array().unwrap().len(), 100); + assert_eq!(visible["hasMore"], true); + assert!(visible["items"] + .as_array() + .unwrap() + .iter() + .all(|item| item["hidden"] == false)); + + let archived = dispatch( + &state, + org, + "COLLECTION_THREADS_LIST", + &Bytes::from_static(br#"{"where":{"hidden":true}}"#), + ) + .await + .unwrap(); + let archived = body_json(archived).await; + assert_eq!(archived["totalCount"], 1); + assert_eq!(archived["items"][0]["id"], hidden_id); + } + + #[tokio::test] + async fn list_validates_pagination_and_supported_filter_shapes() { + let state = persistent_test_state(); + for input in [ + json!({"limit": 0}), + json!({"limit": -1}), + json!({"limit": 1001}), + json!({"limit": 1.5}), + json!({"limit": "50"}), + json!({"offset": -1}), + json!({"offset": 1.5}), + json!({"offset": "0"}), + json!({"where": null}), + json!({"where": {"hidden": "false"}}), + json!({"where": {"created_by": 1}}), + json!({"where": {"trigger_ids": ["ok", 1]}}), + json!({"where": {"virtual_mcp_id": 1}}), + json!({"where": {"has_trigger": "true"}}), + json!({"search": 1}), + json!({"status": false}), + json!({"startDate": "2026-02-30T00:00:00.000Z"}), + json!({"orderBy": [{"field": ["updated_at"], "direction": "sideways"}]}), + ] { + let response = dispatch( + &state, + "thread-tools-list-validation", + "COLLECTION_THREADS_LIST", + &Bytes::from(input.to_string()), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "input: {input}"); + } + } + + #[tokio::test] + async fn list_applies_production_filters_and_agent_fallback_without_ignoring_them() { + let state = persistent_test_state(); + let database = shared_db(&state).unwrap(); + let org = "thread-tools-list-all-filters"; + for (id, title, virtual_mcp_id) in [ + ( + "thread-tools-filter-primary", + "Needle primary", + "vm-primary", + ), + ("thread-tools-filter-agent", "Needle fallback", "vm-agent"), + ("thread-tools-filter-hidden", "Needle hidden", "vm-primary"), + ] { + database + .rt_create_thread( + Some(id), + org, + title, + None, + virtual_mcp_id, + None, + "local-desktop-user", + ) + .unwrap(); + } + database + .rt_update_thread_in_org( + org, + "thread-tools-filter-agent", + "local-desktop-user", + &RtThreadPatch { + status: Some("failed".to_string()), + ..RtThreadPatch::default() + }, + ) + .unwrap(); + database + .rt_update_thread_in_org( + org, + "thread-tools-filter-hidden", + "local-desktop-user", + &RtThreadPatch { + hidden: Some(true), + ..RtThreadPatch::default() + }, + ) + .unwrap(); + + // `virtual_mcp_id` wins over `agentId`; every other normal predicate + // composes, and count uses the identical WHERE clause as items. + let filtered = dispatch( + &state, + org, + "COLLECTION_THREADS_LIST", + &Bytes::from( + json!({ + "where": { + "hidden": false, + "virtual_mcp_id": "vm-primary", + "has_trigger": false + }, + "startDate": "2000-01-01T00:00:00.000Z", + "endDate": "2999-01-01T00:00:00Z", + "search": "needle", + "status": "completed", + "userId": "local-desktop-user", + "agentId": "vm-agent" + }) + .to_string(), + ), + ) + .await + .unwrap(); + assert_eq!(filtered.status(), StatusCode::OK); + let filtered = body_json(filtered).await; + assert_eq!(filtered["totalCount"], 1); + assert_eq!(filtered["items"].as_array().unwrap().len(), 1); + assert_eq!(filtered["items"][0]["id"], "thread-tools-filter-primary"); + + let fallback = dispatch( + &state, + org, + "COLLECTION_THREADS_LIST", + &Bytes::from_static(br#"{"where":{"has_trigger":false},"agentId":"vm-agent"}"#), + ) + .await + .unwrap(); + let fallback = body_json(fallback).await; + assert_eq!(fallback["totalCount"], 1); + assert_eq!(fallback["items"][0]["id"], "thread-tools-filter-agent"); + + let non_matching_trigger = dispatch( + &state, + org, + "COLLECTION_THREADS_LIST", + &Bytes::from_static(br#"{"where":{"trigger_ids":["missing"]}}"#), + ) + .await + .unwrap(); + assert_eq!(non_matching_trigger.status(), StatusCode::OK); + assert_eq!(body_json(non_matching_trigger).await["totalCount"], 0); + + let empty_trigger_ids = dispatch( + &state, + org, + "COLLECTION_THREADS_LIST", + &Bytes::from_static(br#"{"where":{"trigger_ids":[],"virtual_mcp_id":"vm-primary"}}"#), + ) + .await + .unwrap(); + assert_eq!(empty_trigger_ids.status(), StatusCode::OK); + assert_eq!(body_json(empty_trigger_ids).await["totalCount"], 1); + } + + #[tokio::test] + async fn routes_isolate_same_org_slug_by_upstream_and_authenticated_user() { + let state = persistent_test_state(); + let org = "route-account-scope-shared-org"; + let id = "route-account-scope-thread"; + let prod_alice = RtAccountScope::new("studio.decocms.com", "alice").unwrap(); + let prod_bob = RtAccountScope::new("studio.decocms.com", "bob").unwrap(); + let dev_alice = RtAccountScope::new("localhost:4000", "alice").unwrap(); + let body = Bytes::from(json!({"data": {"id": id, "virtual_mcp_id": "vmcp"}}).to_string()); + let created = dispatch_scoped(&state, &prod_alice, org, "COLLECTION_THREADS_CREATE", &body) + .await + .unwrap(); + assert_eq!(created.status(), axum::http::StatusCode::OK); + let created = body_json(created).await; + assert_eq!(created["item"]["organization_id"], org); + assert_eq!(created["item"]["created_by"], "alice"); + assert_eq!(created["item"]["title"], "New chat"); + + let list_body = Bytes::from(json!({"where": {"created_by": "me"}}).to_string()); + let alice_list = body_json( + dispatch_scoped( + &state, + &prod_alice, + org, + "COLLECTION_THREADS_LIST", + &list_body, + ) + .await + .unwrap(), + ) + .await; + assert_eq!(alice_list["totalCount"], 1); + + for foreign in [&prod_bob, &dev_alice] { + let list = body_json( + dispatch_scoped(&state, foreign, org, "COLLECTION_THREADS_LIST", &list_body) + .await + .unwrap(), + ) + .await; + assert_eq!(list["totalCount"], 0); + let get = body_json( + dispatch_scoped( + &state, + foreign, + org, + "COLLECTION_THREADS_GET", + &Bytes::from(json!({"id": id}).to_string()), + ) + .await + .unwrap(), + ) + .await; + assert_eq!(get, json!({"item": null})); + } + } + + #[tokio::test] + async fn update_hides_a_thread() { + let state = persistent_test_state(); + let create_body = + Bytes::from(json!({"data": {"title": "t", "virtual_mcp_id": "v"}}).to_string()); + let created = body_json( + dispatch(&state, "acme", "COLLECTION_THREADS_CREATE", &create_body) + .await + .unwrap(), + ) + .await; + let id = created["item"]["id"].as_str().unwrap().to_string(); + + let update_body = Bytes::from(json!({"id": id, "data": {"hidden": true}}).to_string()); + let res = dispatch(&state, "acme", "COLLECTION_THREADS_UPDATE", &update_body) + .await + .unwrap(); + let updated = body_json(res).await; + assert_eq!(updated["item"]["hidden"], true); + } + + #[tokio::test] + async fn update_unknown_thread_is_404() { + let state = persistent_test_state(); + let body = Bytes::from(json!({"id": "nope", "data": {"title": "x"}}).to_string()); + let res = dispatch(&state, "acme", "COLLECTION_THREADS_UPDATE", &body) + .await + .unwrap(); + assert_eq!(res.status(), axum::http::StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn explicit_id_owned_by_another_org_is_a_non_disclosing_conflict() { + let state = persistent_test_state(); + let id = "thread-tools-cross-org-create-conflict"; + let create = |title: &str| { + Bytes::from( + json!({ + "data": { + "id": id, + "title": title, + "virtual_mcp_id": "v", + }, + }) + .to_string(), + ) + }; + + let first = dispatch( + &state, + "thread-tools-owner-org", + "COLLECTION_THREADS_CREATE", + &create("owner title"), + ) + .await + .unwrap(); + assert_eq!(first.status(), axum::http::StatusCode::OK); + + let conflict = dispatch( + &state, + "thread-tools-other-org", + "COLLECTION_THREADS_CREATE", + &create("attacker title"), + ) + .await + .unwrap(); + assert_eq!(conflict.status(), axum::http::StatusCode::CONFLICT); + assert_eq!( + body_json(conflict).await, + json!({ "error": "thread id is unavailable" }) + ); + + let owner = shared_db(&state) + .unwrap() + .rt_get_thread_in_org("thread-tools-owner-org", id) + .unwrap() + .unwrap(); + assert_eq!(owner.title, "owner title"); + } + + #[tokio::test] + async fn id_operations_are_org_scoped_and_delete_cascades_messages() { + let state = persistent_test_state(); + let db = shared_db(&state).unwrap(); + let owner_org = "thread-tools-scope-owner"; + let other_org = "thread-tools-scope-other"; + let id = "thread-tools-scoped-delete"; + db.rt_create_thread(Some(id), owner_org, "original", None, "v", None, "u") + .unwrap(); + db.rt_append_message("scoped-u", id, "user", &json!([]), None) + .unwrap(); + db.rt_append_message("scoped-a", id, "assistant", &json!([]), None) + .unwrap(); + + let id_body = Bytes::from(json!({ "id": id }).to_string()); + let foreign_get = dispatch(&state, other_org, "COLLECTION_THREADS_GET", &id_body) + .await + .unwrap(); + assert_eq!(body_json(foreign_get).await, json!({ "item": null })); + + let update_body = + Bytes::from(json!({ "id": id, "data": { "title": "hijacked" } }).to_string()); + let foreign_update = dispatch(&state, other_org, "COLLECTION_THREADS_UPDATE", &update_body) + .await + .unwrap(); + assert_eq!(foreign_update.status(), axum::http::StatusCode::NOT_FOUND); + + let messages_body = Bytes::from(json!({ "thread_id": id }).to_string()); + let foreign_messages = dispatch( + &state, + other_org, + "COLLECTION_THREAD_MESSAGES_LIST", + &messages_body, + ) + .await + .unwrap(); + assert_eq!( + body_json(foreign_messages).await, + json!({ "items": [], "totalCount": 0, "hasMore": false }) + ); + + let foreign_delete = dispatch(&state, other_org, "COLLECTION_THREADS_DELETE", &id_body) + .await + .unwrap(); + assert_eq!(foreign_delete.status(), axum::http::StatusCode::NOT_FOUND); + let owner = db + .rt_get_thread_in_org(owner_org, id) + .unwrap() + .expect("foreign operations must not remove the owner's row"); + assert_eq!(owner.title, "original"); + assert_eq!(db.rt_list_messages(id, 100, 0, false).unwrap().1, 2); + + let owner_delete = dispatch(&state, owner_org, "COLLECTION_THREADS_DELETE", &id_body) + .await + .unwrap(); + assert_eq!(owner_delete.status(), axum::http::StatusCode::OK); + assert_eq!(body_json(owner_delete).await["item"]["id"], id); + assert!(db.rt_get_thread(id).unwrap().is_none()); + assert_eq!( + db.rt_list_messages(id, 100, 0, false).unwrap().1, + 0, + "foreign-key cascade must physically remove the messages" + ); + } + + #[tokio::test] + async fn messages_list_is_empty_for_a_fresh_thread() { + let state = persistent_test_state(); + let create_body = + Bytes::from(json!({"data": {"title": "t", "virtual_mcp_id": "v"}}).to_string()); + let created = body_json( + dispatch(&state, "acme", "COLLECTION_THREADS_CREATE", &create_body) + .await + .unwrap(), + ) + .await; + let id = created["item"]["id"].as_str().unwrap().to_string(); + + let body = Bytes::from(json!({"thread_id": id}).to_string()); + let res = dispatch(&state, "acme", "COLLECTION_THREAD_MESSAGES_LIST", &body) + .await + .unwrap(); + let listed = body_json(res).await; + assert_eq!(listed["items"].as_array().unwrap().len(), 0); + assert_eq!(listed["totalCount"], 0); + } + + #[tokio::test] + async fn messages_list_supports_recursive_legacy_where_with_top_level_precedence() { + let state = persistent_test_state(); + let database = shared_db(&state).unwrap(); + let org = "thread-tools-message-legacy-where"; + let first_thread = "thread-tools-message-legacy-first"; + let second_thread = "thread-tools-message-legacy-second"; + for thread_id in [first_thread, second_thread] { + database + .rt_create_thread( + Some(thread_id), + org, + thread_id, + None, + "v", + None, + "local-desktop-user", + ) + .unwrap(); + } + database + .rt_append_message( + "legacy-where-first-message", + first_thread, + "user", + &json!([]), + None, + ) + .unwrap(); + database + .rt_append_message( + "legacy-where-second-message", + second_thread, + "user", + &json!([]), + None, + ) + .unwrap(); + + let nested_where = json!({ + "where": { + "operator": "and", + "conditions": [ + {"field": ["role"], "operator": "eq", "value": "user"}, + { + "operator": "or", + "conditions": [ + {"field": ["thread_id"], "operator": "eq", "value": first_thread} + ] + } + ] + } + }); + let response = dispatch( + &state, + org, + "COLLECTION_THREAD_MESSAGES_LIST", + &Bytes::from(nested_where.to_string()), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let response = body_json(response).await; + assert_eq!(response["items"][0]["id"], "legacy-where-first-message"); + + let top_level_wins = json!({ + "thread_id": second_thread, + "where": {"field": ["thread_id"], "operator": "eq", "value": first_thread} + }); + let response = dispatch( + &state, + org, + "COLLECTION_THREAD_MESSAGES_LIST", + &Bytes::from(top_level_wins.to_string()), + ) + .await + .unwrap(); + let response = body_json(response).await; + assert_eq!(response["items"][0]["id"], "legacy-where-second-message"); + } + + #[tokio::test] + async fn messages_list_validates_collection_pagination_and_where_shapes() { + let state = persistent_test_state(); + for input in [ + json!([]), + json!({"thread_id": 1}), + json!({"thread_id": "t", "limit": 0}), + json!({"thread_id": "t", "limit": 1001}), + json!({"thread_id": "t", "limit": 1.5}), + json!({"thread_id": "t", "offset": -1}), + json!({"thread_id": "t", "offset": 1.5}), + json!({"where": null}), + json!({"where": {"operator": "eq", "field": "thread_id", "value": "t"}}), + json!({"where": {"operator": "and", "conditions": [null]}}), + json!({ + "thread_id": "t", + "orderBy": [{"field": ["created_at"], "direction": "sideways"}] + }), + ] { + let response = dispatch( + &state, + "thread-tools-message-validation", + "COLLECTION_THREAD_MESSAGES_LIST", + &Bytes::from(input.to_string()), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "input: {input}"); + } + } + + #[tokio::test] + async fn messages_list_items_carry_ascending_seq() { + let state = persistent_test_state(); + // Append messages directly via the (process-wide) db — there is no + // thread_tools HTTP handler that writes messages (that's decopilot's + // send_message). A thread id no other test uses avoids collisions on + // the shared `shared_db` OnceLock (see `list_filters_by_org` note). + let db = shared_db(&state).unwrap(); + let tid = "messages-list-seq-thread"; + db.rt_create_thread(Some(tid), "acme", "", None, "v", None, "u") + .unwrap(); + db.rt_append_message("u1", tid, "user", &json!([]), None) + .unwrap(); + db.rt_append_message("a1", tid, "assistant", &json!([]), None) + .unwrap(); + + let body = Bytes::from( + json!({ + "thread_id": tid, + "orderBy": [{"field": ["created_at"], "direction": "asc"}], + }) + .to_string(), + ); + let res = dispatch(&state, "acme", "COLLECTION_THREAD_MESSAGES_LIST", &body) + .await + .unwrap(); + let listed = body_json(res).await; + let items = listed["items"].as_array().unwrap(); + assert_eq!(items.len(), 2); + let s0 = items[0]["seq"].as_i64().expect("seq present on item 0"); + let s1 = items[1]["seq"].as_i64().expect("seq present on item 1"); + assert!(s0 < s1, "asc items must carry strictly increasing seq"); + assert_eq!(items[0]["id"], "u1"); + assert_eq!(items[1]["id"], "a1"); + } + + #[tokio::test] + async fn unrecognized_tool_name_is_not_intercepted() { + let state = persistent_test_state(); + let res = dispatch(&state, "acme", "SOME_OTHER_TOOL", &Bytes::new()).await; + assert!(res.is_none()); + } +} diff --git a/apps/native/crates/local-api/src/routes/intercept/watch.rs b/apps/native/crates/local-api/src/routes/intercept/watch.rs new file mode 100644 index 0000000000..cf2fe9eb2a --- /dev/null +++ b/apps/native/crates/local-api/src/routes/intercept/watch.rs @@ -0,0 +1,618 @@ +//! Native `/api/:org/watch` event stream. +//! +//! Native threads live only in the local SQLite database, so the upstream +//! Studio watch stream can never describe their lifecycle. This module is the +//! local equivalent of Studio's SSE hub. It deliberately never opens or merges +//! the upstream `/watch` stream: native mode's watch contract is local changes +//! only. Listeners are isolated by authenticated account and organization, +//! events are fanned out only after the corresponding SQLite transaction +//! commits, and no history is retained in memory. Clients recover a +//! disconnected gap through their existing `COLLECTION_THREADS_LIST` +//! reconnect reconciliation. + +use std::collections::HashMap; +use std::convert::Infallible; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::Duration; + +use axum::body::{Body, Bytes}; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use futures::stream; +use serde::Serialize; +use serde_json::json; +use tokio::sync::mpsc; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::routes::threads::db::{RtAccountScope, RtThread, RtThreadFence}; + +const THREAD_STATUS_EVENT: &str = "decopilot.thread.status"; +const LISTENER_CAPACITY: usize = 64; +const MAX_CONNECTIONS_PER_SCOPE: usize = 50; +const MAX_TOTAL_CONNECTIONS: usize = 500; +const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ThreadStatus { + InProgress, + Completed, + RequiresAction, + Failed, +} + +impl ThreadStatus { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::InProgress => "in_progress", + Self::Completed => "completed", + Self::RequiresAction => "requires_action", + Self::Failed => "failed", + } + } +} + +#[derive(Debug, Clone, Serialize)] +struct LocalWatchEvent { + id: String, + #[serde(rename = "type")] + event_type: String, + source: String, + subject: String, + data: serde_json::Value, + time: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ListenerScope { + account_scope: String, + organization_id: String, +} + +struct Listener { + type_patterns: Option>, + sender: mpsc::Sender, +} + +#[derive(Default)] +struct LocalWatchHub { + listeners: Mutex>>, +} + +impl LocalWatchHub { + fn lock(&self) -> MutexGuard<'_, HashMap>> { + self.listeners + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn subscribe( + &'static self, + scope: ListenerScope, + type_patterns: Option>, + ) -> Result { + let id = Uuid::new_v4(); + let (sender, receiver) = mpsc::channel(LISTENER_CAPACITY); + let mut listeners = self.lock(); + let total: usize = listeners.values().map(|scoped| scoped.len()).sum(); + if total >= MAX_TOTAL_CONNECTIONS { + return Err(SubscribeError::TotalLimit); + } + let scoped = listeners.entry(scope.clone()).or_default(); + if scoped.len() >= MAX_CONNECTIONS_PER_SCOPE { + return Err(SubscribeError::ScopeLimit); + } + scoped.insert( + id, + Listener { + type_patterns, + sender, + }, + ); + Ok(Subscription { + hub: self, + scope, + id, + receiver, + }) + } + + fn emit(&self, scope: &ListenerScope, event: &LocalWatchEvent) { + let frame = match event_frame(event) { + Ok(frame) => frame, + Err(error) => { + tracing::error!(%error, "failed to serialize native watch event"); + return; + } + }; + let mut listeners = self.lock(); + let Some(scoped) = listeners.get_mut(scope) else { + return; + }; + scoped.retain(|_, listener| { + if !matches_patterns(&event.event_type, listener.type_patterns.as_deref()) { + return true; + } + match listener.sender.try_send(frame.clone()) { + Ok(()) => true, + Err(mpsc::error::TrySendError::Closed(_)) + | Err(mpsc::error::TrySendError::Full(_)) => false, + } + }); + if scoped.is_empty() { + listeners.remove(scope); + } + } + + fn remove(&self, scope: &ListenerScope, id: Uuid) { + let mut listeners = self.lock(); + let Some(scoped) = listeners.get_mut(scope) else { + return; + }; + scoped.remove(&id); + if scoped.is_empty() { + listeners.remove(scope); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SubscribeError { + ScopeLimit, + TotalLimit, +} + +struct Subscription { + hub: &'static LocalWatchHub, + scope: ListenerScope, + id: Uuid, + receiver: mpsc::Receiver, +} + +impl Drop for Subscription { + fn drop(&mut self) { + self.hub.remove(&self.scope, self.id); + } +} + +struct WatchStream { + initial: Option, + subscription: Subscription, +} + +fn hub() -> &'static LocalWatchHub { + static HUB: OnceLock = OnceLock::new(); + HUB.get_or_init(LocalWatchHub::default) +} + +fn listener_scope(account_scope: String, organization_id: impl Into) -> ListenerScope { + ListenerScope { + account_scope, + organization_id: organization_id.into(), + } +} + +fn matches_patterns(event_type: &str, patterns: Option<&[String]>) -> bool { + let Some(patterns) = patterns else { + return true; + }; + patterns + .iter() + .any(|pattern| match pattern.strip_suffix('*') { + Some(prefix) => event_type.starts_with(prefix), + None => event_type == pattern, + }) +} + +fn parse_type_patterns(query: Option<&str>) -> Result>, ApiError> { + let Some(raw) = query.and_then(|query| { + query.split('&').find_map(|field| { + let (name, value) = field.split_once('=').unwrap_or((field, "")); + (name == "types").then_some(value) + }) + }) else { + return Ok(None); + }; + let decoded = urlencoding::decode(raw) + .map_err(|error| ApiError::bad_request(format!("invalid types query: {error}")))?; + let patterns: Vec = decoded + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect(); + if let Some(invalid) = patterns.iter().find(|pattern| !valid_type_pattern(pattern)) { + return Err(ApiError::bad_request(format!( + "invalid watch type pattern: {invalid}" + ))); + } + Ok((!patterns.is_empty()).then_some(patterns)) +} + +fn valid_type_pattern(pattern: &str) -> bool { + let Some(prefix) = pattern.strip_suffix(".*") else { + return !pattern.contains('*'); + }; + !prefix.is_empty() && !prefix.contains('*') +} + +fn connected_frame( + listener_id: Uuid, + organization_id: &str, + type_patterns: Option<&[String]>, +) -> Bytes { + let connected_at = crate::routes::threads::db::now_rfc3339(); + let data = json!({ + "listenerId": listener_id.to_string(), + "organizationId": organization_id, + "typePatterns": type_patterns, + "connectedAt": connected_at, + }); + Bytes::from(format!("event: connected\ndata: {data}\n\n")) +} + +fn event_frame(event: &LocalWatchEvent) -> Result { + let data = serde_json::to_string(event)?; + Ok(Bytes::from(format!( + "id: {}\nevent: {}\ndata: {data}\n\n", + event.id, event.event_type + ))) +} + +pub(crate) fn get(scope: &RtAccountScope, organization_id: &str, query: Option<&str>) -> Response { + if organization_id.is_empty() { + return ApiError::bad_request("organization id missing").into_response(); + } + let type_patterns = match parse_type_patterns(query) { + Ok(patterns) => patterns, + Err(error) => return error.into_response(), + }; + let subscription = match hub().subscribe( + listener_scope(scope.storage_key(), organization_id), + type_patterns.clone(), + ) { + Ok(subscription) => subscription, + Err(SubscribeError::ScopeLimit) => { + return ApiError::new( + StatusCode::TOO_MANY_REQUESTS, + "native watch connection limit reached for this account and organization", + ) + .into_response(); + } + Err(SubscribeError::TotalLimit) => { + return ApiError::new( + StatusCode::TOO_MANY_REQUESTS, + "native watch total connection limit reached", + ) + .into_response(); + } + }; + let initial = connected_frame(subscription.id, organization_id, type_patterns.as_deref()); + let stream_state = WatchStream { + initial: Some(initial), + subscription, + }; + let body_stream = stream::unfold(stream_state, |mut state| async move { + if let Some(initial) = state.initial.take() { + return Some((Ok::<_, Infallible>(initial), state)); + } + match tokio::time::timeout(KEEPALIVE_INTERVAL, state.subscription.receiver.recv()).await { + Ok(Some(frame)) => Some((Ok(frame), state)), + Ok(None) => None, + Err(_) => Some(( + Ok(Bytes::from_static(b"event: keepalive\ndata: \n\n")), + state, + )), + } + }); + + match Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/event-stream") + .header(header::CACHE_CONTROL, "no-cache") + .header(header::CONNECTION, "keep-alive") + .header("X-Accel-Buffering", "no") + .header(header::CONTENT_ENCODING, "identity") + .body(Body::from_stream(body_stream)) + { + Ok(response) => response, + Err(error) => { + tracing::error!(%error, "failed to build native watch response"); + ApiError::internal("failed to build native watch response").into_response() + } + } +} + +/// Publishes the exact production-shaped status event after its SQLite +/// transition committed. Scope is taken from the same generation fence that +/// owned the write, preventing another signed-in account or organization from +/// observing the event. +pub(crate) fn emit_thread_status( + fence: &RtThreadFence, + status: ThreadStatus, + thread: &RtThread, + message_id: Option<&str>, +) { + let mut data = json!({ + "status": status.as_str(), + "virtual_mcp_id": thread.virtual_mcp_id, + "created_by": thread.created_by, + "trigger_id": thread.trigger_id, + "title": thread.title, + "branch": thread.branch, + "created_at": thread.created_at, + "updated_at": thread.updated_at, + }); + if let Some(metadata) = thread.metadata.as_ref() { + data["metadata"] = metadata.clone(); + } + if let Some(message_id) = message_id { + data["message_id"] = serde_json::Value::String(message_id.to_string()); + } + let event = LocalWatchEvent { + id: Uuid::new_v4().to_string(), + event_type: THREAD_STATUS_EVENT.to_string(), + source: "decopilot".to_string(), + subject: fence.thread_id.clone(), + data, + time: crate::routes::threads::db::now_rfc3339(), + }; + hub().emit( + &listener_scope(fence.account_scope.clone(), fence.organization_id.clone()), + &event, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::StreamExt; + + fn scope(user: &str) -> RtAccountScope { + RtAccountScope::new("studio.decocms.com", user).unwrap() + } + + fn fence(scope: &RtAccountScope, organization_id: &str, thread_id: &str) -> RtThreadFence { + RtThreadFence { + account_scope: scope.storage_key(), + organization_id: organization_id.to_string(), + thread_id: thread_id.to_string(), + generation: Uuid::new_v4().to_string(), + } + } + + fn thread_row(fence: &RtThreadFence, status: ThreadStatus, updated_at: &str) -> RtThread { + RtThread { + id: fence.thread_id.clone(), + organization_id: fence.organization_id.clone(), + title: "Local thread".to_string(), + description: None, + hidden: false, + status: status.as_str().to_string(), + created_by: "alice".to_string(), + updated_by: None, + virtual_mcp_id: "vir_local".to_string(), + trigger_id: None, + branch: Some("feature/native".to_string()), + sandbox_provider_kind: Some("user-desktop".to_string()), + harness_id: Some("claude-code".to_string()), + metadata: Some(json!({"kind": "agent"})), + run_config: None, + created_at: "2026-07-24T11:00:00.000Z".to_string(), + updated_at: updated_at.to_string(), + } + } + + async fn next_frame(stream: &mut axum::body::BodyDataStream) -> String { + let chunk = tokio::time::timeout(Duration::from_secs(1), stream.next()) + .await + .expect("watch frame timed out") + .expect("watch stream ended") + .expect("watch frame failed"); + String::from_utf8(chunk.to_vec()).unwrap() + } + + #[tokio::test] + async fn emits_connected_then_committed_status_wire_shape() { + let account = scope("alice"); + let org = format!("org-{}", Uuid::new_v4()); + let response = get( + &account, + &org, + Some("types=decopilot.thread.status,task-board.item.updated"), + ); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "text/event-stream" + ); + let mut stream = response.into_body().into_data_stream(); + + let connected = next_frame(&mut stream).await; + assert!(connected.contains("event: connected")); + assert!(connected.contains(&org)); + assert!(!connected.contains("event: snapshot")); + + let thread_fence = fence(&account, &org, "thread-1"); + let running = thread_row( + &thread_fence, + ThreadStatus::InProgress, + "2026-07-24T12:00:00.000Z", + ); + emit_thread_status( + &thread_fence, + ThreadStatus::InProgress, + &running, + Some("message-1"), + ); + let status = next_frame(&mut stream).await; + assert!(status.contains("event: decopilot.thread.status")); + let data = status + .lines() + .find_map(|line| line.strip_prefix("data: ")) + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(data).unwrap(); + assert_eq!(parsed["subject"], "thread-1"); + assert_eq!(parsed["data"]["status"], "in_progress"); + assert_eq!(parsed["data"]["message_id"], "message-1"); + assert_eq!(parsed["data"]["updated_at"], "2026-07-24T12:00:00.000Z"); + assert_eq!(parsed["data"]["virtual_mcp_id"], "vir_local"); + assert_eq!(parsed["data"]["created_by"], "alice"); + assert_eq!(parsed["data"]["title"], "Local thread"); + assert_eq!(parsed["data"]["branch"], "feature/native"); + assert_eq!(parsed["data"]["created_at"], "2026-07-24T11:00:00.000Z"); + assert_eq!(parsed["data"]["metadata"]["kind"], "agent"); + + let paused = thread_row( + &thread_fence, + ThreadStatus::RequiresAction, + "2026-07-24T12:00:01.000Z", + ); + emit_thread_status(&thread_fence, ThreadStatus::RequiresAction, &paused, None); + let terminal = next_frame(&mut stream).await; + let data = terminal + .lines() + .find_map(|line| line.strip_prefix("data: ")) + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(data).unwrap(); + assert_eq!(parsed["data"]["status"], "requires_action"); + assert!( + parsed["data"].get("message_id").is_none(), + "terminal events must omit message_id rather than serialize null" + ); + } + + #[tokio::test] + async fn isolates_events_by_account_and_organization() { + let alice = scope("alice"); + let bob = scope("bob"); + let alice_org = format!("org-{}", Uuid::new_v4()); + let other_org = format!("org-{}", Uuid::new_v4()); + let response = get(&alice, &alice_org, Some("types=decopilot.thread.*")); + let mut stream = response.into_body().into_data_stream(); + let _ = next_frame(&mut stream).await; + + let bob_thread = fence(&bob, &alice_org, "bob-thread"); + emit_thread_status( + &bob_thread, + ThreadStatus::Completed, + &thread_row( + &bob_thread, + ThreadStatus::Completed, + "2026-07-24T12:00:00.000Z", + ), + None, + ); + let other_org_thread = fence(&alice, &other_org, "other-org-thread"); + emit_thread_status( + &other_org_thread, + ThreadStatus::Completed, + &thread_row( + &other_org_thread, + ThreadStatus::Completed, + "2026-07-24T12:00:00.000Z", + ), + None, + ); + let leaked = tokio::time::timeout(Duration::from_millis(50), stream.next()).await; + assert!(leaked.is_err(), "cross-scope event leaked to listener"); + + let alice_thread = fence(&alice, &alice_org, "alice-thread"); + emit_thread_status( + &alice_thread, + ThreadStatus::Completed, + &thread_row( + &alice_thread, + ThreadStatus::Completed, + "2026-07-24T12:00:01.000Z", + ), + None, + ); + let status = next_frame(&mut stream).await; + assert!(status.contains("alice-thread")); + } + + #[tokio::test] + async fn bounds_connections_per_account_and_organization() { + let account = scope(&format!("limit-user-{}", Uuid::new_v4())); + let org = format!("org-{}", Uuid::new_v4()); + let mut responses = Vec::new(); + for _ in 0..MAX_CONNECTIONS_PER_SCOPE { + let response = get(&account, &org, None); + assert_eq!(response.status(), StatusCode::OK); + responses.push(response); + } + + let rejected = get(&account, &org, None); + assert_eq!(rejected.status(), StatusCode::TOO_MANY_REQUESTS); + drop(responses); + + let reopened = get(&account, &org, None); + assert_eq!(reopened.status(), StatusCode::OK); + } + + #[test] + fn bounds_connections_process_wide() { + let hub = Box::leak(Box::new(LocalWatchHub::default())); + let mut subscriptions = Vec::new(); + for index in 0..MAX_TOTAL_CONNECTIONS { + subscriptions.push( + hub.subscribe( + ListenerScope { + account_scope: format!("account-{index}"), + organization_id: format!("org-{index}"), + }, + None, + ) + .expect("connection below process-wide cap must be accepted"), + ); + } + + let rejected = hub.subscribe( + ListenerScope { + account_scope: "overflow-account".to_string(), + organization_id: "overflow-org".to_string(), + }, + None, + ); + assert!(matches!(rejected, Err(SubscribeError::TotalLimit))); + drop(subscriptions); + + let reopened = hub.subscribe( + ListenerScope { + account_scope: "reopened-account".to_string(), + organization_id: "reopened-org".to_string(), + }, + None, + ); + assert!(reopened.is_ok()); + } + + #[test] + fn type_patterns_match_exact_and_wildcard_suffix() { + let exact = vec!["decopilot.thread.status".to_string()]; + let wildcard = vec!["decopilot.*".to_string()]; + let other = vec!["task-board.*".to_string()]; + assert!(matches_patterns(THREAD_STATUS_EVENT, Some(&exact))); + assert!(matches_patterns(THREAD_STATUS_EVENT, Some(&wildcard))); + assert!(!matches_patterns(THREAD_STATUS_EVENT, Some(&other))); + assert!(matches_patterns(THREAD_STATUS_EVENT, None)); + } + + #[test] + fn malformed_type_filters_are_rejected_instead_of_broadening() { + assert!(parse_type_patterns(Some("types=%FF")).is_err()); + assert!(parse_type_patterns(Some("types=*")).is_err()); + assert!(parse_type_patterns(Some("types=.*")).is_err()); + assert!(parse_type_patterns(Some("types=decopilot*")).is_err()); + assert!(parse_type_patterns(Some("types=decopilot.*.status")).is_err()); + assert_eq!( + parse_type_patterns(Some("types=decopilot.*,task-board.item.updated")).unwrap(), + Some(vec![ + "decopilot.*".to_string(), + "task-board.item.updated".to_string() + ]) + ); + assert_eq!(parse_type_patterns(Some("types=")).unwrap(), None); + } +} diff --git a/apps/native/crates/local-api/src/routes/mcp_callback.rs b/apps/native/crates/local-api/src/routes/mcp_callback.rs new file mode 100644 index 0000000000..17a69599f7 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/mcp_callback.rs @@ -0,0 +1,309 @@ +//! `/_auth/mcp-callback` — the landing point for an MCP OAuth redirect that +//! completed in the user's real browser. +//! +//! ## Why this exists +//! +//! `apps/web`'s MCP OAuth flow opens the provider's authorize URL with +//! `window.open`. In a Tauri webview that call returns `null` unconditionally +//! — WKWebView has no popup or tab concept and Tauri spawns no webview for it +//! — so the flow died with "Popup was blocked" before the user ever saw a +//! consent screen. The desktop therefore hands the authorize URL to the system +//! browser instead, which means the provider's redirect lands in Safari, in a +//! different process, with a different `localStorage`. Neither of the web +//! flow's two return channels (`postMessage` to `window.opener`, and a +//! `storage` event) can cross that boundary. +//! +//! This module is that missing channel: the browser's redirect hits THIS +//! server, which parks the authorization code and lets the webview collect it. +//! Sending users to a real browser is also the only variant that keeps working +//! as more providers are connected — Google rejects embedded webviews outright +//! (`disallowed_useragent`), so an in-app webview would have fixed GitHub and +//! then broken on the next connector. +//! +//! ## Why the two halves have different authority +//! +//! [`receive`] is reached by the SYSTEM BROWSER, which has no per-launch +//! session cookie and cannot be given one, so it is registered outside the +//! private guard. [`result`] is reached by the WEBVIEW and stays behind the +//! guard, so the authorization code is only ever readable by a caller that +//! proved it is this app. Getting that asymmetry backwards would let any local +//! process read a live authorization code off `localhost`. +//! +//! Leaving `receive` unauthenticated is safe because it is not the security +//! boundary: `state` is a `crypto.randomUUID()` the webview generated and +//! keeps in memory, and the webview refuses any callback whose state does not +//! match (`OAuth state mismatch - possible CSRF attack`). A local process that +//! wanted to inject a code would have to guess that UUID. +//! +//! ## Why this stays a dumb relay +//! +//! The stored `state` is handed back verbatim. Some providers wrap it (deco.cx +//! base64-encodes a `{clientState}` envelope), and `handleOAuthCallback` +//! already implements that unwrapping plus the CSRF comparison. Duplicating it +//! here would fork the rule across two languages and gain nothing, so Rust +//! parks bytes and the webview keeps owning the decision. + +use std::collections::HashMap; +use std::sync::{Mutex, MutexGuard, OnceLock}; +use std::time::{Duration, Instant}; + +use axum::extract::Query; +use axum::response::{Html, IntoResponse, Response}; +use axum::Json; +use serde_json::{json, Value}; + +/// How long a parked callback stays collectable. +/// +/// The webview gives up on an OAuth flow after 120s, so anything older than +/// this is from a flow nobody is waiting for anymore. +const TTL: Duration = Duration::from_secs(300); + +struct Parked { + code: Option, + state: Option, + error: Option, + at: Instant, +} + +/// A single slot, not a map keyed by state. +/// +/// Keying by state would force this module to unwrap the provider-specific +/// state envelope to find its own entry — the exact duplication the module +/// doc rules out. One slot is sufficient because a desktop user drives one +/// consent screen at a time, and it is safe because the webview clears it when +/// a flow starts ([`discard`]), reads it destructively, and still verifies the +/// state itself. +/// +/// Deliberately holds no lock of its own: the handlers own the global, and +/// keeping the transitions here as plain synchronous methods is what lets them +/// be tested directly rather than through process-global state. +#[derive(Default)] +struct Slot(Option); + +impl Slot { + /// Park one redirect. Returns whether it carried an authorization code. + fn park(&mut self, params: &HashMap) -> bool { + let error = params + .get("error_description") + .or_else(|| params.get("error")) + .cloned(); + let ok = error.is_none() && params.contains_key("code"); + self.0 = Some(Parked { + code: params.get("code").cloned(), + state: params.get("state").cloned(), + error, + at: Instant::now(), + }); + ok + } + + /// Take the parked callback, if one is present and still fresh. + /// + /// Destructive: a callback is handed out once, so a second poll for a flow + /// that already resolved cannot replay a live authorization code. + fn take(&mut self) -> Option { + self.0.take().filter(|parked| parked.at.elapsed() < TTL) + } + + fn clear(&mut self) { + self.0 = None; + } +} + +fn slot() -> MutexGuard<'static, Slot> { + static SLOT: OnceLock> = OnceLock::new(); + SLOT.get_or_init(|| Mutex::new(Slot::default())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// `GET /_auth/mcp-callback` — where the provider redirects the SYSTEM BROWSER. +/// +/// Answers with a self-contained page rather than a redirect: the browser tab +/// is a dead end by design, and the user's attention has to go back to the app +/// window. +pub async fn receive(Query(params): Query>) -> Response { + // Scoped so the guard is released before building the response — the lock + // is never held across an await. + let ok = { slot().park(¶ms) }; + Html(page(ok)).into_response() +} + +/// `GET /_auth/mcp-callback/result` — the WEBVIEW collecting the callback. +pub async fn result() -> Response { + let taken = { slot().take() }; + match taken { + Some(parked) => Json(payload(&parked)).into_response(), + None => Json(json!({ "status": "pending" })).into_response(), + } +} + +/// `DELETE /_auth/mcp-callback/result` — the WEBVIEW starting a fresh flow. +/// +/// Without this, a callback the user abandoned (consent granted, app quit +/// before collection) would still be sitting in the slot when the next flow +/// began, and the next poll would pick it up. The state check would reject it, +/// but the user would see "state mismatch — possible CSRF attack" instead of a +/// working login, so the stale entry is dropped up front. +pub async fn discard() -> Response { + { + slot().clear(); + } + Json(json!({ "ok": true })).into_response() +} + +fn payload(parked: &Parked) -> Value { + json!({ + "status": "ready", + "success": parked.error.is_none() && parked.code.is_some(), + "code": parked.code, + "state": parked.state, + "error": parked.error, + }) +} + +/// The dead-end page shown in the browser tab. +/// +/// Uses the chrome shared with desktop sign-in (`upstream::browser_page`) +/// rather than a bespoke layout: these pages are the only Studio surface a +/// user ever sees that React does not render, so a one-off would drift away +/// from the product's look on its own. +/// +/// Takes only a flag, never the query parameters: `error_description` is +/// provider-controlled text, and reflecting it would tell the user whatever +/// the provider felt like saying on a page served by the app's own origin. +/// Enforced by this signature, not by escaping, so it cannot regress. +fn page(ok: bool) -> String { + let (heading, body) = if ok { + ( + "Connection authorized", + "You can close this tab and return to Studio.", + ) + } else { + ( + "Authorization failed", + "Studio could not complete this connection.", + ) + }; + upstream::browser_page::render(upstream::browser_page::Page { + title: &format!("{heading} — deco Studio"), + heading, + body, + hint: Some("Close this tab and try again from Studio."), + destructive: !ok, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() + } + + #[test] + fn parks_a_code_for_the_webview_to_collect() { + let mut slot = Slot::default(); + assert!(slot.park(¶ms(&[("code", "abc"), ("state", "st-1")]))); + + let value = payload(&slot.take().expect("parked")); + assert_eq!(value["status"], "ready"); + assert_eq!(value["success"], true); + assert_eq!(value["code"], "abc"); + // Handed back verbatim — the webview owns unwrapping and comparing it. + assert_eq!(value["state"], "st-1"); + } + + /// A code must be collectable exactly once; a replay would hand a live + /// authorization code to whatever polled next. + #[test] + fn a_parked_callback_is_handed_out_only_once() { + let mut slot = Slot::default(); + slot.park(¶ms(&[("code", "abc"), ("state", "st-1")])); + + assert!(slot.take().is_some()); + assert!(slot.take().is_none()); + } + + #[test] + fn reports_a_denial_as_a_failure_not_a_code() { + let mut slot = Slot::default(); + assert!(!slot.park(¶ms(&[ + ("error", "access_denied"), + ("error_description", "The user denied the request"), + ("state", "st-1"), + ]))); + + let value = payload(&slot.take().expect("parked")); + assert_eq!(value["success"], false); + assert_eq!(value["error"], "The user denied the request"); + assert!(value["code"].is_null()); + } + + /// A redirect carrying neither a code nor an error is not a success. + #[test] + fn a_callback_without_a_code_is_not_successful() { + let mut slot = Slot::default(); + assert!(!slot.park(¶ms(&[("state", "st-1")]))); + assert_eq!(payload(&slot.take().expect("parked"))["success"], false); + } + + #[test] + fn discard_drops_a_callback_the_next_flow_would_otherwise_pick_up() { + let mut slot = Slot::default(); + slot.park(¶ms(&[("code", "stale"), ("state", "old")])); + slot.clear(); + + assert!(slot.take().is_none()); + } + + #[test] + fn nothing_parked_reads_as_pending() { + assert!(Slot::default().take().is_none()); + } + + /// Bare `error` is used when the provider sends no description. + #[test] + fn falls_back_to_the_bare_error_code() { + let mut slot = Slot::default(); + slot.park(¶ms(&[("error", "access_denied")])); + assert_eq!( + payload(&slot.take().expect("parked"))["error"], + "access_denied" + ); + } + + /// A callback older than the TTL belongs to a flow nobody is waiting for. + #[test] + fn an_expired_callback_is_not_handed_out() { + let mut slot = Slot::default(); + slot.park(¶ms(&[("code", "abc")])); + slot.0.as_mut().expect("parked").at = Instant::now() - TTL - Duration::from_secs(1); + + assert!(slot.take().is_none()); + } + + #[test] + fn the_success_and_failure_pages_say_different_things() { + assert!(page(true).contains("Connection authorized")); + assert!(page(false).contains("Authorization failed")); + // Only the failure page is coloured as an error. + assert!(page(false).contains("var(--destructive)")); + assert!(!page(true).contains("var(--destructive)")); + } + + /// The browser tab is the one Studio surface React never renders, so it + /// has to carry the product's own chrome rather than a bespoke layout. + #[test] + fn both_pages_wear_the_shared_studio_chrome() { + for html in [page(true), page(false)] { + assert!(html.contains("--background: oklch(0.99 0.003 73)")); + assert!(html.contains("brand-mark")); + assert!(html.contains("prefers-color-scheme: dark")); + } + } +} diff --git a/apps/native/crates/local-api/src/routes/mod.rs b/apps/native/crates/local-api/src/routes/mod.rs new file mode 100644 index 0000000000..7a92410083 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/mod.rs @@ -0,0 +1,26 @@ +//! Route handler modules, one per family — see +//! the native module-ownership contract for exactly which family owns +//! which file. `router.rs` (a shared file) is the only place these modules' +//! handlers get wired to paths; adding/removing a route is a `router.rs` +//! change, not a change here. + +pub mod bash; +pub mod config; +pub mod dispatch; +pub mod events; +pub mod fs; +pub mod git; +pub mod health; +pub mod intercept; +pub mod mcp_callback; +pub mod models; +pub mod orgfs; +pub mod proxy; +pub mod repo_dir; +pub mod scripts; +pub mod setup; +pub mod tasks; +pub mod threads; +pub mod upstream; +pub mod webdav; +pub mod ws_proxy; diff --git a/apps/native/crates/local-api/src/routes/models.rs b/apps/native/crates/local-api/src/routes/models.rs new file mode 100644 index 0000000000..a271bbc028 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/models.rs @@ -0,0 +1,88 @@ +//! `GET /models` — new. Detects which harness CLIs (`claude`, `codex`) are +//! installed/reachable/logged-in and returns the static tier catalog for +//! each. Full shape: `the native local-API contract +//! #models--tiers-endpoint`, oracle `apps/native/e2e/models.e2e.test.ts`. +//! +//! **Now-superseded** for the real production shell (see +//! `routes/intercept/link_current.rs`'s doc comment, which cross-references +//! this note): the shipped frontend (`apps/web/src`) never calls this +//! route at runtime — grep finds only a doc-comment/test reference in +//! `transport-rules.ts` — it gets harness availability exclusively via +//! `POST /api/:org/tools/LINK_CURRENT_GET` (`useCurrentLink()`), served by +//! `routes/intercept/link_current.rs` from the SAME `harness::detect` cache +//! this file reads. See +//! the native desktop-runtime audit finding #7. +//! KEPT, not deleted: exercised by this crate's own e2e oracle +//! (`models.e2e.test.ts`) and useful standalone as a selftest/e2e +//! harness-detection auth probe — reading through the same cache +//! `LINK_CURRENT_GET` uses means a probe against this route and the shipped +//! path can never disagree about which CLIs are usable. +//! +//! Phase 2: detection AND the tier catalog both come from `crates/harness` +//! (`harness::detect`, `harness::tiers`) — the CANONICAL copies, shared +//! with `routes/dispatch.rs`'s own "is this harness runnable" gate, so +//! `/models` and dispatch's `unknown_harness` gate can never disagree +//! about which CLIs are usable. `harness::detect`'s cache is process-wide +//! (not per-request), so this handler is a thin read-through: warm the +//! cache on first call (bounded — see `harness::detect::ensure_detected`'s +//! doc comment), read it on every call after. +//! +//! - `harnesses` always has exactly 2 entries, stable order +//! (`claude-code` then `codex`), even when neither is detected — NEVER +//! an empty array (`harness::HarnessId::ALL`'s order is pinned by its +//! own doc comment + unit test). +//! - "Detected" means installed AND logged in (`harness::detect`'s +//! version-probe + auth-probe), with GROW-ONLY caching so a transient +//! probe failure never un-lists a CLI mid-run (mirrors +//! `apps/api/src/link-daemon/capabilities.ts`). +//! - No Ollama/LM Studio probing in v1 (the desktop migration contract decision #5); no third +//! harness (no `"decopilot"`). + +use axum::extract::State; +use axum::Json; +use serde_json::{json, Value}; + +use crate::state::AppState; + +pub async fn list(State(_state): State) -> Json { + let cache = harness::detect::ensure_detected().await; + + let harnesses: Vec = harness::HarnessId::ALL + .iter() + .map(|&h| { + let detected = cache.get(h); + let tiers = harness::tiers::tiers_for(h); + json!({ + "harness": h.wire_id(), + "detected": detected, + "tiers": if detected { tiers.to_vec() } else { Vec::new() }, + }) + }) + .collect(); + + Json(json!({ "harnesses": harnesses })) +} + +#[cfg(test)] +mod tests { + #[test] + fn harness_order_is_claude_code_then_codex() { + assert_eq!(harness::HarnessId::ALL[0].wire_id(), "claude-code"); + assert_eq!(harness::HarnessId::ALL[1].wire_id(), "codex"); + } + + #[test] + fn every_tier_id_is_prefixed_with_its_harness() { + for h in harness::HarnessId::ALL { + for tier in harness::tiers::tiers_for(h) { + assert!( + tier.id.starts_with(&format!("{}:", h.wire_id())), + "{} does not start with {}:", + tier.id, + h.wire_id() + ); + assert!(!tier.label.is_empty()); + } + } + } +} diff --git a/apps/native/crates/local-api/src/routes/orgfs.rs b/apps/native/crates/local-api/src/routes/orgfs.rs new file mode 100644 index 0000000000..323b3a8a10 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/orgfs.rs @@ -0,0 +1,144 @@ +//! `POST /_sandbox/orgfs-config` — config-relay FILE WRITE ONLY. +//! +//! The full daemon feature (`packages/sandbox/daemon/routes/orgfs-config.ts` +//! plus `org-fs/config.ts`) is a two-part story. The first part validates +//! and relays the config to a shared-volume file a privileged sidecar +//! watches; the second part is that sidecar actually performing the rclone +//! mount. The mount part is explicitly DROPPED for local-api (see +//! the native local-API contract: +//! "No org/output/org/upload symlink plumbing; file sharing with the org +//! happens through the app-API proxy instead"). The relay part is what +//! THIS file implements, byte-parity with `makeOrgFsConfigHandler`, because +//! `daemon.e2e.test.ts`'s `orgfs-config` describe block exercises it +//! end-to-end (including a real `ORGFS_SIDECAR_CONFIG_PATH` file write) and +//! is NOT in the contract doc's dropped-tests list — only the mount lifecycle +//! is out of scope, not the relay's own request/response contract. +//! +//! `ORGFS_SIDECAR_CONFIG_PATH` is read directly from the process environment +//! per request (not threaded through `AppState`, which is a shared file this +//! family may not edit) — cheap enough that there's no need to cache it, and +//! it mirrors the TS handler's own "inert when unset" behavior exactly. + +use axum::body::Bytes; +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +use crate::error::ApiError; +use crate::state::AppState; + +pub async fn orgfs_config(State(_state): State, body: Bytes) -> Response { + if !is_valid_org_fs_config(&body) { + return ApiError::bad_request("invalid org-fs config").into_response(); + } + + let Some(config_path) = std::env::var_os("ORGFS_SIDECAR_CONFIG_PATH") else { + return Json(json!({ "written": false })).into_response(); + }; + let config_path = PathBuf::from(config_path); + + match write_relay_file(&config_path, &body) { + Ok(()) => Json(json!({ "written": true })).into_response(), + Err(_) => ApiError::internal("relay failed").into_response(), + } +} + +/// Byte-parity port of `org-fs/config.ts::parseOrgFsConfig`'s shape check +/// (`baseUrl`/`orgSlug`/`token` strings, `mounts` a non-empty array of +/// `{volume, path, readonly?}`). +fn is_valid_org_fs_config(raw: &[u8]) -> bool { + let Ok(parsed) = serde_json::from_slice::(raw) else { + return false; + }; + let Some(obj) = parsed.as_object() else { + return false; + }; + let has_str = |key: &str| obj.get(key).and_then(Value::as_str).is_some(); + if !has_str("baseUrl") || !has_str("orgSlug") || !has_str("token") { + return false; + } + let Some(mounts) = obj.get("mounts").and_then(Value::as_array) else { + return false; + }; + let valid_mounts = mounts.iter().filter(|m| is_valid_mount(m)).count(); + valid_mounts > 0 +} + +fn is_valid_mount(mount: &Value) -> bool { + let Some(obj) = mount.as_object() else { + return false; + }; + let volume_ok = obj.get("volume").and_then(Value::as_str).is_some(); + let path_ok = obj.get("path").and_then(Value::as_str).is_some(); + let readonly_ok = obj.get("readonly").is_none_or(Value::is_boolean); + volume_ok && path_ok && readonly_ok +} + +/// Atomic write: the sidecar must never read a torn file — byte-parity with +/// the TS handler's write-to-tmp-then-rename sequence. +fn write_relay_file(path: &Path, contents: &[u8]) -> std::io::Result<()> { + let dir = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(dir)?; + let tmp = dir.join(format!(".config-{}.tmp", std::process::id())); + std::fs::write(&tmp, contents)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_body() -> Vec { + serde_json::to_vec(&json!({ + "baseUrl": "https://cluster.example", + "orgSlug": "acme", + "token": "fs-scoped-token", + "mounts": [{"volume": "skills", "path": "skills", "readonly": true}], + })) + .unwrap() + } + + #[test] + fn valid_config_passes() { + assert!(is_valid_org_fs_config(&valid_body())); + } + + #[test] + fn missing_fields_are_invalid() { + assert!(!is_valid_org_fs_config(b"{}")); + assert!(!is_valid_org_fs_config( + br#"{"not":"a valid org-fs config"}"# + )); + } + + #[test] + fn empty_mounts_array_is_invalid() { + let body = json!({ + "baseUrl": "https://cluster.example", + "orgSlug": "acme", + "token": "t", + "mounts": [], + }); + assert!(!is_valid_org_fs_config(&serde_json::to_vec(&body).unwrap())); + } + + #[test] + fn malformed_json_is_invalid() { + assert!(!is_valid_org_fs_config(b"}{not json")); + } + + #[test] + fn write_relay_file_creates_parent_and_is_atomic() { + let dir = tempfile::tempdir().expect("tempdir"); + let target = dir.path().join("nested").join("orgfs.json"); + write_relay_file(&target, b"hello").expect("write ok"); + let contents = std::fs::read(&target).expect("read back"); + assert_eq!(contents, b"hello"); + } +} diff --git a/apps/native/crates/local-api/src/routes/proxy.rs b/apps/native/crates/local-api/src/routes/proxy.rs new file mode 100644 index 0000000000..d44487f660 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/proxy.rs @@ -0,0 +1,1430 @@ +//! Reverse-HTTP-proxy catch-all — ANY request to the PREVIEW listener lands +//! here (`router.rs`'s `build_preview`); this is the ONLY route that +//! listener serves. Byte-parity target: `daemon/proxy.ts`, oracle +//! `daemon.proxy.e2e.test.ts`. +//! +//! Moved off the MAIN listener (which used to serve this as its top-level +//! fallback for any path outside `/health`, `/_sandbox/*`, `/threads*`, +//! `/models`, `/upstream/*`) onto its own dedicated loopback port — see +//! `lib.rs`/`router.rs`'s module docs for why: the app's own API and the +//! previewed dev server now need to be genuinely different origins. +//! +//! NO AUTH is applied to this family (byte-parity — the daemon doesn't gate +//! these either; this is the app-under-test being previewed, not the +//! control API). `router.rs`'s `build_preview` wires this as that router's +//! ONLY route, its top-level `.fallback()`, with no middleware stack at +//! all — do not add auth inside this handler; if that invariant ever needs +//! to change it's a `router.rs` change, not a change here. For the same +//! reason, the +//! placeholder pages below (no-upstream/starting/no-web-page/proxy-error) +//! carry the daemon's own `Access-Control-Allow-Origin: *` — byte-parity +//! with `daemon/proxy.ts`, and NOT a violation of +//! the native local-API contract's "no wildcard CORS" rule, +//! which scopes to the bearer-gated zone-2 surface (`cors.rs`) that this +//! family is explicitly excluded from. +//! +//! ## Dev-port resolution — sniffed port, per-handle routing +//! +//! The daemon's `getDevPort()` is `portSniffer.current() ?? application.port +//! ?? null` (`entry.ts`) — the sniffed port (from a spawned dev script's +//! stdout, `process/port-sniffer.ts`) wins over the statically configured +//! one. This crate's [`crate::setup::SetupOrchestrator`] already captures +//! that same sniffed port (`setup/dev.rs`'s `confirm_running` transitions +//! `lifecycle` to `{"phase":"running","port":,...}`) — the gap this +//! file used to have was never "no sniffed port exists," it was "this file +//! never reads `state.setup.lifecycle_snapshot()`," fixed by [`dev_port`] +//! below reading it first and falling back to the static `application.port` +//! only when the orchestrator hasn't reached `running` yet. +//! +//! Per the native Git-sandbox contract's preview section, a +//! git-backed thread's dev server runs under its OWN per-handle +//! `SandboxManager`-owned orchestrator, not the process-global one — so +//! there is no longer a SINGLE dev port for the whole process once more +//! than one sandbox handle is active. [`dev_port`] resolves which +//! orchestrator to read from a request header, +//! [`crate::sandbox::SANDBOX_HANDLE_HEADER`] +//! (`x-decocms-sandbox-handle`): present + a known handle -> that handle's +//! own `Sandbox.setup`/`Sandbox.config`; absent (or an unrecognized handle) +//! -> the plain global path, UNCHANGED from before this file's git-sandbox +//! work (byte-parity with "non-git threads keep today's behavior"). A path +//! prefix (`…/preview//…`) was the other option the design doc +//! floated; a header was chosen because it doesn't require rewriting the +//! proxied request's path before forwarding it upstream. **Known gap**: the +//! webview/frontend side of actually SETTING this header on a preview +//! iframe's requests is not wired by this change (the existing preview URL +//! is computed server-side, via a different pipeline, for the single +//! process-global sandbox) — this file/[`crate::sandbox`] only implement +//! the backend capability; see the native Git-sandbox contract +//! for the follow-up this leaves. +//! +//! ## Testability +//! +//! The actual proxying logic is split out as `proxy_to_port`/`bridge_ws`, +//! which take an already-resolved `u16` port rather than reading +//! `AppState` — so this file's own unit tests can exercise HTML injection, +//! header stripping, and the placeholder-page branches end-to-end against a +//! real loopback `TcpListener` without needing the `config` family's +//! `POST /_sandbox/config` (still a 501 stub as of this writing) to be +//! implemented first. + +use std::sync::Arc; +use std::time::Duration; + +use axum::{ + body::Body, + extract::{ws::WebSocketUpgrade, Extension, FromRequestParts, State}, + http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::{json, Value}; + +use super::ws_proxy; +use crate::state::AppState; + +type Request = axum::extract::Request; + +/// Guards the *headers* phase of the upstream fetch only — mirrors +/// `proxy.ts`'s comment: "a hung dev server shouldn't pin a request slot +/// forever... once headers arrive we cancel the timer: SSE / NDJSON / +/// long-poll bodies must be allowed to stream indefinitely." Achieved here +/// by wrapping only the `.send()` future (which resolves once the response +/// status/headers are in) in `tokio::time::timeout`, never the subsequent +/// body stream. +const HEADERS_TIMEOUT: Duration = Duration::from_secs(60); + +/// Generous but bounded — matches the daemon's unbounded `req.arrayBuffer()` +/// in spirit while keeping a single misbehaving client from parking an +/// unbounded allocation in this process (a desktop app's dev-preview proxy, +/// unlike the daemon's, has no surrounding cluster-level body-size guard). +const MAX_PROXY_BODY_BYTES: usize = 100 * 1024 * 1024; + +const PREVIEW_COOKIE_SELFTEST_NAME: &str = "decocms-preview-selftest"; + +const NO_UPSTREAM_HTML: &str = r#"No dev server

No dev server running

Start one in this sandbox (e.g. bun run dev) and the preview will appear here automatically.

"#; + +const STARTING_HTML: &str = r#"Starting...

Server is starting…

This page will refresh automatically.

"#; + +const NO_WEB_PAGE_HTML: &str = r#"No web page

No web page at this URL

The dev server is running but doesn't serve HTML at /. The preview only renders web pages — open the logs to see what's running.

"#; + +/// HTML injected before `` so the preview iframe can talk to the +/// parent — byte-parity with `packages/sandbox/shared.ts`'s +/// `IFRAME_BOOTSTRAP_SCRIPT` (aliased as `BOOTSTRAP_SCRIPT` in +/// `daemon/constants.ts`). +const BOOTSTRAP_SCRIPT: &str = r#""#; + +#[derive(Clone)] +pub(crate) struct PreviewCookieSelftest { + pub control_origin: Arc, + pub expected_host: Arc, + pub cookie_value: Arc, +} + +pub async fn fallback(State(state): State, req: Request) -> Response { + if is_websocket_upgrade(req.headers()) { + return handle_ws_upgrade(state, req).await; + } + + match resolve_preview(&state, req.headers()) { + PreviewResolution::Port(port) => proxy_to_port(port, req).await, + PreviewResolution::Starting => starting_response(), + PreviewResolution::NoServer => no_upstream_response(), + } +} + +/// Selftest-only credentialed cross-origin cookie probe. This route is mounted +/// only when the embedding shell explicitly enables it; normal builds proxy +/// this path to the sandbox like every other preview request. +pub(crate) async fn preview_cookie_selftest( + Extension(config): Extension, + method: Method, + headers: HeaderMap, +) -> Response { + if !has_exact_single_header(&headers, header::HOST, &config.expected_host) + || !has_exact_single_header(&headers, header::ORIGIN, &config.control_origin) + { + return StatusCode::FORBIDDEN.into_response(); + } + + if method == Method::OPTIONS { + if headers + .get(header::ACCESS_CONTROL_REQUEST_METHOD) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| !value.eq_ignore_ascii_case("GET")) + { + return StatusCode::FORBIDDEN.into_response(); + } + let mut response = StatusCode::NO_CONTENT.into_response(); + apply_preview_selftest_cors(response.headers_mut(), &config.control_origin); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("GET, OPTIONS"), + ); + response.headers_mut().insert( + header::VARY, + HeaderValue::from_static( + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers", + ), + ); + if let Some(requested_headers) = headers.get(header::ACCESS_CONTROL_REQUEST_HEADERS) { + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + requested_headers.clone(), + ); + } + return response; + } + + let received = cookie_has_value(&headers, PREVIEW_COOKIE_SELFTEST_NAME, &config.cookie_value); + let mut response = Json(json!({ "received": received })).into_response(); + apply_preview_selftest_cors(response.headers_mut(), &config.control_origin); + if !received { + let set_cookie = format!( + "{PREVIEW_COOKIE_SELFTEST_NAME}={}; Path=/; HttpOnly; SameSite=Strict", + config.cookie_value + ); + let Ok(set_cookie) = HeaderValue::from_str(&set_cookie) else { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + response + .headers_mut() + .append(header::SET_COOKIE, set_cookie); + } + response +} + +/// Selftest-only iframe target. Navigations do not reliably carry `Origin`, +/// so this endpoint validates the exact preview Host instead and returns a +/// deterministic 200 document without mutating the cookie jar. +pub(crate) async fn preview_frame_selftest( + Extension(config): Extension, + headers: HeaderMap, +) -> Response { + if !has_exact_single_header(&headers, header::HOST, &config.expected_host) { + return StatusCode::FORBIDDEN.into_response(); + } + let Ok(target_origin) = serde_json::to_string(config.control_origin.as_ref()) else { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + let target_origin = target_origin + .replace('<', "\\u003c") + .replace('>', "\\u003e"); + let html = format!( + "" + ); + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .header(header::CACHE_CONTROL, "no-store") + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .body(Body::from(html)) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) +} + +fn has_exact_single_header( + headers: &HeaderMap, + name: axum::http::header::HeaderName, + expected: &str, +) -> bool { + let mut values = headers.get_all(name).iter(); + values.next().and_then(|value| value.to_str().ok()) == Some(expected) && values.next().is_none() +} + +fn apply_preview_selftest_cors(headers: &mut HeaderMap, control_origin: &str) { + if let Ok(origin) = HeaderValue::from_str(control_origin) { + headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin); + } + headers.insert( + header::ACCESS_CONTROL_ALLOW_CREDENTIALS, + HeaderValue::from_static("true"), + ); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + headers.insert(header::VARY, HeaderValue::from_static("Origin")); +} + +fn cookie_has_value(headers: &HeaderMap, name: &str, expected: &str) -> bool { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(';')) + .filter_map(|part| part.trim().split_once('=')) + .any(|(candidate, value)| candidate == name && value == expected) +} + +/// `POST /_sandbox/preview-handle {"handle": "..."}` — points the headerless +/// preview (the iframe, which can't set +/// [`crate::sandbox::SANDBOX_HANDLE_HEADER`]) at a +/// specific sandbox. The webview calls this when a git-backed thread becomes +/// focused, so switching threads updates the preview even without re-running +/// (a fresh dispatch also sets it, via `SandboxManager::ensure`). The handle +/// must have a durable registry row: accepting a frontend-computed phantom +/// would persist an active pointer that cannot ever resolve to repo config. +pub async fn set_preview_handle( + State(state): State, + axum::Json(body): axum::Json, +) -> Response { + match body.get("handle").and_then(Value::as_str) { + Some(handle) if !handle.is_empty() => match state.sandbox_manager.set_active(handle) { + Ok(()) => (StatusCode::OK, axum::Json(json!({ "ok": true }))).into_response(), + Err(error) if error.starts_with("unknown sandbox handle:") => { + (StatusCode::NOT_FOUND, axum::Json(json!({ "error": error }))).into_response() + } + Err(error) => ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({ "error": error })), + ) + .into_response(), + }, + _ => ( + StatusCode::BAD_REQUEST, + axum::Json(json!({ "error": "handle is required" })), + ) + .into_response(), + } +} + +/// `getDevPort()`: the sniffed port from a `running` orchestrator, else the +/// statically configured `application.port` — resolved against a SPECIFIC +/// sandbox handle's own orchestrator/config when +/// [`crate::sandbox::SANDBOX_HANDLE_HEADER`] names one this process knows +/// about, else the process-global `state.setup`/`state.config` pair (unchanged +/// plain-path behavior). See this file's module doc. +/// +/// KEPT (not replaced by [`resolve_preview`]): the WS-upgrade path and this +/// file's `dev_port_*` tests still drive `dev_port` directly, and its +/// unknown-handle -> `None` rule (never preview the wrong branch) is +/// deliberately NOT the observability fallback [`resolve_preview`] uses. +fn dev_port(state: &AppState, headers: &HeaderMap) -> Option { + match crate::sandbox::handle_from_headers(headers) { + Some(handle) => { + let sandbox = state.sandbox_manager.get(handle)?; + dev_port_for(&sandbox.setup, &sandbox.config) + } + // Headerless request (e.g. a preview iframe, which can't set the + // handle header): serve the ACTIVE sandbox's dev server if a + // git-backed thread has been dispatched, else the global (non-git) + // orchestrator — same behavior as before for the plain path. + None => match state.sandbox_manager.active() { + Some(sandbox) => dev_port_for(&sandbox.setup, &sandbox.config), + None => dev_port_for(&state.setup, &state.config), + }, + } +} + +/// `portSniffer.current() ?? application.port ?? null` for one +/// The dev port of an [`AppState`] whose target is already the sandbox in +/// question — i.e. one built by `state_for_sandbox`, where the "global" +/// orchestrator/config pair IS that sandbox's. +pub(crate) fn target_dev_port(state: &AppState) -> Option { + dev_port_for(&state.setup, &state.config) +} + +/// The dev port of one already-resolved sandbox — for callers that hold a +/// [`crate::sandbox::Sandbox`] rather than a request's headers (the +/// `preview-invoke` intercept, which addresses a sandbox by id+branch). +pub(crate) fn sandbox_dev_port(sandbox: &crate::sandbox::manager::Sandbox) -> Option { + dev_port_for(&sandbox.setup, &sandbox.config) +} + +/// orchestrator/config pair — shared by the process-global path and every +/// per-handle [`crate::sandbox::Sandbox`]. +fn dev_port_for( + setup: &crate::setup::SetupOrchestrator, + config: &crate::config::ConfigStore, +) -> Option { + let lifecycle = setup.lifecycle_snapshot(); + if lifecycle.get("phase").and_then(Value::as_str) == Some("running") { + if let Some(port) = lifecycle + .get("port") + .and_then(Value::as_u64) + .and_then(|p| u16::try_from(p).ok()) + { + return Some(port); + } + } + let snapshot = config.snapshot(); + let cfg = snapshot.config?; + let port = cfg.get("application")?.get("port")?.as_u64()?; + u16::try_from(port).ok() +} + +/// Phase-aware outcome for the preview `fallback` — distinguishes "a dev +/// server is bound, proxy to it" from "a sandbox exists and is provisioning, +/// show the spinner" from "nothing to preview." Compare with [`dev_port`], +/// which collapses the middle case into `None`: the WS path has no HTML +/// placeholder, so it keeps using `dev_port`; only `fallback` (which CAN serve +/// [`STARTING_HTML`]) needs this distinction. See plan D4. +enum PreviewResolution { + Port(u16), + Starting, + NoServer, +} + +/// Resolves the preview outcome using the SAME handle -> `get(handle)` -> +/// `active()` -> global shape as [`dev_port`] (an unknown explicit handle +/// stays `NoServer`, never previewing the wrong branch), then maps a +/// no-dev-port result to `Starting` vs `NoServer` by the resolved +/// orchestrator's lifecycle phase. NOT shared with `dev_port` on purpose — see +/// [`dev_port`]'s doc and plan D1/D4. +fn resolve_preview(state: &AppState, headers: &HeaderMap) -> PreviewResolution { + match crate::sandbox::handle_from_headers(headers) { + Some(handle) => match state.sandbox_manager.get(handle) { + Some(sandbox) => preview_from(&sandbox.setup, &sandbox.config), + // Unknown explicit handle: never fall back to the wrong branch. + None => PreviewResolution::NoServer, + }, + None => match state.sandbox_manager.active() { + Some(sandbox) => preview_from(&sandbox.setup, &sandbox.config), + None => preview_from(&state.setup, &state.config), + }, + } +} + +/// Maps one orchestrator/config pair to a [`PreviewResolution`]: a resolvable +/// dev port -> `Port`; else the lifecycle phase decides `Starting` (the +/// sandbox exists and is provisioning) vs `NoServer` (idle / no config / +/// terminal-failed). +fn preview_from( + setup: &crate::setup::SetupOrchestrator, + config: &crate::config::ConfigStore, +) -> PreviewResolution { + match dev_port_for(setup, config) { + Some(port) => PreviewResolution::Port(port), + None => match setup + .lifecycle_snapshot() + .get("phase") + .and_then(Value::as_str) + { + Some("cloning" | "checking-out" | "installing" | "starting" | "running") => { + PreviewResolution::Starting + } + _ => PreviewResolution::NoServer, + }, + } +} + +fn is_websocket_upgrade(headers: &HeaderMap) -> bool { + headers + .get(header::UPGRADE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.eq_ignore_ascii_case("websocket")) +} + +async fn handle_ws_upgrade(state: AppState, req: Request) -> Response { + let port = dev_port(&state, req.headers()); + let (mut parts, _body) = req.into_parts(); + let path_and_query = parts + .uri + .path_and_query() + .map(|pq| pq.as_str().to_string()) + .unwrap_or_else(|| parts.uri.path().to_string()); + let requested_protocols: Vec = parts + .headers + .get(header::SEC_WEBSOCKET_PROTOCOL) + .and_then(|v| v.to_str().ok()) + .map(|v| v.split(',').map(|p| p.trim().to_string()).collect()) + .unwrap_or_default(); + let request_headers = parts.headers.clone(); + + match WebSocketUpgrade::from_request_parts(&mut parts, &state).await { + Ok(upgrade) => { + ws_proxy::upgrade( + upgrade, + port, + path_and_query, + requested_protocols, + request_headers, + ) + .await + } + Err(rejection) => rejection.into_response(), + } +} + +/// Given an already-resolved dev-server port, reverse-proxies `req` to it. +/// Split out from `fallback` so unit tests can drive it directly against a +/// real loopback listener without needing `AppState`/`ConfigStore` — see +/// this file's module doc. +async fn proxy_to_port(port: u16, req: Request) -> Response { + let (parts, body) = req.into_parts(); + let is_root = parts.uri.path() == "/"; + let path_and_query = parts + .uri + .path_and_query() + .map(|pq| pq.as_str().to_string()) + .unwrap_or_else(|| "/".to_string()); + + let mut out_headers = parts.headers.clone(); + // Byte-parity with `proxy.ts`: don't ask the dev server to compress (we + // may need to read+rewrite an HTML body). Cookie and Authorization are + // end-to-end application headers on this dedicated preview listener: + // local-api authentication never runs here, so stripping either would + // silently break the sandboxed application's own sessions. + out_headers.remove(header::ACCEPT_ENCODING); + strip_hop_by_hop_headers(&mut out_headers); + out_headers.remove(header::CONTENT_LENGTH); + + let body_bytes = match axum::body::to_bytes(body, MAX_PROXY_BODY_BYTES).await { + Ok(b) => b, + Err(err) => return proxy_error_response(&format!("failed to read request body: {err}")), + }; + + match send_to_loopback( + port, + &parts.method, + &path_and_query, + out_headers, + body_bytes, + ) + .await + { + Ok(upstream) => build_success_response(upstream, is_root).await, + Err(msg) => { + tracing::warn!(port, path = %path_and_query, error = %msg, "proxy error"); + if is_root { + starting_response() + } else { + proxy_error_response(&msg) + } + } + } +} + +fn proxy_client() -> &'static reqwest::Client { + static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + // Byte-parity with `proxy.ts`'s `redirect: "manual"` — a 3xx + // from the dev server passes straight through to the browser + // instead of being silently followed here. + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) + }) +} + +/// Tries `[::1]` then `127.0.0.1`, matching `proxy/loopback.ts`'s +/// dual-stack preference (some dev servers bind IPv6-only, some IPv4-only). +/// Only retries the second host on a genuine connect-phase failure — a +/// mid-flight failure after a connection was established is never retried, +/// since re-sending a non-idempotent request risks a double write upstream +/// (same reasoning as `fetchLoopback`). +async fn send_to_loopback( + port: u16, + method: &axum::http::Method, + path_and_query: &str, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result { + let client = proxy_client(); + let hosts = ["[::1]", "127.0.0.1"]; + let mut last_err: Option = None; + for (idx, host) in hosts.iter().enumerate() { + let url = format!("http://{host}:{port}{path_and_query}"); + let builder = client + .request(method.clone(), &url) + .headers(headers.clone()) + .body(body.clone()); + match tokio::time::timeout(HEADERS_TIMEOUT, builder.send()).await { + Ok(Ok(resp)) => return Ok(resp), + Ok(Err(e)) => { + let is_last = idx == hosts.len() - 1; + if !is_last && e.is_connect() { + last_err = Some(e.to_string()); + continue; + } + return Err(e.to_string()); + } + Err(_elapsed) => return Err("upstream headers timeout".to_string()), + } + } + Err(last_err.unwrap_or_else(|| "upstream unreachable".to_string())) +} + +async fn build_success_response(upstream: reqwest::Response, is_root: bool) -> Response { + let status = upstream.status(); + if status.as_u16() >= 500 { + tracing::warn!(status = status.as_u16(), "proxy upstream error"); + } + + let mut resp_headers = upstream.headers().clone(); + strip_hop_by_hop_headers(&mut resp_headers); + resp_headers.remove("x-frame-options"); + resp_headers.remove("content-security-policy"); + resp_headers.remove("content-security-policy-report-only"); + + let content_type = upstream + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + + if content_type.contains("text/html") { + resp_headers.remove(header::CONTENT_LENGTH); + resp_headers.remove(header::CONTENT_ENCODING); + let text = match upstream.text().await { + Ok(t) => t, + Err(err) => { + return proxy_error_response(&format!("failed reading upstream body: {err}")) + } + }; + let injected = inject_bootstrap_script(&text); + return build_response(status, resp_headers, Body::from(injected)); + } + + // Root document that isn't HTML (or has no Content-Type at all): render + // the dedicated notice instead of dumping raw JSON/text into the + // iframe. Sub-paths pass through untouched. + if is_root { + // Drain the body so the connection can be reused/closed cleanly — + // mirrors `upstream.body?.cancel()`. + let _ = upstream.bytes().await; + return no_web_page_response(resp_headers); + } + + let body = Body::from_stream(upstream.bytes_stream()); + build_response(status, resp_headers, body) +} + +/// Removes headers that describe one HTTP connection rather than the proxied +/// message. `Connection` may name additional hop-by-hop fields, so capture its +/// tokens before removing the standard set. +fn strip_hop_by_hop_headers(headers: &mut HeaderMap) { + let connection_tokens: Vec = headers + .get_all(header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .filter_map(|name| HeaderName::from_bytes(name.trim().as_bytes()).ok()) + .collect(); + + for name in connection_tokens { + headers.remove(name); + } + for name in [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", + ] { + headers.remove(name); + } +} + +fn inject_bootstrap_script(html: &str) -> String { + match html.rfind("") { + Some(idx) => { + let mut out = String::with_capacity(html.len() + BOOTSTRAP_SCRIPT.len()); + out.push_str(&html[..idx]); + out.push_str(BOOTSTRAP_SCRIPT); + out.push_str(&html[idx..]); + out + } + None => format!("{html}{BOOTSTRAP_SCRIPT}"), + } +} + +fn build_response(status: StatusCode, headers: HeaderMap, body: Body) -> Response { + let mut res = Response::new(body); + *res.status_mut() = status; + *res.headers_mut() = headers; + res +} + +fn no_upstream_response() -> Response { + placeholder_html(StatusCode::SERVICE_UNAVAILABLE, NO_UPSTREAM_HTML, &[]) +} + +fn starting_response() -> Response { + placeholder_html( + StatusCode::SERVICE_UNAVAILABLE, + STARTING_HTML, + &[(header::RETRY_AFTER, HeaderValue::from_static("1"))], + ) +} + +fn no_web_page_response(mut headers: HeaderMap) -> Response { + // This response replaces only the upstream body so the preview iframe + // shows a useful notice. Preserve application metadata such as Set-Cookie + // and custom headers; only representation/framing headers may describe + // the replacement document instead. + headers.remove(header::CONTENT_LENGTH); + headers.remove(header::CONTENT_ENCODING); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + headers.insert( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ); + build_response(StatusCode::OK, headers, Body::from(NO_WEB_PAGE_HTML)) +} + +/// Every placeholder page byte-parities the daemon's own wildcard CORS on +/// this specific response family — see this file's module doc. +fn placeholder_html( + status: StatusCode, + body: &'static str, + extra_headers: &[(axum::http::HeaderName, HeaderValue)], +) -> Response { + let mut builder = Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "text/html; charset=utf-8") + .header(header::CACHE_CONTROL, "no-store") + .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"); + for (name, value) in extra_headers { + builder = builder.header(name, value); + } + builder + .body(Body::from(body)) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) +} + +fn proxy_error_response(msg: &str) -> Response { + ( + StatusCode::BAD_GATEWAY, + [( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + )], + Json(json!({ "error": format!("proxy error: {msg}") })), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::to_bytes; + use axum::routing::{get, post}; + use tokio::net::TcpListener; + + /// Minimal loopback HTTP server for exercising `proxy_to_port` + /// end-to-end without any daemon/e2e harness — see the module doc's + /// "Testability" section. Built on `axum::serve` (already a dependency) + /// rather than a hand-rolled hyper service, since it's only ever the + /// test-side stand-in for "some dev server". + async fn spawn_upstream(app: axum::Router) -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + port + } + + fn empty_request(method: &str, uri: &str) -> Request { + axum::extract::Request::builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .unwrap() + } + + #[tokio::test] + async fn no_dev_port_returns_503_placeholder() { + let res = no_upstream_response(); + assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + res.headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .unwrap(), + "*" + ); + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + assert!(String::from_utf8_lossy(&body).contains("No dev server running")); + } + + #[tokio::test] + async fn dead_port_at_root_is_starting_page() { + let free_port = { + // Reserve then immediately drop a port so nothing listens there. + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + let res = proxy_to_port(free_port, empty_request("GET", "/")).await; + assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(res.headers().get(header::RETRY_AFTER).unwrap(), "1"); + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + assert!(String::from_utf8_lossy(&body).contains("Server is starting")); + } + + #[tokio::test] + async fn dead_port_at_non_root_is_502_json() { + let free_port = { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + let res = proxy_to_port(free_port, empty_request("GET", "/api/thing")).await; + assert_eq!(res.status(), StatusCode::BAD_GATEWAY); + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(json["error"].as_str().unwrap().contains("proxy error")); + } + + #[tokio::test] + async fn html_upstream_injects_bootstrap_and_strips_xfo_csp() { + let app = axum::Router::new().route( + "/", + get(|| async { + Response::builder() + .header(header::CONTENT_TYPE, "text/html") + .header("x-frame-options", "DENY") + .header("content-security-policy", "default-src 'none'") + .body(Body::from("

hi

")) + .unwrap() + }), + ); + let port = spawn_upstream(app).await; + + let res = proxy_to_port(port, empty_request("GET", "/")).await; + assert_eq!(res.status(), StatusCode::OK); + assert!(res.headers().get("x-frame-options").is_none()); + assert!(res.headers().get("content-security-policy").is_none()); + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + let text = String::from_utf8_lossy(&body); + let script_idx = text.find("").unwrap(); + assert!(script_idx < body_close_idx); + } + + #[tokio::test] + async fn http1_preview_reframes_chunked_html_without_an_empty_reply() { + let upstream = axum::Router::new().route( + "/", + get(|| async { + let chunks = futures_util::stream::iter([ + Ok::<_, std::convert::Infallible>(axum::body::Bytes::from_static( + b"", + )), + Ok(axum::body::Bytes::from_static( + b"chunked page", + )), + ]); + Response::builder() + .header(header::CONTENT_TYPE, "text/html") + .header(header::CONNECTION, "keep-alive, x-upstream-hop") + .header("keep-alive", "timeout=5") + .header("x-upstream-hop", "remove-me") + .header("x-end-to-end", "keep-me") + .body(Body::from_stream(chunks)) + .unwrap() + }), + ); + let upstream_port = spawn_upstream(upstream).await; + let preview = axum::Router::new() + .fallback(move |req: Request| async move { proxy_to_port(upstream_port, req).await }); + let preview_port = spawn_upstream(preview).await; + + let client = reqwest::Client::builder().http1_only().build().unwrap(); + let res = client + .get(format!("http://127.0.0.1:{preview_port}/")) + .send() + .await + .expect("the HTTP/1.1 preview must return a framed response"); + + assert_eq!(res.status(), StatusCode::OK); + assert!(res.headers().get(header::CONNECTION).is_none()); + assert!(res.headers().get("keep-alive").is_none()); + assert!(res.headers().get("x-upstream-hop").is_none()); + assert_eq!(res.headers().get("x-end-to-end").unwrap(), "keep-me"); + let body = res.text().await.unwrap(); + assert!(body.contains("chunked page")); + assert!(body.contains(BOOTSTRAP_SCRIPT)); + } + + #[test] + fn strips_standard_and_connection_nominated_hop_by_hop_headers() { + let mut headers = HeaderMap::new(); + headers.append( + header::CONNECTION, + HeaderValue::from_static("keep-alive, x-remove-me"), + ); + headers.append(header::CONNECTION, HeaderValue::from_static("x-remove-too")); + headers.insert("keep-alive", HeaderValue::from_static("timeout=5")); + headers.insert( + header::TRANSFER_ENCODING, + HeaderValue::from_static("chunked"), + ); + headers.insert("x-remove-me", HeaderValue::from_static("one")); + headers.insert("x-remove-too", HeaderValue::from_static("two")); + headers.insert("x-end-to-end", HeaderValue::from_static("keep")); + + strip_hop_by_hop_headers(&mut headers); + + assert!(headers.get(header::CONNECTION).is_none()); + assert!(headers.get("keep-alive").is_none()); + assert!(headers.get(header::TRANSFER_ENCODING).is_none()); + assert!(headers.get("x-remove-me").is_none()); + assert!(headers.get("x-remove-too").is_none()); + assert_eq!(headers.get("x-end-to-end").unwrap(), "keep"); + } + + #[tokio::test] + async fn non_html_at_root_is_no_web_page_notice() { + let app = axum::Router::new().route( + "/", + get(|| async { + Response::builder() + .header(header::CONTENT_TYPE, "application/json") + .header( + header::SET_COOKIE, + "sandbox-session=abc; Path=/; HttpOnly; SameSite=Lax", + ) + .header( + header::SET_COOKIE, + "sandbox-theme=dark; Path=/; SameSite=Strict", + ) + .header(header::AUTHORIZATION, "Bearer sandbox-response") + .header("x-sandbox-app", "preserved") + .body(Body::from(r#"{"ok":true}"#)) + .unwrap() + }), + ); + let port = spawn_upstream(app).await; + + let res = proxy_to_port(port, empty_request("GET", "/")).await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers()[header::CONTENT_TYPE], + "text/html; charset=utf-8" + ); + assert_eq!( + res.headers()[header::AUTHORIZATION], + "Bearer sandbox-response" + ); + assert_eq!(res.headers()["x-sandbox-app"], "preserved"); + let cookies = res + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| value.to_str().unwrap()) + .collect::>(); + assert_eq!( + cookies, + [ + "sandbox-session=abc; Path=/; HttpOnly; SameSite=Lax", + "sandbox-theme=dark; Path=/; SameSite=Strict", + ] + ); + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + assert!(String::from_utf8_lossy(&body).contains("No web page at this URL")); + } + + #[tokio::test] + async fn non_root_pass_through_strips_xfo_and_forwards_body() { + let app = axum::Router::new().route( + "/api/echo", + post(|body: axum::body::Bytes| async move { + assert_eq!(body.as_ref(), br#"{"hello":"world"}"#); + Response::builder() + .header("x-frame-options", "DENY") + .header(header::CONTENT_TYPE, "application/json") + .header(header::CONTENT_ENCODING, "sandbox-test-codec") + .header("x-sandbox-app", "preserved") + .body(Body::from(r#"{"ok":true}"#)) + .unwrap() + }), + ); + let port = spawn_upstream(app).await; + + let req = axum::extract::Request::builder() + .method("POST") + .uri("/api/echo") + .header("content-type", "application/json") + .body(Body::from(r#"{"hello":"world"}"#)) + .unwrap(); + let res = proxy_to_port(port, req).await; + assert_eq!(res.status(), StatusCode::OK); + assert!(res.headers().get("x-frame-options").is_none()); + assert_eq!( + res.headers()[header::CONTENT_ENCODING], + "sandbox-test-codec" + ); + assert_eq!(res.headers()["x-sandbox-app"], "preserved"); + } + + #[tokio::test] + async fn forwards_application_cookie_and_authorization() { + let app = axum::Router::new().route( + "/api/sniff", + get(|headers: HeaderMap| async move { + let seen_auth = headers + .get("authorization") + .map(|v| v.to_str().unwrap().to_string()); + let seen_cookie = headers + .get("cookie") + .map(|v| v.to_str().unwrap().to_string()); + Response::builder() + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + json!({ "seenAuth": seen_auth, "seenCookie": seen_cookie }).to_string(), + )) + .unwrap() + }), + ); + let port = spawn_upstream(app).await; + + let req = axum::extract::Request::builder() + .method("GET") + .uri("/api/sniff") + .header("authorization", "Bearer application-token") + .header( + "cookie", + "decocms-local-session=control-secret; sandbox-session=abc", + ) + .body(Body::empty()) + .unwrap(); + let res = proxy_to_port(port, req).await; + assert_eq!(res.status(), StatusCode::OK); + let body = to_bytes(res.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + String::from_utf8_lossy(&body), + r#"{"seenAuth":"Bearer application-token","seenCookie":"decocms-local-session=control-secret; sandbox-session=abc"}"# + ); + } + + #[tokio::test] + async fn forwards_every_application_set_cookie_header() { + let app = axum::Router::new().route( + "/api/session", + get(|| async { + Response::builder() + .header(header::CONTENT_TYPE, "application/json") + .header( + header::SET_COOKIE, + "sandbox-session=abc; Path=/; HttpOnly; SameSite=Lax", + ) + .header( + header::SET_COOKIE, + "sandbox-theme=dark; Path=/; SameSite=Strict", + ) + .body(Body::from(r#"{"ok":true}"#)) + .unwrap() + }), + ); + let port = spawn_upstream(app).await; + + let res = proxy_to_port(port, empty_request("GET", "/api/session")).await; + assert_eq!(res.status(), StatusCode::OK); + let cookies = res + .headers() + .get_all(header::SET_COOKIE) + .iter() + .map(|value| value.to_str().unwrap()) + .collect::>(); + assert_eq!( + cookies, + [ + "sandbox-session=abc; Path=/; HttpOnly; SameSite=Lax", + "sandbox-theme=dark; Path=/; SameSite=Strict", + ] + ); + } + + #[test] + fn detects_websocket_upgrade_header() { + let mut headers = HeaderMap::new(); + headers.insert(header::UPGRADE, HeaderValue::from_static("websocket")); + assert!(is_websocket_upgrade(&headers)); + + let mut mixed_case = HeaderMap::new(); + mixed_case.insert(header::UPGRADE, HeaderValue::from_static("WebSocket")); + assert!(is_websocket_upgrade(&mixed_case)); + + assert!(!is_websocket_upgrade(&HeaderMap::new())); + } + + #[test] + fn injects_before_last_body_close_tag() { + let html = "

hi

"; + let out = inject_bootstrap_script(html); + let script_idx = out.find("").unwrap(); + assert!( + script_idx < last_close, + "script must land before the LAST " + ); + } + + #[test] + fn appends_when_no_body_close_tag_present() { + let html = "

no body tag

"; + let out = inject_bootstrap_script(html); + assert_eq!(out, format!("{html}{BOOTSTRAP_SCRIPT}")); + } + + fn state_at(app_root: std::path::PathBuf) -> AppState { + let config = std::sync::Arc::new(crate::config::ConfigStore::new()); + let repo_dir = app_root.join("global-repo"); + // No test in this file exercises real process spawning/log I/O + // through this default helper, so its shared temp root remains inert. + // The one git-backed test below supplies a unique tempdir instead. + let logs = std::sync::Arc::new(crate::log_store::LogStore::new(app_root.join("logs"))); + let tasks = std::sync::Arc::new(crate::tasks::TaskRegistry::new(logs)); + let broadcaster = std::sync::Arc::new(crate::events::Broadcaster::new()); + let setup = crate::setup::SetupOrchestrator::new( + repo_dir.clone(), + repo_dir.clone(), + config.clone(), + tasks.clone(), + broadcaster.clone(), + ); + AppState { + token: "test-token".into(), + boot_id: "test-boot".into(), + sandbox_manager: crate::sandbox::SandboxManager::new(app_root.clone()), + app_root, + repo_dir, + mode: crate::state::ApiMode::Strict, + config, + tasks, + broadcaster, + shutdown: std::sync::Arc::new(crate::shutdown::ShutdownCoordinator::new()), + setup, + } + } + + fn fresh_state() -> AppState { + state_at(std::env::temp_dir()) + } + + fn preview_headers(host: &str, handle: Option<&str>) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, HeaderValue::from_str(host).unwrap()); + if let Some(handle) = handle { + headers.insert( + crate::sandbox::SANDBOX_HANDLE_HEADER, + HeaderValue::from_str(handle).unwrap(), + ); + } + headers + } + + #[tokio::test] + async fn preview_cookie_selftest_is_origin_bound_and_round_trips_strict_cookie() { + let config = PreviewCookieSelftest { + control_origin: Arc::from("http://localhost:43120"), + expected_host: Arc::from("localhost:61234"), + cookie_value: Arc::from("fresh-boot-secret"), + }; + let frame = preview_frame_selftest( + Extension(config.clone()), + preview_headers("localhost:61234", None), + ) + .await; + assert_eq!(frame.status(), StatusCode::OK); + assert_eq!(frame.headers()[header::CACHE_CONTROL], "no-store"); + let frame_body = axum::body::to_bytes(frame.into_body(), usize::MAX) + .await + .unwrap(); + let frame_body = String::from_utf8_lossy(&frame_body); + assert!(frame_body.contains("decocms-preview-selftest-ready")); + assert!(frame_body.contains("\"http://localhost:43120\"")); + assert_eq!( + preview_frame_selftest( + Extension(config.clone()), + preview_headers("127.0.0.1:61234", None), + ) + .await + .status(), + StatusCode::FORBIDDEN + ); + + let mut headers = preview_headers("localhost:61234", None); + headers.insert( + header::ORIGIN, + HeaderValue::from_static("http://localhost:43120"), + ); + let first = + preview_cookie_selftest(Extension(config.clone()), Method::GET, headers.clone()).await; + assert_eq!(first.status(), StatusCode::OK); + assert_eq!( + first.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], + "http://localhost:43120" + ); + assert_eq!( + first.headers()[header::ACCESS_CONTROL_ALLOW_CREDENTIALS], + "true" + ); + let set_cookie = first.headers()[header::SET_COOKIE].to_str().unwrap(); + assert_eq!( + set_cookie, + "decocms-preview-selftest=fresh-boot-secret; Path=/; HttpOnly; SameSite=Strict" + ); + + let mut preflight_headers = headers.clone(); + preflight_headers.insert( + header::ACCESS_CONTROL_REQUEST_METHOD, + HeaderValue::from_static("GET"), + ); + preflight_headers.insert( + header::ACCESS_CONTROL_REQUEST_HEADERS, + HeaderValue::from_static("cache-control, pragma"), + ); + let preflight = preview_cookie_selftest( + Extension(config.clone()), + Method::OPTIONS, + preflight_headers, + ) + .await; + assert_eq!(preflight.status(), StatusCode::NO_CONTENT); + assert_eq!( + preflight.headers()[header::ACCESS_CONTROL_ALLOW_METHODS], + "GET, OPTIONS" + ); + assert_eq!( + preflight.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], + "http://localhost:43120" + ); + assert_eq!( + preflight.headers()[header::ACCESS_CONTROL_ALLOW_CREDENTIALS], + "true" + ); + assert_eq!( + preflight.headers()[header::ACCESS_CONTROL_ALLOW_HEADERS], + "cache-control, pragma" + ); + + let mut duplicate_origin = headers.clone(); + duplicate_origin.append( + header::ORIGIN, + HeaderValue::from_static("http://localhost:43120"), + ); + assert_eq!( + preview_cookie_selftest(Extension(config.clone()), Method::GET, duplicate_origin,) + .await + .status(), + StatusCode::FORBIDDEN + ); + + headers.insert( + header::COOKIE, + HeaderValue::from_static("sandbox=keep; decocms-preview-selftest=fresh-boot-secret"), + ); + let second = preview_cookie_selftest(Extension(config.clone()), Method::GET, headers).await; + let body = axum::body::to_bytes(second.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(body.as_ref(), br#"{"received":true}"#); + + let mut wrong_origin = preview_headers("localhost:61234", None); + wrong_origin.insert( + header::ORIGIN, + HeaderValue::from_static("http://evil.localhost:43120"), + ); + assert_eq!( + preview_cookie_selftest(Extension(config.clone()), Method::GET, wrong_origin,) + .await + .status(), + StatusCode::FORBIDDEN + ); + assert_eq!( + preview_cookie_selftest( + Extension(config), + Method::GET, + preview_headers("127.0.0.1:61234", None), + ) + .await + .status(), + StatusCode::FORBIDDEN + ); + } + + #[test] + fn dev_port_none_when_config_unset() { + // Exercises `dev_port()` against a real (freshly booted) `AppState`. + let state = fresh_state(); + assert!(dev_port(&state, &HeaderMap::new()).is_none()); + } + + #[test] + fn dev_port_prefers_the_sniffed_running_port_over_static_config() { + let state = fresh_state(); + state + .config + .patch(json!({"application": {"port": 4321}})) + .expect("patch ok"); + // Static config alone: falls back to `application.port`. + assert_eq!(dev_port(&state, &HeaderMap::new()), Some(4321)); + + // Orchestrator reaches `running` with a DIFFERENT (sniffed) port — + // that must win, matching `getDevPort()`'s `portSniffer.current() ?? + // application.port` precedence. + state + .setup + .transition_lifecycle(json!({"phase": "running", "port": 9999, "htmlSupport": false})); + assert_eq!(dev_port(&state, &HeaderMap::new()), Some(9999)); + } + + #[test] + fn dev_port_returns_none_for_an_unrecognized_handle_header() { + // An unrecognized handle in the header must NOT silently fall back + // to the process-global dev server (that would preview the WRONG + // sandbox) — it resolves to no upstream at all. + let state = fresh_state(); + state + .config + .patch(json!({"application": {"port": 1111}})) + .expect("patch ok"); + let mut headers = HeaderMap::new(); + headers.insert( + crate::sandbox::SANDBOX_HANDLE_HEADER, + HeaderValue::from_static("unknown-handle"), + ); + assert_eq!(dev_port(&state, &headers), None); + } + + #[test] + fn resolve_preview_is_no_server_when_idle_and_unconfigured() { + let state = fresh_state(); + assert!(matches!( + resolve_preview(&state, &HeaderMap::new()), + PreviewResolution::NoServer + )); + } + + #[test] + fn resolve_preview_is_starting_while_provisioning() { + let state = fresh_state(); + for phase in ["cloning", "checking-out", "installing", "starting"] { + state.setup.transition_lifecycle(json!({ "phase": phase })); + assert!( + matches!( + resolve_preview(&state, &HeaderMap::new()), + PreviewResolution::Starting + ), + "phase {phase} should map to Starting" + ); + } + } + + #[test] + fn resolve_preview_is_port_once_running_with_a_sniffed_port() { + let state = fresh_state(); + state + .setup + .transition_lifecycle(json!({"phase": "running", "port": 8321, "htmlSupport": false})); + assert!(matches!( + resolve_preview(&state, &HeaderMap::new()), + PreviewResolution::Port(8321) + )); + } + + #[test] + fn resolve_preview_is_starting_for_running_without_a_port() { + // `running` but no port field and no static config port: the phase + // still means "provisioning", so serve the spinner, not "no server". + let state = fresh_state(); + state + .setup + .transition_lifecycle(json!({"phase": "running", "htmlSupport": false})); + assert!(matches!( + resolve_preview(&state, &HeaderMap::new()), + PreviewResolution::Starting + )); + } + + #[test] + fn resolve_preview_unknown_handle_is_no_server() { + // An unrecognized explicit handle must NOT preview the wrong branch — + // same strict rule as `dev_port`, surfaced here as `NoServer`. + let state = fresh_state(); + state + .setup + .transition_lifecycle(json!({ "phase": "installing" })); + let mut headers = HeaderMap::new(); + headers.insert( + crate::sandbox::SANDBOX_HANDLE_HEADER, + HeaderValue::from_static("unknown-handle"), + ); + assert!(matches!( + resolve_preview(&state, &headers), + PreviewResolution::NoServer + )); + } + + #[tokio::test] + async fn dev_port_routes_by_sandbox_handle_header_to_that_handles_own_port() { + // Process-global path configured at one port... + let state_root = tempfile::tempdir().unwrap(); + let state = state_at(state_root.path().to_path_buf()); + state + .config + .patch(json!({"application": {"port": 1111}})) + .expect("patch ok"); + + // ...while a REAL per-handle sandbox (created the same way a + // git-backed dispatch would via `SandboxManager::ensure`) reaches + // `running` at a DIFFERENT, sniffed port. + fn git(dir: &std::path::Path, args: &[&str]) { + let out = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git failed to spawn"); + assert!(out.status.success(), "git {args:?} failed: {:?}", out); + } + let dir = tempfile::tempdir().unwrap(); + let bare_dir = dir.path().join("origin.git"); + let work_dir = dir.path().join("author"); + std::fs::create_dir_all(&bare_dir).unwrap(); + std::fs::create_dir_all(&work_dir).unwrap(); + git(&bare_dir, &["init", "--bare", "-q"]); + git(&work_dir, &["init", "-q", "-b", "main"]); + git(&work_dir, &["config", "user.name", "Test User"]); + git(&work_dir, &["config", "user.email", "test@example.com"]); + std::fs::write(work_dir.join("f.txt"), "x").unwrap(); + git(&work_dir, &["add", "."]); + git(&work_dir, &["commit", "-q", "-m", "initial"]); + let bare_str = bare_dir.to_str().unwrap(); + git(&work_dir, &["remote", "add", "origin", bare_str]); + git(&work_dir, &["push", "-q", "-u", "origin", "main"]); + git(&bare_dir, &["symbolic-ref", "HEAD", "refs/heads/main"]); + + let sandbox = state + .sandbox_manager + .ensure(&crate::sandbox::GitSandboxConfig { + virtual_mcp_id: "vmcp-proxy-test".to_string(), + clone_url: bare_str.to_string(), + branch: Some("main".to_string()), + ..Default::default() + }) + .await + .expect("ensure succeeds against a real one-commit bare repo"); + sandbox + .setup + .transition_lifecycle(json!({"phase": "running", "port": 8123, "htmlSupport": false})); + + let mut headers = HeaderMap::new(); + headers.insert( + crate::sandbox::SANDBOX_HANDLE_HEADER, + HeaderValue::from_str(&sandbox.handle).unwrap(), + ); + assert_eq!(dev_port(&state, &headers), Some(8123)); + // Headerless (the preview iframe) now follows the ACTIVE handle, which + // `ensure()` set to this sandbox — so it serves the branch just run + // (8123), NOT the process-global config port. An explicit re-focus + // still works and is honored the same way. + assert_eq!(dev_port(&state, &HeaderMap::new()), Some(8123)); + // A frontend-computed phantom cannot replace the durable active + // sandbox. The known target remains selected after the rejection. + assert_eq!( + state + .sandbox_manager + .set_active("never-ensured-handle") + .unwrap_err(), + "unknown sandbox handle: never-ensured-handle" + ); + assert_eq!(dev_port(&state, &HeaderMap::new()), Some(8123)); + } +} diff --git a/apps/native/crates/local-api/src/routes/repo_dir.rs b/apps/native/crates/local-api/src/routes/repo_dir.rs new file mode 100644 index 0000000000..39f3f5e3cc --- /dev/null +++ b/apps/native/crates/local-api/src/routes/repo_dir.rs @@ -0,0 +1,219 @@ +//! `GET /_sandbox/repo-dir` — NEW, no daemon precedent. Resolves the +//! ABSOLUTE workdir of a specific git-backed desktop sandbox so the webview's +//! "Open in VS Code / Cursor" deep link has a local route to call instead of +//! the cloud-only `/api/:org/sandbox/:id/:branch/config` REST route +//! local-api's intercept table doesn't carry — see +//! the native desktop-runtime audit finding #4 +//! ("Additional wrinkle"), which is what this route exists to close. +//! +//! ## Resolution: per-handle, NOT the three-tier observability fallback +//! +//! Deliberately mirrors `routes/proxy.rs::resolve_preview`'s per-handle shape +//! (`handle -> get(handle)` else `active()`, unknown-handle-never-guesses), +//! NOT `sandbox::target::AppState::resolve_sandbox_target`'s three-tier +//! `handle -> active() -> global` one that `routes/setup.rs`/`routes/tasks.rs`/ +//! `routes/events.rs`/`routes/scripts.rs` use: "open THIS thread's cloned repo +//! in an editor" has no sensible meaning for the process-global (plain, +//! non-git) path the way "restart the dev server" or "list tasks" still do — +//! a caller that reaches neither the explicit-handle nor the active-sandbox +//! branch gets a `404`, never the unrelated global `state.repo_dir` folder. +//! +//! - `x-decocms-sandbox-handle` present + known -> that sandbox's `workdir`. +//! - Header present + UNKNOWN -> `404` (never guesses at a different repo). +//! - Header absent -> [`crate::sandbox::SandboxManager::active`]; `None` (no +//! git sandbox ever `ensure()`-d this process) -> `404`. +//! - Resolved but the workdir no longer exists on disk (e.g. removed out from +//! under the process) -> `404` — this route's whole point is "here is a +//! real, openable directory", so a stale path is exactly as useless to the +//! caller as an unknown handle. + +use axum::extract::State; +use axum::http::HeaderMap; +use axum::Json; +use serde_json::{json, Value}; + +use crate::error::{ApiError, ApiResult}; +use crate::sandbox::manager::Sandbox; +use crate::state::AppState; +use std::sync::Arc; + +pub async fn get(State(state): State, headers: HeaderMap) -> ApiResult> { + let sandbox = resolve(&state, &headers)?; + if tokio::fs::metadata(&sandbox.workdir).await.is_err() { + return Err(ApiError::not_found(format!( + "repo dir does not exist: {}", + sandbox.workdir.display() + ))); + } + Ok(Json( + json!({ "repoDir": sandbox.workdir.to_string_lossy() }), + )) +} + +/// `handle -> get(handle)` else `active()` — see this file's module doc for +/// why this does NOT fall further to the process-global path. +fn resolve(state: &AppState, headers: &HeaderMap) -> Result, ApiError> { + match crate::sandbox::handle_from_headers(headers) { + Some(handle) => state + .sandbox_manager + .get(handle) + .ok_or_else(|| ApiError::not_found(format!("unknown sandbox handle: {handle}"))), + None => state + .sandbox_manager + .active() + .ok_or_else(|| ApiError::not_found("no active sandbox")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ConfigStore; + use crate::events::Broadcaster; + use crate::setup::SetupOrchestrator; + use crate::tasks::TaskRegistry; + use axum::http::HeaderValue; + + /// Minimal `AppState` fixture — mirrors `sandbox::target::tests::fresh_state` + /// / `routes::setup::tests::fresh_state`; duplicated locally (rather than + /// exported cross-module) since it's pure test plumbing with no + /// non-test caller. + fn fresh_state(sandbox_manager: Arc) -> AppState { + let config = Arc::new(ConfigStore::new()); + let app_root = std::env::temp_dir(); + let repo_dir = std::env::temp_dir(); + let logs = Arc::new(crate::log_store::LogStore::new(app_root.join("logs"))); + let tasks = Arc::new(TaskRegistry::new(logs)); + let broadcaster = Arc::new(Broadcaster::new()); + let setup = SetupOrchestrator::new( + repo_dir.clone(), + repo_dir.clone(), + config.clone(), + tasks.clone(), + broadcaster.clone(), + ); + AppState { + token: "test-token".into(), + boot_id: "test-boot".into(), + sandbox_manager, + app_root, + repo_dir, + mode: crate::state::ApiMode::Strict, + config, + tasks, + broadcaster, + shutdown: Arc::new(crate::shutdown::ShutdownCoordinator::new()), + setup, + } + } + + fn git(dir: &std::path::Path, args: &[&str]) { + let out = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git failed to spawn"); + assert!(out.status.success(), "git {args:?} failed: {out:?}"); + } + + async fn ensure_one_sandbox( + manager: &Arc, + vmcp: &str, + ) -> Arc { + let dir = tempfile::tempdir().unwrap(); + let bare_dir = dir.path().join("origin.git"); + let work_dir = dir.path().join("author"); + std::fs::create_dir_all(&bare_dir).unwrap(); + std::fs::create_dir_all(&work_dir).unwrap(); + git(&bare_dir, &["init", "--bare", "-q"]); + git(&work_dir, &["init", "-q", "-b", "main"]); + git(&work_dir, &["config", "user.name", "Test User"]); + git(&work_dir, &["config", "user.email", "test@example.com"]); + std::fs::write(work_dir.join("f.txt"), "x").unwrap(); + git(&work_dir, &["add", "."]); + git(&work_dir, &["commit", "-q", "-m", "initial"]); + let bare_str = bare_dir.to_str().unwrap(); + git(&work_dir, &["remote", "add", "origin", bare_str]); + git(&work_dir, &["push", "-q", "-u", "origin", "main"]); + git(&bare_dir, &["symbolic-ref", "HEAD", "refs/heads/main"]); + + manager + .ensure(&crate::sandbox::GitSandboxConfig { + virtual_mcp_id: vmcp.to_string(), + clone_url: bare_str.to_string(), + branch: Some("main".to_string()), + ..Default::default() + }) + .await + .expect("ensure succeeds against a real one-commit bare repo") + } + + #[test] + fn headerless_with_no_active_sandbox_is_a_404() { + let root = tempfile::tempdir().unwrap(); + let manager = crate::sandbox::SandboxManager::new(root.path().to_path_buf()); + let state = fresh_state(manager); + // `Arc` (the `Ok` side) isn't `Debug`, so `expect_err` isn't + // available here — match explicitly instead. + match resolve(&state, &HeaderMap::new()) { + Err(e) => assert_eq!(e.status, axum::http::StatusCode::NOT_FOUND), + Ok(_) => panic!("no active sandbox -> 404"), + } + } + + #[test] + fn an_unknown_explicit_handle_is_a_404() { + let root = tempfile::tempdir().unwrap(); + let manager = crate::sandbox::SandboxManager::new(root.path().to_path_buf()); + let state = fresh_state(manager); + let mut headers = HeaderMap::new(); + headers.insert( + "x-decocms-sandbox-handle", + HeaderValue::from_static("never-ensured"), + ); + match resolve(&state, &headers) { + Err(e) => assert_eq!(e.status, axum::http::StatusCode::NOT_FOUND), + Ok(_) => panic!("unknown handle -> 404"), + } + } + + #[tokio::test] + async fn an_explicit_known_handle_resolves_to_that_sandbox() { + let root = tempfile::tempdir().unwrap(); + let manager = crate::sandbox::SandboxManager::new(root.path().to_path_buf()); + let sandbox = ensure_one_sandbox(&manager, "repo-dir-known").await; + + let mut headers = HeaderMap::new(); + headers.insert( + "x-decocms-sandbox-handle", + HeaderValue::from_str(&sandbox.handle).unwrap(), + ); + let state = fresh_state(manager); + let resolved = resolve(&state, &headers).expect("known handle resolves"); + assert_eq!(resolved.workdir, sandbox.workdir); + } + + #[tokio::test] + async fn headerless_falls_back_to_the_active_sandbox() { + let root = tempfile::tempdir().unwrap(); + let manager = crate::sandbox::SandboxManager::new(root.path().to_path_buf()); + let sandbox = ensure_one_sandbox(&manager, "repo-dir-active").await; + + let state = fresh_state(manager); + let resolved = resolve(&state, &HeaderMap::new()).expect("active sandbox resolves"); + assert_eq!(resolved.workdir, sandbox.workdir); + } + + #[tokio::test] + async fn get_returns_404_when_the_resolved_workdir_no_longer_exists_on_disk() { + let root = tempfile::tempdir().unwrap(); + let manager = crate::sandbox::SandboxManager::new(root.path().to_path_buf()); + let sandbox = ensure_one_sandbox(&manager, "repo-dir-vanished").await; + std::fs::remove_dir_all(&sandbox.workdir).unwrap(); + + let state = fresh_state(manager); + let res = get(State(state), HeaderMap::new()).await; + let err = res.expect_err("a vanished workdir must 404, not 200 a stale path"); + assert_eq!(err.status, axum::http::StatusCode::NOT_FOUND); + } +} diff --git a/apps/native/crates/local-api/src/routes/scripts.rs b/apps/native/crates/local-api/src/routes/scripts.rs new file mode 100644 index 0000000000..36861b0133 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/scripts.rs @@ -0,0 +1,1073 @@ +//! Exec / scripts — `GET /_sandbox/scripts`, +//! `POST /_sandbox/exec/:name[/kill]`. Byte-parity target: +//! `daemon/routes/scripts.ts` + `daemon/routes/exec.ts` + +//! `daemon/process/script-discovery.ts`, oracle +//! `daemon.git.e2e.test.ts` (`exec` describe block, minus its +//! `bootstrapRepo`/clone-dependent specs — the setup/clone pipeline is +//! DROPPED for local-api, see the contract doc §C and +//! the native module-ownership contract's "Deliberately out of scope" +//! section) + `daemon.e2e.test.ts`'s +//! `"GET /_sandbox/scripts returns { scripts: [] } before discovery"`. +//! +//! Spawned processes are registered in the SAME `state.tasks` registry the +//! bash/tasks family owns (`TaskRegistry::next_id`/`insert`/ +//! `append_output`/`finalize`) — see +//! the native module-ownership contract's "Uses: the SAME `state.tasks` +//! registry" note. The drain/kill/escalation loop below (`run_exec`) +//! deliberately mirrors `routes/bash.rs`'s detached process owner structurally +//! (external `kill -SIG -` process-group signaling, the same +//! `classify_status`/`exit_status_to_code` mapping, the same `"tasks"` +//! broadcaster payload shape) so a task spawned by either family looks +//! identical to `GET /_sandbox/tasks` / `/_sandbox/tasks/:id/stream` +//! consumers. Duplicated rather than shared because `routes/bash.rs` and +//! `tasks/registry.rs` are the bash/tasks family's owned files (see +//! the native module-ownership contract's hard rule) — an interface request is flagged in +//! the bootstrap report to hoist this into a shared `process` helper +//! module if a third family ever needs the same shape. +//! +//! ## Deliberate deviations from the TS daemon (documented, not hidden) +//! +//! 1. **Spawned-process environment**: the TS `exec.ts` builds a +//! *minimal* env for the child (`HOST`/`HOSTNAME`/config env/body env/ +//! computed `PATH` — deliberately NOT `process.env`, appropriate for a +//! sandboxed container with a fixed toolchain path). local-api targets +//! a user's own laptop, where `npm`/`bun`/`deno` etc. typically live in +//! a devshell/nvm/version-manager PATH that only the inherited +//! environment knows about. This crate inherits the full parent +//! environment (`tokio::process::Command`'s default — env is layered +//! ON TOP via `.envs()`, never `.env_clear()`'d) and layers config-env, +//! then body-env, then `HOST`/`HOSTNAME`/`PORT` defaults (only when +//! unset) on top — the safer default for "run the user's own package +//! script on the user's own machine." +//! 2. **`POST /exec/:name/kill` response shape**: the contract doc's route +//! table says `{ killed: boolean }`, but the actual daemon +//! (`TaskManager.killByLogName`) returns the *count* of matching running +//! tasks signaled, and `daemon.git.e2e.test.ts` asserts +//! `typeof body.killed === "number"` (with an explicit comment: "// +//! killByLogName returns the number of matching tasks killed"). Per +//! "the spec is the test suite," this file matches the test (a number), +//! not the doc's prose. Worth a doc fix, not a code bug. +//! 3. **UTF-8 chunk boundaries**: unlike `routes/bash.rs`'s +//! `Utf8ChunkDecoder` (which carries a dangling partial multi-byte +//! sequence across two 8KB pipe reads), this file decodes each chunk +//! independently via `String::from_utf8_lossy`. A multi-byte character +//! split exactly on an 8KB boundary can render as a replacement +//! character. No test (either suite) exercises this, and script output +//! is overwhelmingly ASCII (build tool logs); flagged as a known, +//! narrow gap rather than duplicating `Utf8ChunkDecoder` verbatim. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use axum::body::Bytes; +use axum::extract::{Path as AxumPath, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use futures_util::FutureExt; +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::io::AsyncReadExt; +use tokio::process::{ChildStderr, ChildStdout, Command as TokioCommand}; +use tokio::sync::oneshot; + +use crate::error::ApiError; +use crate::events::Broadcaster; +use crate::process_group::ProcessGroupChild; +use crate::sandbox::SandboxTarget; +use crate::state::AppState; +use crate::tasks::{ + now_ms, KillHandle, KillSignal, OutputStream, ProcessController, TaskEntry, TaskRegistry, + TaskStatus, TaskSummary, +}; + +/// Resolve the per-handle [`SandboxTarget`] from the request's +/// `x-decocms-sandbox-handle` header (absent/unknown -> active/global) so a +/// script runs in — and its logs/tasks are observed on — the RIGHT sandbox's +/// workdir + orchestrator quadruple. See [`AppState::resolve_sandbox_target`]. +fn resolve(state: &AppState, headers: &HeaderMap) -> SandboxTarget { + state.resolve_sandbox_target(crate::sandbox::handle_from_headers(headers)) +} + +/// Byte-parity with `killWithEscalation`'s 3s TERM->KILL escalation window +/// in `process/task-manager.ts` (and `routes/bash.rs`'s identical constant). +const ESCALATE_AFTER: Duration = Duration::from_secs(3); + +// --- GET /_sandbox/scripts --------------------------------------------------- + +pub async fn list(State(state): State, headers: HeaderMap) -> Json { + let target = resolve(&state, &headers); + let snapshot = target.config.snapshot(); + let pm = pm_name(&snapshot.config); + let cwd = pm_path(&snapshot.config).unwrap_or_else(|| target.repo_dir.clone()); + let scripts = discover_scripts(&cwd, pm.as_deref()); + emit_if_changed(&target.broadcaster, &cwd, &scripts); + Json(json!({ "scripts": scripts })) +} + +/// Compares against the last-discovered set for THIS cwd and emits `"scripts"` +/// only on an actual change, per the native module-ownership contract's +/// "Emits: `scripts` when the discovered set changes." Keyed by cwd (O8) so a +/// second sandbox's first `scripts` emit is not suppressed by the first +/// sandbox's identical set — each per-handle workdir is its own key. +fn emit_if_changed(broadcaster: &Broadcaster, cwd: &Path, scripts: &[String]) { + let key = cwd.to_string_lossy().into_owned(); + let cell = last_known_scripts(); + let mut guard = match cell.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + let changed = guard + .get(&key) + .map(|v| v.as_slice() != scripts) + .unwrap_or(true); + if changed { + guard.insert(key, scripts.to_vec()); + broadcaster.emit("scripts", json!({ "scripts": scripts })); + } +} + +fn last_known_scripts() -> &'static Mutex>> { + static LAST: OnceLock>>> = OnceLock::new(); + LAST.get_or_init(|| Mutex::new(HashMap::new())) +} + +// --- POST /_sandbox/exec/:name ------------------------------------------------ + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct ExecBody { + mode: Option, + timeout_ms: Option, + env: Option>, +} + +pub async fn exec( + State(state): State, + headers: HeaderMap, + AxumPath(name): AxumPath, + body: Bytes, +) -> Result { + let target = resolve(&state, &headers); + let snapshot = target.config.snapshot(); + let Some(pm) = pm_name(&snapshot.config) else { + return Err(ApiError::conflict( + "no application configured; POST /config first", + )); + }; + let run_prefix = run_prefix_for(&pm) + .ok_or_else(|| ApiError::internal(format!("unknown package manager: {pm}")))?; + + let cwd = pm_path(&snapshot.config).unwrap_or_else(|| target.repo_dir.clone()); + let scripts = discover_scripts(&cwd, Some(&pm)); + if !scripts.iter().any(|s| s == &name) { + return Err(ApiError::with_extra( + StatusCode::NOT_FOUND, + format!("script \"{name}\" not found in package file"), + json!({ "available": scripts }), + )); + } + + let exec_body: ExecBody = if body.is_empty() { + ExecBody::default() + } else { + serde_json::from_slice(&body).unwrap_or_default() + }; + let background = exec_body.mode.as_deref() != Some("await"); + let timeout_ms = exec_body.timeout_ms; + + let command = format!("{run_prefix} {name}"); + let env = build_env(&snapshot.config, exec_body.env.as_ref()); + + let Some(admission) = state.shutdown.admit_work().await else { + return Err(ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + "application is shutting down", + )); + }; + + let mut cmd = build_command(&command, &cwd, &env); + let controller = ProcessController::new(); + let kill_handle = controller.kill_handle(); + let mut child = ProcessGroupChild::spawn(&mut cmd, target.tasks.child_lifetime_lock_path()) + .await + .map_err(|err| ApiError::internal(format!("spawn error: {err}")))?; + + // Everything from here runs against the RESOLVED sandbox's registry + + // broadcaster (cheap `Arc` clones), so the spawned drain task can outlive + // this handler without borrowing `target`. + let tasks = target.tasks.clone(); + let broadcaster = target.broadcaster.clone(); + + let id = tasks.next_id(); + let summary = TaskSummary { + id: id.clone(), + command: command.clone(), + status: TaskStatus::Running, + exit_code: None, + started_at: now_ms(), + finished_at: None, + timed_out: false, + truncated: false, + log_name: Some(name.clone()), + intentional: None, + }; + tasks.insert(TaskEntry::new(summary, Some(kill_handle.clone()))); + emit_tasks_event(&tasks, &broadcaster); + + // Registration deliberately precedes these takes. Once spawn succeeds, + // shutdown can always find a durable controller; there is no await or + // cancellation point between process creation and TaskRegistry ownership. + let (Some(stdout_pipe), Some(stderr_pipe)) = (child.take_stdout(), child.take_stderr()) else { + spawn_missing_stdio_cleanup(tasks, broadcaster, id, child); + drop(admission); + return Err(ApiError::internal("spawn error: missing stdio pipe")); + }; + + let (completion_tx, completion_rx) = oneshot::channel(); + spawn_exec_owner( + tasks.clone(), + broadcaster.clone(), + name.clone(), + id.clone(), + child, + stdout_pipe, + stderr_pipe, + timeout_ms, + controller, + completion_tx, + ); + drop(admission); + + if background { + return Ok(Json(json!({ "taskId": id, "status": "running" })).into_response()); + } + + let mut cancel_on_drop = CancelOnDrop::new(kill_handle); + completion_rx + .await + .map_err(|_| ApiError::internal("script process owner stopped unexpectedly"))?; + cancel_on_drop.disarm(); + let final_summary = tasks.get(&id); + let (stdout, stderr, truncated) = tasks.output(&id).await.unwrap_or_default(); + let (exit_code, timed_out) = final_summary + .map(|s| (s.exit_code, s.timed_out)) + .unwrap_or((None, false)); + Ok(Json(json!({ + "taskId": id, + "stdout": stdout, + "stderr": stderr, + "exitCode": exit_code, + "timedOut": timed_out, + "truncated": truncated, + })) + .into_response()) +} + +// --- POST /_sandbox/exec/:name/kill ------------------------------------------- + +pub async fn exec_kill( + State(state): State, + headers: HeaderMap, + AxumPath(name): AxumPath, +) -> Json { + let target = resolve(&state, &headers); + let matching: Vec = target + .tasks + .list(Some(&[TaskStatus::Running])) + .into_iter() + .filter(|s| s.log_name.as_deref() == Some(name.as_str())) + .map(|s| s.id) + .collect(); + + let killed = matching + .iter() + .filter(|id| matches!(target.tasks.kill(id, KillSignal::Term), Some(true))) + .count(); + + Json(json!({ "killed": killed })) +} + +struct CancelOnDrop { + kill: Option, +} + +impl CancelOnDrop { + fn new(kill: KillHandle) -> Self { + Self { kill: Some(kill) } + } + + fn disarm(&mut self) { + self.kill = None; + } +} + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + if let Some(kill) = self.kill.take() { + let _ = kill(KillSignal::Term); + } + } +} + +// --- process spawn / drain / kill --------------------------------------------- + +pub(crate) fn build_command( + command: &str, + cwd: &Path, + env: &HashMap, +) -> TokioCommand { + let mut cmd = TokioCommand::new("sh"); + cmd.arg("-c").arg(command); + cmd.current_dir(cwd); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + // The detached owner normally reaps the whole process group through its + // registered controller. If that owner itself is aborted/panics, Tokio's + // child drop path still kills the immediate shell as a last-resort fence. + cmd.kill_on_drop(true); + // Layered ON TOP of the inherited environment (no `env_clear()`) — see + // the module doc's deviation note #1. + cmd.envs(env); + cmd +} + +fn spawn_missing_stdio_cleanup( + tasks: Arc, + broadcaster: Arc, + id: String, + mut child: ProcessGroupChild, +) { + tokio::spawn(async move { + child + .kill_and_reap(Duration::from_secs(2), "script missing-stdio cleanup") + .await; + tasks.finalize(&id, TaskStatus::Failed, -1, false); + emit_tasks_event(&tasks, &broadcaster); + }); +} + +#[allow(clippy::too_many_arguments)] +fn spawn_exec_owner( + tasks: Arc, + broadcaster: Arc, + log_name: String, + id: String, + mut child: ProcessGroupChild, + stdout_pipe: ChildStdout, + stderr_pipe: ChildStderr, + timeout_ms: Option, + controller: ProcessController, + completion_tx: oneshot::Sender<()>, +) { + tokio::spawn(async move { + let driven = std::panic::AssertUnwindSafe(run_exec( + tasks.clone(), + broadcaster.clone(), + log_name, + id.clone(), + &mut child, + stdout_pipe, + stderr_pipe, + timeout_ms, + controller, + )) + .catch_unwind() + .await; + if driven.is_err() { + child + .kill_and_reap(Duration::from_secs(2), "script process-owner panic cleanup") + .await; + tasks.finalize(&id, TaskStatus::Failed, -1, false); + emit_tasks_event(&tasks, &broadcaster); + } + let _ = completion_tx.send(()); + }); +} + +#[cfg(unix)] +pub(crate) async fn kill_group(pid: u32, signal: &str) { + let _ = TokioCommand::new("kill") + .arg(signal) + .arg(format!("-{pid}")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .status() + .await; +} + +#[cfg(not(unix))] +pub(crate) async fn kill_group(pid: u32, _signal: &str) { + let _ = TokioCommand::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .status() + .await; +} + +#[cfg(unix)] +pub(crate) fn exit_status_to_code(status: std::process::ExitStatus) -> i32 { + use std::os::unix::process::ExitStatusExt; + match status.signal() { + Some(sig) => 128 + sig, + None => status.code().unwrap_or(1), + } +} + +#[cfg(not(unix))] +pub(crate) fn exit_status_to_code(status: std::process::ExitStatus) -> i32 { + status.code().unwrap_or(1) +} + +/// Byte-parity in spirit with `routes/bash.rs`'s `classify_status` (kept as +/// its own copy — see the module doc on why this isn't shared). Reused by +/// the `setup` module's dev-server spawn (`setup/dev.rs`) — same process +/// lifecycle, same registry. +pub(crate) fn classify_status(timed_out: bool, exit_code: i32) -> TaskStatus { + if timed_out { + TaskStatus::Timeout + } else if exit_code == 0 { + TaskStatus::Exited + } else if exit_code == -1 { + TaskStatus::Failed + } else if exit_code > 128 { + TaskStatus::Killed + } else { + TaskStatus::Exited + } +} + +/// Drains stdout/stderr into `tasks` (file-backed retention — both this +/// task's own file and the source's combined transcript — AND the +/// `/stream` broadcast channel, via `append_log`) AND emits a `"log"` SSE +/// frame per chunk on `broadcaster` (`{source: , data: }`, source = the script name — the terminal's tab identity; raw +/// text, the frontend normalizes `\r\n`), honors an optional timeout and +/// durable [`ProcessController`] signals +/// (escalating `SIGTERM` to `SIGKILL` after `ESCALATE_AFTER`, byte-parity with +/// `killWithEscalation`), then finalizes the task and emits a `"tasks"` +/// broadcaster event. Self-contained (void return) — both `background` and +/// `await` exec modes call this the same way; `await` mode reads the result +/// back via `tasks.get`/`output` after it returns. Takes the resolved +/// sandbox's `tasks`/`broadcaster` Arcs (not `AppState`) so a per-handle exec +/// registers/streams on the RIGHT sandbox. +#[allow(clippy::too_many_arguments)] +async fn run_exec( + tasks: Arc, + broadcaster: Arc, + log_name: String, + id: String, + child: &mut ProcessGroupChild, + mut stdout_pipe: ChildStdout, + mut stderr_pipe: ChildStderr, + timeout_ms: Option, + controller: ProcessController, +) { + let has_timeout = timeout_ms.is_some_and(|ms| ms > 0); + // No real timeout configured: use a far-future deadline instead of + // `Option>>` bookkeeping. Guarded out of the + // `select!` below via `timer_active` so it's never actually polled in + // that case. 100 years comfortably avoids `Instant` overflow while + // being "never" for any real exec call. + let sleep_duration = match timeout_ms { + Some(ms) if ms > 0 => Duration::from_millis(ms), + _ => Duration::from_secs(60 * 60 * 24 * 365 * 100), + }; + let sleep = tokio::time::sleep(sleep_duration); + tokio::pin!(sleep); + let escalation = tokio::time::sleep(ESCALATE_AFTER); + tokio::pin!(escalation); + + let mut stdout_open = true; + let mut stderr_open = true; + let mut exited = false; + let mut exit_status: Option = None; + let mut timed_out = false; + let mut timer_active = has_timeout; + let mut observed_signal = None; + let mut escalation_active = false; + + let mut so_chunk = [0u8; 8192]; + let mut se_chunk = [0u8; 8192]; + + loop { + if exited && !stdout_open && !stderr_open { + break; + } + tokio::select! { + res = stdout_pipe.read(&mut so_chunk), if stdout_open => { + match res { + Ok(0) | Err(_) => stdout_open = false, + Ok(n) => { + let text = String::from_utf8_lossy(&so_chunk[..n]).into_owned(); + tasks + .append_log(&id, &log_name, OutputStream::Stdout, &text, &broadcaster) + .await; + } + } + } + res = stderr_pipe.read(&mut se_chunk), if stderr_open => { + match res { + Ok(0) | Err(_) => stderr_open = false, + Ok(n) => { + let text = String::from_utf8_lossy(&se_chunk[..n]).into_owned(); + tasks + .append_log(&id, &log_name, OutputStream::Stderr, &text, &broadcaster) + .await; + } + } + } + // Keep the leader unreaped until every inherited output writer has + // closed. Its unreaped PID pins the process-group identity, so + // timeout/controller signals cannot ever target a recycled PGID. + status = child.wait(), if !exited && !stdout_open && !stderr_open => { + exited = true; + timer_active = false; + escalation_active = false; + exit_status = status.ok(); + } + _ = &mut sleep, if timer_active && !exited => { + timer_active = false; + escalation_active = false; + timed_out = true; + child.signal(KillSignal::Kill).await; + } + sig = controller.wait_for_change(observed_signal), if !exited => { + observed_signal = Some(sig); + if child.signal(sig).await { + if matches!(sig, KillSignal::Term) { + escalation.as_mut().reset(tokio::time::Instant::now() + ESCALATE_AFTER); + escalation_active = true; + } else { + escalation_active = false; + } + } + } + _ = &mut escalation, if escalation_active && !exited => { + escalation_active = false; + child.signal(KillSignal::Kill).await; + } + } + } + + let raw_exit_code = exit_status.map(exit_status_to_code).unwrap_or(-1); + let status = classify_status(timed_out, raw_exit_code); + tasks.finalize(&id, status, raw_exit_code, timed_out); + emit_tasks_event(&tasks, &broadcaster); +} + +/// `{"active": [{id, command, logName?}]}` — byte-parity with +/// `getActiveTasks()` in `entry.ts`; identical payload shape to +/// `routes/bash.rs`'s own copy (see the module doc on why this isn't +/// shared), broadcast under the `"tasks"` name on every registration and +/// every exit. Takes the resolved sandbox's registry + broadcaster so a +/// per-handle exec's task list is fanned out on the RIGHT stream. +fn emit_tasks_event(tasks: &TaskRegistry, broadcaster: &Broadcaster) { + let active: Vec = tasks + .list(Some(&[TaskStatus::Running])) + .into_iter() + .map(|t| { + let mut obj = json!({"id": t.id, "command": t.command}); + if let Some(name) = t.log_name { + obj["logName"] = json!(name); + } + obj + }) + .collect(); + broadcaster.emit("tasks", json!({"active": active})); +} + +// --- config-store reads (opaque `Value` — see `config/store.rs`) ------------- + +fn pm_name(config: &Option) -> Option { + config + .as_ref()? + .get("application")? + .get("packageManager")? + .get("name")? + .as_str() + .map(str::to_string) +} + +fn pm_path(config: &Option) -> Option { + config + .as_ref()? + .get("application")? + .get("packageManager")? + .get("path")? + .as_str() + .map(PathBuf::from) +} + +/// Byte-parity target: `PACKAGE_MANAGER_DAEMON_CONFIG` in +/// `daemon/constants.ts`. Reused by `setup/dev.rs` (same package-manager -> +/// run-prefix mapping drives the discovered `dev`/`start` starter command). +pub(crate) fn run_prefix_for(pm: &str) -> Option<&'static str> { + match pm { + "npm" => Some("npm run"), + "pnpm" => Some("pnpm run"), + "yarn" => Some("yarn run"), + "bun" => Some("bun run"), + "deno" => Some("deno task"), + _ => None, + } +} + +/// Byte-parity target: `discoverScripts` in +/// `daemon/process/script-discovery.ts`. Reused by the `setup` module +/// (install/start steps discover the same script set this route reports). +pub(crate) fn discover_scripts(cwd: &Path, pm: Option<&str>) -> Vec { + let Some(pm) = pm else { + return Vec::new(); + }; + if pm == "deno" { + for candidate in ["deno.json", "deno.jsonc"] { + let Ok(raw) = std::fs::read_to_string(cwd.join(candidate)) else { + continue; + }; + let Ok(parsed) = serde_json::from_str::(&raw) else { + continue; + }; + let tasks = parsed + .get("tasks") + .and_then(|v| v.as_object()) + .map(|o| o.keys().cloned().collect()) + .unwrap_or_default(); + return tasks; + } + return Vec::new(); + } + let Ok(raw) = std::fs::read_to_string(cwd.join("package.json")) else { + return Vec::new(); + }; + let Ok(parsed) = serde_json::from_str::(&raw) else { + return Vec::new(); + }; + parsed + .get("scripts") + .and_then(|v| v.as_object()) + .map(|o| o.keys().cloned().collect()) + .unwrap_or_default() +} + +/// deep-merge-free env layering: process env (inherited by +/// `tokio::process::Command` by default) < config's `env` map < body's +/// `env` overrides < HOST/HOSTNAME/PORT defaults when still unset. See the +/// module doc's deviation note #1. +fn build_env( + config: &Option, + body_env: Option<&HashMap>, +) -> HashMap { + let mut env = HashMap::new(); + if let Some(cfg_env) = config + .as_ref() + .and_then(|c| c.get("env")) + .and_then(|v| v.as_object()) + { + for (k, v) in cfg_env { + if let Some(s) = v.as_str() { + env.insert(k.clone(), s.to_string()); + } + } + } + if let Some(body_env) = body_env { + for (k, v) in body_env { + env.insert(k.clone(), v.clone()); + } + } + env.entry("HOST".to_string()) + .or_insert_with(|| "0.0.0.0".to_string()); + env.entry("HOSTNAME".to_string()) + .or_insert_with(|| "0.0.0.0".to_string()); + if !env.contains_key("PORT") { + if let Some(port) = config + .as_ref() + .and_then(|c| c.get("application")) + .and_then(|a| a.get("port")) + .and_then(|p| p.as_u64()) + { + env.insert("PORT".to_string(), port.to_string()); + } + } + env +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(dir: &tempfile::TempDir, name: &str, contents: &str) { + std::fs::write(dir.path().join(name), contents).expect("write fixture"); + } + + #[test] + fn discover_scripts_returns_empty_when_pm_is_none() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(discover_scripts(dir.path(), None), Vec::::new()); + } + + #[test] + fn discover_scripts_returns_empty_when_manifest_missing() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + discover_scripts(dir.path(), Some("npm")), + Vec::::new() + ); + } + + #[test] + fn discover_scripts_reads_package_json_scripts() { + let dir = tempfile::tempdir().unwrap(); + write( + &dir, + "package.json", + r#"{"scripts":{"echo":"echo hi","build":"tsc"}}"#, + ); + let mut scripts = discover_scripts(dir.path(), Some("npm")); + scripts.sort(); + assert_eq!(scripts, vec!["build".to_string(), "echo".to_string()]); + } + + #[test] + fn discover_scripts_handles_missing_scripts_field() { + let dir = tempfile::tempdir().unwrap(); + write(&dir, "package.json", r#"{"name":"x"}"#); + assert_eq!( + discover_scripts(dir.path(), Some("npm")), + Vec::::new() + ); + } + + #[test] + fn discover_scripts_deno_reads_tasks_from_deno_json() { + let dir = tempfile::tempdir().unwrap(); + write( + &dir, + "deno.json", + r#"{"tasks":{"dev":"deno run -A main.ts"}}"#, + ); + assert_eq!( + discover_scripts(dir.path(), Some("deno")), + vec!["dev".to_string()] + ); + } + + #[test] + fn discover_scripts_deno_falls_back_to_jsonc_when_json_invalid() { + let dir = tempfile::tempdir().unwrap(); + write(&dir, "deno.json", "not json"); + write(&dir, "deno.jsonc", r#"{"tasks":{"dev":"x"}}"#); + assert_eq!( + discover_scripts(dir.path(), Some("deno")), + vec!["dev".to_string()] + ); + } + + #[test] + fn run_prefix_known_and_unknown_package_managers() { + assert_eq!(run_prefix_for("npm"), Some("npm run")); + assert_eq!(run_prefix_for("deno"), Some("deno task")); + assert_eq!(run_prefix_for("cargo"), None); + } + + #[test] + fn pm_name_and_path_extraction() { + let cfg = Some(json!({ + "application": { "packageManager": { "name": "npm", "path": "/tmp/app" } } + })); + assert_eq!(pm_name(&cfg), Some("npm".to_string())); + assert_eq!(pm_path(&cfg), Some(PathBuf::from("/tmp/app"))); + + let empty: Option = None; + assert_eq!(pm_name(&empty), None); + assert_eq!(pm_path(&empty), None); + } + + #[test] + fn build_env_layers_config_then_body_then_defaults() { + let cfg = Some(json!({ + "env": { "FOO": "from-config", "HOST": "custom-host" }, + "application": { "port": 4000 } + })); + let mut body_env = HashMap::new(); + body_env.insert("FOO".to_string(), "from-body".to_string()); + let env = build_env(&cfg, Some(&body_env)); + assert_eq!(env.get("FOO"), Some(&"from-body".to_string())); + assert_eq!(env.get("HOST"), Some(&"custom-host".to_string())); + assert_eq!(env.get("HOSTNAME"), Some(&"0.0.0.0".to_string())); + assert_eq!(env.get("PORT"), Some(&"4000".to_string())); + } + + #[test] + fn exec_body_defaults_to_background_mode() { + let parsed: ExecBody = serde_json::from_str("{}").unwrap(); + assert_eq!(parsed.mode, None); + let parsed: ExecBody = serde_json::from_str(r#"{"mode":"await"}"#).unwrap(); + assert_eq!(parsed.mode.as_deref(), Some("await")); + } + + #[test] + fn classify_status_matches_bash_family_semantics() { + assert_eq!(classify_status(true, 0), TaskStatus::Timeout); + assert_eq!(classify_status(false, 0), TaskStatus::Exited); + assert_eq!(classify_status(false, -1), TaskStatus::Failed); + assert_eq!(classify_status(false, 137), TaskStatus::Killed); + assert_eq!(classify_status(false, 1), TaskStatus::Exited); + } + + #[test] + fn emit_if_changed_only_broadcasts_on_actual_change() { + // Use a cwd key unique to this test so the process-lifetime static + // (now keyed by cwd) makes this order-independent without a global + // reset. + let cwd = std::env::temp_dir().join("emit-if-changed-scripts-test"); + { + let mut guard = last_known_scripts().lock().unwrap(); + guard.remove(&cwd.to_string_lossy().into_owned()); + } + let broadcaster = Arc::new(crate::events::Broadcaster::new()); + let mut rx = broadcaster.subscribe(); + + emit_if_changed(&broadcaster, &cwd, &["a".to_string()]); + let evt = rx.try_recv().expect("first change emits"); + assert_eq!(evt.name, "scripts"); + + // Same set again for the SAME cwd: no second emit. + emit_if_changed(&broadcaster, &cwd, &["a".to_string()]); + assert!(rx.try_recv().is_err()); + + // A DIFFERENT cwd with the same set still emits (per-cwd keying, O8). + let other_cwd = std::env::temp_dir().join("emit-if-changed-scripts-test-2"); + { + let mut guard = last_known_scripts().lock().unwrap(); + guard.remove(&other_cwd.to_string_lossy().into_owned()); + } + emit_if_changed(&broadcaster, &other_cwd, &["a".to_string()]); + let evt = rx.try_recv().expect("a different cwd is a distinct key"); + assert_eq!(evt.name, "scripts"); + } + + fn test_state(broadcaster: Arc) -> AppState { + let config = Arc::new(crate::config::ConfigStore::new()); + // `run_exec` now writes real per-task/per-source `LogStore` files + // (see `tasks/registry.rs`'s file-backed retention) — an isolated + // root per call is required so parallel tests spawning a task named + // "t1" don't race each other's files. `.keep()` intentionally leaks + // the tempdir (this fixture has nowhere to stash a `TempDir` guard + // across the whole test); the OS temp reaper cleans it up. + let app_root = tempfile::tempdir().unwrap().keep(); + let logs = Arc::new(crate::log_store::LogStore::new(app_root.join("logs"))); + let tasks = Arc::new(crate::tasks::TaskRegistry::new(logs)); + let repo_dir = app_root.clone(); + let setup = crate::setup::SetupOrchestrator::new( + repo_dir.clone(), + repo_dir.clone(), + config.clone(), + tasks.clone(), + broadcaster.clone(), + ); + AppState { + token: Arc::from("test-token"), + boot_id: Arc::from("test-boot"), + sandbox_manager: crate::sandbox::SandboxManager::new(app_root.clone()), + app_root, + repo_dir, + mode: crate::state::ApiMode::Strict, + config, + tasks, + broadcaster, + shutdown: Arc::new(crate::shutdown::ShutdownCoordinator::new()), + setup, + } + } + + #[allow(clippy::type_complexity)] + async fn spawn_registered( + state: &AppState, + id: &str, + script: &str, + ) -> ( + ProcessGroupChild, + ChildStdout, + ChildStderr, + Option, + ProcessController, + ) { + let env = HashMap::new(); + let mut cmd = build_command(script, Path::new("."), &env); + let mut child = ProcessGroupChild::spawn(&mut cmd, state.tasks.child_lifetime_lock_path()) + .await + .expect("spawn fixture command"); + let pid = child.id(); + let stdout = child.take_stdout().expect("stdout piped"); + let stderr = child.take_stderr().expect("stderr piped"); + let controller = ProcessController::new(); + let summary = TaskSummary { + id: id.to_string(), + command: script.to_string(), + status: TaskStatus::Running, + exit_code: None, + started_at: now_ms(), + finished_at: None, + timed_out: false, + truncated: false, + log_name: None, + intentional: None, + }; + state + .tasks + .insert(TaskEntry::new(summary, Some(controller.kill_handle()))); + (child, stdout, stderr, pid, controller) + } + + #[tokio::test] + async fn run_exec_captures_stdout_and_finalizes_registry() { + let state = test_state(Arc::new(crate::events::Broadcaster::new())); + let (mut child, stdout, stderr, _pid, controller) = + spawn_registered(&state, "t1", "echo hi-from-echo && exit 0").await; + run_exec( + state.tasks.clone(), + state.broadcaster.clone(), + "t1".into(), + "t1".into(), + &mut child, + stdout, + stderr, + None, + controller, + ) + .await; + + let summary = state.tasks.get("t1").expect("task registered"); + assert_eq!(summary.status, TaskStatus::Exited); + assert_eq!(summary.exit_code, Some(0)); + let (stdout, _stderr, _truncated) = + state.tasks.output("t1").await.expect("output recorded"); + assert!(stdout.contains("hi-from-echo")); + } + + #[tokio::test] + async fn run_exec_captures_nonzero_exit_code() { + // `classify_status`'s `TaskStatus` tracks process lifecycle, not + // success — a clean nonzero exit is still `Exited` (byte-parity + // with `routes/bash.rs`'s `classify_status_matches_task_manager_finalize` + // test: only the `-1` spawn-failure sentinel or a >128 signal-death + // code map away from `Exited`). `exit_code` itself still carries 3. + let state = test_state(Arc::new(crate::events::Broadcaster::new())); + let (mut child, stdout, stderr, _pid, controller) = + spawn_registered(&state, "t1", "exit 3").await; + run_exec( + state.tasks.clone(), + state.broadcaster.clone(), + "t1".into(), + "t1".into(), + &mut child, + stdout, + stderr, + None, + controller, + ) + .await; + + let summary = state.tasks.get("t1").expect("task registered"); + assert_eq!(summary.status, TaskStatus::Exited); + assert_eq!(summary.exit_code, Some(3)); + } + + #[tokio::test] + async fn run_exec_times_out_and_kills() { + let state = test_state(Arc::new(crate::events::Broadcaster::new())); + let (mut child, stdout, stderr, _pid, controller) = + spawn_registered(&state, "t1", "sleep 5").await; + run_exec( + state.tasks.clone(), + state.broadcaster.clone(), + "t1".into(), + "t1".into(), + &mut child, + stdout, + stderr, + Some(100), + controller, + ) + .await; + + let summary = state.tasks.get("t1").expect("task registered"); + assert_eq!(summary.status, TaskStatus::Timeout); + assert!(summary.timed_out); + } + + #[tokio::test] + async fn run_exec_kill_via_controller_sends_real_sigterm() { + let state = test_state(Arc::new(crate::events::Broadcaster::new())); + let (mut child, stdout, stderr, _pid, controller) = + spawn_registered(&state, "t1", "sleep 5").await; + // Signal termination immediately, before `run_exec` starts polling — + // exercises the real `kill -TERM -` path (not just a timeout). + assert_eq!(state.tasks.kill("t1", KillSignal::Term), Some(true)); + run_exec( + state.tasks.clone(), + state.broadcaster.clone(), + "t1".into(), + "t1".into(), + &mut child, + stdout, + stderr, + None, + controller, + ) + .await; + + let summary = state.tasks.get("t1").expect("task registered"); + assert_eq!(summary.status, TaskStatus::Killed); + assert!(!summary.timed_out); + } + + #[cfg(unix)] + #[tokio::test] + async fn dropping_await_guard_reaps_detached_exec_owner() { + let state = test_state(Arc::new(crate::events::Broadcaster::new())); + let (child, stdout, stderr, pid, controller) = + spawn_registered(&state, "abort-await", "sleep 30").await; + let pid = pid.expect("fixture child pid"); + let (completion_tx, completion_rx) = oneshot::channel(); + spawn_exec_owner( + state.tasks.clone(), + state.broadcaster.clone(), + "dev".into(), + "abort-await".into(), + child, + stdout, + stderr, + None, + controller.clone(), + completion_tx, + ); + + let guard = CancelOnDrop::new(controller.kill_handle()); + drop(guard); + tokio::time::timeout(Duration::from_secs(5), completion_rx) + .await + .expect("owner completed after request cancellation") + .expect("owner completion channel stayed open"); + + let summary = state.tasks.get("abort-await").expect("task registered"); + assert_eq!(summary.status, TaskStatus::Killed); + let alive = std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + assert!(!alive, "request cancellation left exec child alive"); + } +} diff --git a/apps/native/crates/local-api/src/routes/setup.rs b/apps/native/crates/local-api/src/routes/setup.rs new file mode 100644 index 0000000000..5561f66bcb --- /dev/null +++ b/apps/native/crates/local-api/src/routes/setup.rs @@ -0,0 +1,971 @@ +//! `POST /_sandbox/setup/{clone,install,start}` — KEPT (corrected from an +//! earlier "DROPPED family" classification; see `crate::setup`'s module doc +//! and the native parity contract for the full story). Byte-parity +//! target: `daemon/routes/setup.ts::makeSetupHandler` — always `200 +//! {"enqueued":""}`, fire-and-forget (does not wait for the pipeline to +//! finish before responding), idempotent (re-requesting the same step while +//! one is in flight is a no-op on the orchestrator side, not an error here). +//! +//! ## Per-handle resolution is STRICTER than observability's +//! +//! [`resolve`] below deliberately does NOT reuse +//! [`AppState::resolve_sandbox_target`] verbatim (unlike `routes/tasks.rs`/ +//! `routes/events.rs`/`routes/scripts.rs`, which do): an EXPLICIT +//! `x-decocms-sandbox-handle` header this process has never `ensure()`-d is a +//! caller/state-tracking bug worth surfacing loudly (`404`) here, not +//! silently clone/install/restarting the process-global (plain-path) +//! orchestrator instead — see +//! the native desktop-runtime audit finding #1: the +//! drawer's Restart button always means "restart THIS sandbox's dev server"; +//! if the handle it was given no longer resolves, restarting an unrelated +//! target and reporting `200 {"enqueued":...}` back would look like success +//! while doing the wrong thing (or nothing the caller can observe). +//! +//! A HEADERLESS request's resolution is UNCHANGED for `clone`/`install` — +//! `resolve_sandbox_target`'s existing `handle -> active() -> global` +//! fallback still applies, falling all the way to the process-global +//! orchestrator when no sandbox has ever been `ensure()`-d. This is NOT a gap +//! left unfixed: `POST /_sandbox/setup/{clone,install}` is byte-parity +//! pinned (`daemon/routes/setup.ts::makeSetupHandler`, oracle +//! `daemon.git.e2e.test.ts`'s `"setup routes"` describe block, all-pass per +//! the native parity contract) to always enqueue for the PLAIN +//! (non-git) path too — a headerless call with no active sandbox MUST still +//! resolve to the process-global orchestrator and return `200`, not error, +//! or this crate silently stops being a byte-parity daemon replacement for +//! every desktop user who never attaches a persistent GitHub repo. +//! +//! ## `start` also self-heals a git sandbox forgotten by a backend restart +//! +//! [`resolve`] above is necessary but not sufficient for `start`: an +//! EXPLICIT handle this process forgot (backend restart — dev-loop rebuild, +//! app relaunch — kills the in-memory `sandboxes` map and `active_handle` +//! while the workdir + git history persist on disk, see `sandbox::manager`'s +//! module doc) used to 404 loudly (correct — never silently restart the +//! wrong target) but left the caller with NO way to make Restart actually +//! work again short of a fresh chat dispatch. [`resolve_for_start`] closes +//! that gap: before giving up, it tries +//! [`crate::sandbox::SandboxManager::resurrect`]/`resurrect_active`, which +//! re-`ensure()`s the handle from its persisted `GitSandboxConfig` sidecar +//! (`sandbox::persist`) — a REAL, non-destructive restart of the actual +//! sandbox (safe `checkout_existing` against the already-cloned workdir, see +//! `setup::clone::run`), not a no-op ack against an unrelated target. Only +//! `clone`/`install`/`stop` keep using the plain, non-resurrecting [`resolve`] +//! — see [`stop`]'s own doc comment for why resurrection is deliberately +//! wrong for a Stop click specifically. +//! +//! `resolve_for_start` still returns a LOUD error (never a silent +//! `{"enqueued":...}`) when the target genuinely cannot serve the caller's +//! intent: an explicit handle with no sidecar at all (never `ensure()`-d, +//! ever) is a `404`, matching [`resolve`]'s existing contract; a sidecar that +//! exists but fails to re-`ensure()` (e.g. the workdir's git remote is no +//! longer reachable) is a `409`, distinct from "unknown" so a caller can +//! tell the two apart. A headerless request keeps byte-parity: if nothing is +//! active AND nothing was ever persisted as active, it still falls all the +//! way to the process-global path and `200`s (the legitimate "never +//! configured, plain path" case above) — a headerless resurrection FAILURE +//! (sidecar existed but `ensure()` errored) degrades the same way rather +//! than hard-erroring a fire-and-forget request, logged instead. + +use axum::extract::State; +use axum::http::HeaderMap; +use axum::Json; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::error::{ApiError, ApiResult}; +use crate::sandbox::SandboxTarget; +use crate::setup::Step; +use crate::state::AppState; +use crate::tasks::{KillSignal, TaskStatus}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnsureSandboxRequest { + virtual_mcp_id: String, + repo: EnsureRepository, + #[serde(default)] + workload: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EnsureRepository { + clone_url: String, + #[serde(default)] + branch: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EnsureWorkload { + #[serde(default)] + runtime: Option, + #[serde(default)] + package_manager: Option, + #[serde(default)] + package_manager_path: Option, +} + +/// Establish a durable, explicitly-addressable git sandbox from the same +/// `DesktopSandboxBlock` shape carried by local chat dispatch. Returns as +/// soon as the setup worker accepts clone/start so the UI can attach SSE and +/// render clone/install output live; lifecycle failures arrive on that stream +/// and are also persisted in the registry. +pub async fn ensure( + State(state): State, + Json(body): Json, +) -> ApiResult> { + let workload = body.workload.unwrap_or_default(); + let config = crate::sandbox::GitSandboxConfig { + // Daemon-parity route: its body carries no org. A sandbox that has + // already learned one keeps it via `merge_durable_config`. + org_slug: None, + virtual_mcp_id: body.virtual_mcp_id, + clone_url: body.repo.clone_url, + branch: body.repo.branch, + runtime: workload.runtime, + package_manager: workload.package_manager, + package_manager_path: workload.package_manager_path, + git_user_name: None, + git_user_email: None, + }; + if config.virtual_mcp_id.trim().is_empty() { + return Err(ApiError::bad_request("virtualMcpId is required")); + } + if config.clone_url.trim().is_empty() { + return Err(ApiError::bad_request("repo.cloneUrl is required")); + } + let sandbox = state + .sandbox_manager + .provision(&config) + .await + .map_err(ApiError::conflict)?; + let record = state + .sandbox_manager + .registry_record(&sandbox.handle) + .map_err(ApiError::internal)? + .ok_or_else(|| ApiError::internal("ensured sandbox was not persisted"))?; + Ok(Json(json!({ + "handle": sandbox.handle, + "state": record.observed_status, + "desiredStatus": record.desired_status, + }))) +} + +/// Resolve the [`SandboxTarget`] a setup step should run against — see this +/// file's module doc for exactly how this differs from +/// [`AppState::resolve_sandbox_target`]: an explicit, UNKNOWN handle is a 404 +/// here; a headerless request keeps the unchanged three-tier fallback. Used +/// by `clone`/`install`/[`stop`] — never resurrects (see [`stop`]'s doc +/// comment for why; `clone`/`install` simply haven't needed it, unlike +/// `start`, which uses [`resolve_for_start`] instead). +fn resolve(state: &AppState, headers: &HeaderMap) -> Result { + match crate::sandbox::handle_from_headers(headers) { + Some(handle) => state + .sandbox_manager + .get(handle) + .map(|sb| SandboxTarget::from_sandbox(&sb)) + .ok_or_else(|| ApiError::not_found(format!("unknown sandbox handle: {handle}"))), + None => Ok(state.resolve_sandbox_target(None)), + } +} + +/// [`start`]'s own resolver — see this file's module doc's "`start` also +/// self-heals..." section. Returns `(target, resurrected)`: `resurrected` is +/// `true` when this call is the one that just re-`ensure()`-d the target +/// (so `start` can skip its own kill+resume — the resurrection's own +/// `ensure()` cascade already covers it, see `start`'s body). +async fn resolve_for_start( + state: &AppState, + headers: &HeaderMap, +) -> Result<(SandboxTarget, bool), ApiError> { + match crate::sandbox::handle_from_headers(headers) { + Some(handle) => { + let already_known = state.sandbox_manager.get(handle).is_some(); + match state.sandbox_manager.resurrect(handle).await { + Ok(Some(sb)) => Ok((SandboxTarget::from_sandbox(&sb), !already_known)), + Ok(None) => Err(ApiError::not_found(format!( + "unknown sandbox handle: {handle}" + ))), + Err(err) => Err(ApiError::conflict(format!( + "sandbox handle {handle} could not be resurrected: {err}" + ))), + } + } + None => { + let already_active = state.sandbox_manager.active().is_some(); + match state.sandbox_manager.resurrect_active().await { + Ok(Some(sb)) => Ok((SandboxTarget::from_sandbox(&sb), !already_active)), + Ok(None) => Ok((state.resolve_sandbox_target(None), false)), + Err(err) => { + // Byte-parity floor: a headerless request must still 200 + // even when self-heal itself fails — fall back to the + // existing (global) resolution rather than hard-erroring + // a fire-and-forget request. Logged so the failure isn't + // silent. + tracing::warn!( + error = %err, + "headerless sandbox resurrection failed; falling back to the process-global target" + ); + Ok((state.resolve_sandbox_target(None), false)) + } + } + } + } +} + +pub async fn clone(State(state): State, headers: HeaderMap) -> ApiResult> { + if !resolve(&state, &headers)?.setup.resume_from(Step::Clone) { + return Err(ApiError::new( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "setup worker is unavailable", + )); + } + Ok(Json(json!({ "enqueued": Step::Clone.as_str() }))) +} + +pub async fn install(State(state): State, headers: HeaderMap) -> ApiResult> { + if !resolve(&state, &headers)?.setup.resume_from(Step::Install) { + return Err(ApiError::new( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "setup worker is unavailable", + )); + } + Ok(Json(json!({ "enqueued": Step::Install.as_str() }))) +} + +pub async fn start(State(state): State, headers: HeaderMap) -> ApiResult> { + let explicit_handle = crate::sandbox::handle_from_headers(&headers).map(str::to_owned); + let (target, resurrected) = resolve_for_start(&state, &headers).await?; + if resurrected { + // `SandboxManager::ensure` (invoked by `resolve_for_start`'s + // resurrection above) already ran its own clone/checkout -> install + // -> start cascade against a FRESH `ConfigStore` — a restarted + // process always classifies this as a "bootstrap" transition, never + // "no-op" (see `ensure()`'s doc comment) — so this request already + // got its real dev-server restart as a side effect of resurrection. + // Nothing is running yet at this exact instant (the cascade's + // Install step hasn't reached Start yet), so the kill-loop below + // would find nothing to kill anyway; redundantly re-enqueuing + // `Step::Start` on top of the cascade already in flight would race a + // SECOND dev server onto a second port before the first is + // confirmed running, orphaning it — the same leak class flagged for + // a killed local-api's own children — so skip it. + tracing::info!( + "setup/start: target was just resurrected — its own ensure() cascade already covers this restart" + ); + } else { + let durable_handle = explicit_handle.or_else(|| { + state + .sandbox_manager + .active() + .map(|sandbox| sandbox.handle.clone()) + }); + if let Some(handle) = durable_handle { + state + .sandbox_manager + .restart_registered(&handle) + .await + .map_err(ApiError::conflict)? + .ok_or_else(|| ApiError::not_found(format!("unknown sandbox handle: {handle}")))?; + } else { + // Daemon-compatible process-global path: reap the old dev group + // before admitting Start, just like the per-handle path above. + crate::sandbox::manager::terminate_tasks_by_log_name(&target.tasks, &["dev", "start"]) + .await + .map_err(ApiError::conflict)?; + if !target.setup.resume_from(Step::Start) { + return Err(ApiError::new( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "setup worker is unavailable", + )); + } + } + } + Ok(Json(json!({ "enqueued": Step::Start.as_str() }))) +} + +/// `POST /_sandbox/setup/stop` — NEW, no daemon precedent and no byte-parity +/// target: kills the resolved target's running `dev`/`start` task(s) WITHOUT +/// re-spawning (unlike `start` above, which kills-then-resumes). Gives the +/// sandbox drawer's Stop button a real desktop-local action to call — see +/// the native desktop-runtime audit finding #1's +/// sibling bug: `stop()` (`sandbox-lifecycle-context.tsx`) had NO desktop +/// branch at all, only the cloud `SANDBOX_DELETE` path gated on a +/// `sandboxProviderKind` that's always `null` in Tauri. +/// +/// A registered handle forgotten by this process is already stopped: its old +/// child lifetime ended with that process. We persist `desired=stopped` and +/// return an idempotent success without materializing the sandbox (which +/// would risk starting it). A truly unknown explicit handle remains a loud +/// 404. Headerless non-git daemon compatibility retains the legacy 400 when +/// there is no dev/start task to stop. +pub async fn stop(State(state): State, headers: HeaderMap) -> ApiResult> { + let explicit_handle = crate::sandbox::handle_from_headers(&headers).map(str::to_owned); + let durable_handle = match explicit_handle { + Some(handle) => Some(handle), + None => state + .sandbox_manager + .active() + .map(|sandbox| sandbox.handle.clone()) + .or(state + .sandbox_manager + .registered_active_handle() + .map_err(ApiError::internal)?), + }; + + if let Some(handle) = durable_handle { + let killed = state + .sandbox_manager + .stop_registered(&handle) + .await + .map_err(ApiError::internal)? + .ok_or_else(|| ApiError::not_found(format!("unknown sandbox handle: {handle}")))?; + return Ok(Json(json!({ + "stopped": true, + "killed": killed, + "alreadyStopped": killed == 0, + "handle": handle, + }))); + } + + // Daemon-compatible plain-path fallback for a non-git workspace. + let target = state.resolve_sandbox_target(None); + + let mut killed = 0usize; + for t in target.tasks.list(Some(&[TaskStatus::Running])) { + if matches!(t.log_name.as_deref(), Some("dev") | Some("start")) + && target.tasks.kill(&t.id, KillSignal::Term) == Some(true) + { + killed += 1; + } + } + if killed == 0 { + return Err(ApiError::bad_request( + "nothing to stop: no running dev/start task for this sandbox", + )); + } + // No new `LifecycleState` phase needed — `idle` is already the pre-clone + // default (see `crate::setup`'s `LifecycleState` union), so the drawer's + // SSE-driven status naturally reflects "stopped" without a wire-shape + // change. + target + .setup + .transition_lifecycle(json!({ "phase": "idle" })); + Ok(Json(json!({ + "stopped": true, + "killed": killed, + "alreadyStopped": killed == 0, + "handle": Value::Null, + }))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ConfigStore; + use crate::events::Broadcaster; + use crate::setup::SetupOrchestrator; + use crate::tasks::TaskRegistry; + use axum::http::HeaderValue; + use std::sync::Arc; + + fn fresh_state() -> AppState { + fresh_state_at(std::env::temp_dir()) + } + + /// Parameterized twin of [`fresh_state`] — every test that exercises + /// disk-persisted resurrection (`resolve_for_start`'s + /// `SandboxManager::resurrect`/`resurrect_active`, backed by + /// `sandbox::persist`) MUST use a UNIQUE `app_root` (never the shared + /// `std::env::temp_dir()` [`fresh_state`] uses for its non-persisting + /// callers), or concurrently-running tests would race on the SAME + /// `/sandboxes/.active-handle` file. + fn fresh_state_at(app_root: std::path::PathBuf) -> AppState { + let config = Arc::new(ConfigStore::new()); + let repo_dir = std::env::temp_dir(); + let logs = Arc::new(crate::log_store::LogStore::new(app_root.join("logs"))); + let tasks = Arc::new(TaskRegistry::new(logs)); + let broadcaster = Arc::new(Broadcaster::new()); + let setup = SetupOrchestrator::new( + repo_dir.clone(), + repo_dir.clone(), + config.clone(), + tasks.clone(), + broadcaster.clone(), + ); + AppState { + token: "test-token".into(), + boot_id: "test-boot".into(), + sandbox_manager: crate::sandbox::SandboxManager::new(app_root.clone()), + app_root, + repo_dir, + mode: crate::state::ApiMode::Strict, + config, + tasks, + broadcaster, + shutdown: Arc::new(crate::shutdown::ShutdownCoordinator::new()), + setup, + } + } + + #[test] + fn headerless_resolve_falls_back_to_the_global_target_when_nothing_is_active() { + let state = fresh_state(); + let target = resolve(&state, &HeaderMap::new()).expect("headerless never errors"); + assert!(Arc::ptr_eq(&target.setup, &state.setup)); + } + + #[test] + fn an_explicit_unknown_handle_is_a_404_not_a_silent_global_fallback() { + let state = fresh_state(); + let mut headers = HeaderMap::new(); + headers.insert( + "x-decocms-sandbox-handle", + HeaderValue::from_static("never-ensured-handle"), + ); + // `SandboxTarget` (the `Ok` side) isn't `Debug`, so `expect_err` + // isn't available here — match explicitly instead. + match resolve(&state, &headers) { + Err(e) => { + assert_eq!(e.status, axum::http::StatusCode::NOT_FOUND); + assert_eq!( + e.body["error"], + "unknown sandbox handle: never-ensured-handle" + ); + } + Ok(_) => panic!("unknown handle must error"), + } + } + + #[test] + fn an_empty_handle_header_behaves_like_no_header_at_all() { + let state = fresh_state(); + let mut headers = HeaderMap::new(); + headers.insert("x-decocms-sandbox-handle", HeaderValue::from_static("")); + let target = resolve(&state, &headers).expect("empty handle is treated as absent"); + assert!(Arc::ptr_eq(&target.setup, &state.setup)); + } + + #[tokio::test] + async fn setup_routes_return_503_when_the_worker_rejects_admission() { + // `start` probes the durable active-sandbox pointer before falling back + // to the process-global orchestrator. Give this test its own app root: + // using the shared system temp directory can resurrect a sandbox left + // by another test/process and accidentally exercise that open worker + // instead of the deliberately closed global worker below. + let root = tempfile::tempdir().unwrap(); + let state = fresh_state_at(root.path().to_path_buf()); + state.setup.close(); + + for result in [ + clone(State(state.clone()), HeaderMap::new()).await, + install(State(state.clone()), HeaderMap::new()).await, + start(State(state.clone()), HeaderMap::new()).await, + ] { + let error = result.expect_err("closed setup worker must reject the request"); + assert_eq!(error.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(error.body["error"], "setup worker is unavailable"); + } + } + + fn git(dir: &std::path::Path, args: &[&str]) { + let out = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git failed to spawn"); + assert!(out.status.success(), "git {args:?} failed: {out:?}"); + } + + /// Mirrors `sandbox::target::tests::ensure_one_sandbox` — a real + /// one-commit bare origin, `ensure()`-d into a fresh per-handle sandbox + /// (which also marks it `active()`). + async fn ensure_one_sandbox( + state: &AppState, + vmcp: &str, + ) -> Arc { + let dir = tempfile::tempdir().unwrap(); + let bare_dir = dir.path().join("origin.git"); + let work_dir = dir.path().join("author"); + std::fs::create_dir_all(&bare_dir).unwrap(); + std::fs::create_dir_all(&work_dir).unwrap(); + git(&bare_dir, &["init", "--bare", "-q"]); + git(&work_dir, &["init", "-q", "-b", "main"]); + git(&work_dir, &["config", "user.name", "Test User"]); + git(&work_dir, &["config", "user.email", "test@example.com"]); + std::fs::write(work_dir.join("f.txt"), "x").unwrap(); + git(&work_dir, &["add", "."]); + git(&work_dir, &["commit", "-q", "-m", "initial"]); + let bare_str = bare_dir.to_str().unwrap(); + git(&work_dir, &["remote", "add", "origin", bare_str]); + git(&work_dir, &["push", "-q", "-u", "origin", "main"]); + git(&bare_dir, &["symbolic-ref", "HEAD", "refs/heads/main"]); + + state + .sandbox_manager + .ensure(&crate::sandbox::GitSandboxConfig { + virtual_mcp_id: vmcp.to_string(), + clone_url: bare_str.to_string(), + branch: Some("main".to_string()), + ..Default::default() + }) + .await + .expect("ensure succeeds against a real one-commit bare repo") + } + + #[tokio::test] + async fn ensure_route_accepts_desktop_sandbox_block_and_persists_identity() { + let app_root = tempfile::tempdir().unwrap(); + let state = fresh_state_at(app_root.path().to_path_buf()); + let origin_root = tempfile::tempdir().unwrap(); + let bare_dir = origin_root.path().join("origin.git"); + let author_dir = origin_root.path().join("author"); + std::fs::create_dir_all(&bare_dir).unwrap(); + std::fs::create_dir_all(&author_dir).unwrap(); + git(&bare_dir, &["init", "--bare", "-q"]); + git(&author_dir, &["init", "-q", "-b", "main"]); + git(&author_dir, &["config", "user.name", "Test User"]); + git(&author_dir, &["config", "user.email", "test@example.com"]); + std::fs::write(author_dir.join("README.md"), "fixture").unwrap(); + git(&author_dir, &["add", "."]); + git(&author_dir, &["commit", "-q", "-m", "initial"]); + git( + &author_dir, + &["remote", "add", "origin", bare_dir.to_str().unwrap()], + ); + git(&author_dir, &["push", "-q", "-u", "origin", "main"]); + git(&bare_dir, &["symbolic-ref", "HEAD", "refs/heads/main"]); + + let response = ensure( + State(state.clone()), + Json(EnsureSandboxRequest { + virtual_mcp_id: "vmcp-ensure-route".to_string(), + repo: EnsureRepository { + clone_url: bare_dir.to_string_lossy().into_owned(), + branch: Some("main".to_string()), + }, + workload: Some(EnsureWorkload { + runtime: Some("bun".to_string()), + package_manager: Some("bun".to_string()), + package_manager_path: None, + }), + }), + ) + .await + .expect("ensure durably registers and queues a real sandbox"); + + let handle = response.0["handle"].as_str().unwrap(); + assert_eq!( + handle, + crate::sandbox::SandboxManager::compute_handle("vmcp-ensure-route", "main") + ); + assert!(state.sandbox_manager.get(handle).is_some()); + assert_eq!( + state + .sandbox_manager + .registered_active_handle() + .unwrap() + .as_deref(), + Some(handle) + ); + let record = state + .sandbox_manager + .registry_record(handle) + .unwrap() + .unwrap(); + assert_eq!(record.config.package_manager.as_deref(), Some("bun")); + assert_eq!(record.desired_status, "running"); + } + + #[tokio::test] + async fn ensure_route_returns_early_and_records_async_clone_failure() { + let app_root = tempfile::tempdir().unwrap(); + let state = fresh_state_at(app_root.path().to_path_buf()); + let response = ensure( + State(state.clone()), + Json(EnsureSandboxRequest { + virtual_mcp_id: "vmcp-broken-clone".to_string(), + repo: EnsureRepository { + clone_url: app_root + .path() + .join("missing.git") + .to_string_lossy() + .into_owned(), + branch: Some("main".to_string()), + }, + workload: None, + }), + ) + .await + .expect("the control route returns before git so SSE can observe it"); + assert_eq!(response.0["state"], "provisioning"); + + let handle = crate::sandbox::SandboxManager::compute_handle("vmcp-broken-clone", "main"); + let record = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let record = state + .sandbox_manager + .registry_record(&handle) + .unwrap() + .unwrap(); + if record.observed_status == "failed" { + break record; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("clone failure reaches durable lifecycle state"); + assert_eq!(record.observed_status, "failed"); + let error = record.error.unwrap(); + assert!(error.contains("git"), "unexpected clone failure: {error}"); + } + + #[tokio::test] + async fn an_explicit_known_handle_resolves_to_that_sandboxs_own_setup() { + let state = fresh_state(); + let sandbox = ensure_one_sandbox(&state, "setup-resolve-known").await; + + let mut headers = HeaderMap::new(); + headers.insert( + "x-decocms-sandbox-handle", + HeaderValue::from_str(&sandbox.handle).unwrap(), + ); + let target = resolve(&state, &headers).expect("known handle resolves"); + assert!(Arc::ptr_eq(&target.setup, &sandbox.setup)); + assert_eq!(target.repo_dir, sandbox.workdir); + } + + #[tokio::test] + async fn headerless_resolve_prefers_the_active_sandbox_over_global() { + let state = fresh_state(); + // `ensure()` marks its handle active, so a headerless resolve must + // follow it — NOT fall all the way to the global orchestrator (see + // `sandbox::target`'s equivalent coverage for `resolve_sandbox_target` + // directly; this test pins the SAME behavior through this file's + // stricter `resolve()` wrapper). + let sandbox = ensure_one_sandbox(&state, "setup-resolve-active").await; + + let target = resolve(&state, &HeaderMap::new()).expect("headerless never errors"); + assert!(Arc::ptr_eq(&target.setup, &sandbox.setup)); + assert!(!Arc::ptr_eq(&target.setup, &state.setup)); + } + + // --- start(): resurrection after a simulated backend restart ----------- + + /// `ensure()`s a real git sandbox into `app_root` via a THROWAWAY + /// `AppState`/`SandboxManager`, then drops it — leaving only the + /// workdir + persisted sidecar + `.active-handle` file on disk, exactly + /// like a real backend process restart (same `LOCAL_API_WORKDIR`, brand + /// new in-memory state). Returns the handle. + async fn ensure_then_forget(app_root: &std::path::Path, vmcp: &str) -> String { + let throwaway = fresh_state_at(app_root.to_path_buf()); + let sandbox = ensure_one_sandbox(&throwaway, vmcp).await; + sandbox.handle.clone() + // `throwaway` (and its `SandboxManager`) drops here — its in-memory + // `sandboxes` map and `active_handle` go with it. + } + + #[tokio::test] + async fn start_resurrects_an_explicit_handle_forgotten_by_a_process_restart() { + let app_root = tempfile::tempdir().unwrap(); + let handle = ensure_then_forget(app_root.path(), "start-resurrect-explicit").await; + + // A FRESH state over the SAME app_root — simulates the restarted + // process: `state.sandbox_manager` has never heard of `handle`. + let state = fresh_state_at(app_root.path().to_path_buf()); + assert!(state.sandbox_manager.get(&handle).is_none()); + + let mut headers = HeaderMap::new(); + headers.insert( + "x-decocms-sandbox-handle", + HeaderValue::from_str(&handle).unwrap(), + ); + let res = start(State(state.clone()), headers) + .await + .expect("start resurrects the forgotten handle instead of 404ing"); + assert_eq!(res.0["enqueued"], "start"); + assert!( + state.sandbox_manager.get(&handle).is_some(), + "a successful resurrection must leave the handle known in memory afterward" + ); + } + + #[tokio::test] + async fn start_headerless_resurrects_the_persisted_active_handle_after_a_restart() { + let app_root = tempfile::tempdir().unwrap(); + let handle = ensure_then_forget(app_root.path(), "start-resurrect-headerless").await; + + let state = fresh_state_at(app_root.path().to_path_buf()); + assert!(state.sandbox_manager.active().is_none()); + + let res = start(State(state.clone()), HeaderMap::new()) + .await + .expect("headerless start resurrects the persisted active handle"); + assert_eq!(res.0["enqueued"], "start"); + assert!( + state.sandbox_manager.get(&handle).is_some(), + "the persisted active handle must be the one resurrected" + ); + } + + #[tokio::test] + async fn start_with_an_explicit_handle_that_has_no_sidecar_is_still_a_404() { + // Regression guard: resurrection must not turn a GENUINELY unknown + // handle (never `ensure()`-d in ANY process lifetime — no sidecar + // exists at all) into a silent success. + let app_root = tempfile::tempdir().unwrap(); + let state = fresh_state_at(app_root.path().to_path_buf()); + let mut headers = HeaderMap::new(); + headers.insert( + "x-decocms-sandbox-handle", + HeaderValue::from_static("never-ensured-handle"), + ); + match start(State(state), headers).await { + Err(e) => { + assert_eq!(e.status, axum::http::StatusCode::NOT_FOUND); + assert_eq!( + e.body["error"], + "unknown sandbox handle: never-ensured-handle" + ); + } + Ok(_) => panic!("a handle with no sidecar must never silently succeed"), + } + } + + #[tokio::test] + async fn start_headerless_on_a_fresh_instance_with_nothing_persisted_still_200s_the_global_path( + ) { + // Byte-parity floor (`daemon.git.e2e.test.ts`'s "setup routes" + // describe): a headerless start on a process that has NEVER seen a + // git sandbox (no active handle ever persisted) must still ack 200 + // against the process-global path, not error. + let app_root = tempfile::tempdir().unwrap(); + let state = fresh_state_at(app_root.path().to_path_buf()); + let res = start(State(state), HeaderMap::new()) + .await + .expect("headerless start on a never-configured instance still 200s"); + assert_eq!(res.0["enqueued"], "start"); + } + + #[tokio::test] + async fn start_does_not_resurrect_when_the_handle_is_already_known_in_memory() { + // The "already known" (not-resurrected) branch must keep the + // PRE-EXISTING kill+resume_from(Start) behavior — this is really a + // guard that `resolve_for_start`'s `already_known`/`already_active` + // computation doesn't accidentally flip `resurrected` to `true` for + // the common, non-restart case. + let state = fresh_state(); + let sandbox = ensure_one_sandbox(&state, "start-no-resurrect-needed").await; + let mut headers = HeaderMap::new(); + headers.insert( + "x-decocms-sandbox-handle", + HeaderValue::from_str(&sandbox.handle).unwrap(), + ); + let res = start(State(state.clone()), headers) + .await + .expect("start on an already-known handle succeeds"); + assert_eq!(res.0["enqueued"], "start"); + assert!(Arc::ptr_eq( + &state.sandbox_manager.get(&sandbox.handle).unwrap(), + &sandbox + )); + } + + #[tokio::test] + async fn start_with_a_truly_unknown_explicit_handle_is_a_404() { + let root = tempfile::tempdir().unwrap(); + let state = fresh_state_at(root.path().to_path_buf()); + let mut headers = HeaderMap::new(); + headers.insert( + "x-decocms-sandbox-handle", + HeaderValue::from_static("never-registered"), + ); + let error = start(State(state), headers) + .await + .expect_err("explicit start must not fall back to the global setup worker"); + assert_eq!(error.status, axum::http::StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn restart_waits_for_old_dev_task_to_be_terminal_before_admitting_start() { + let root = tempfile::tempdir().unwrap(); + let state = fresh_state_at(root.path().to_path_buf()); + let controller = crate::tasks::ProcessController::new(); + state.tasks.insert(crate::tasks::TaskEntry::new( + crate::tasks::TaskSummary { + id: "old-dev".to_string(), + command: "bun run dev".to_string(), + status: TaskStatus::Running, + exit_code: None, + started_at: 0, + finished_at: None, + timed_out: false, + truncated: false, + log_name: Some("dev".to_string()), + intentional: None, + }, + Some(controller.kill_handle()), + )); + let owner = { + let tasks = state.tasks.clone(); + let controller = controller.clone(); + tokio::spawn(async move { + let signal = controller.wait_for_change(None).await; + tokio::task::yield_now().await; + tasks.finalize("old-dev", TaskStatus::Killed, signal.exit_code(), false); + }) + }; + + let response = start(State(state.clone()), HeaderMap::new()) + .await + .expect("restart reaps the old task before queueing Start"); + assert_eq!(response.0["enqueued"], "start"); + owner.await.unwrap(); + assert_eq!( + state.tasks.get("old-dev").unwrap().status, + TaskStatus::Killed + ); + assert_eq!(controller.requested(), Some(KillSignal::Term)); + } + + // --- stop() -------------------------------------------------------------- + + fn insert_running_task( + state: &AppState, + id: &str, + log_name: Option<&str>, + ) -> Arc { + let killed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = killed.clone(); + let kill_handle: crate::tasks::KillHandle = Arc::new(move |_sig| { + flag.store(true, std::sync::atomic::Ordering::SeqCst); + true + }); + let summary = crate::tasks::TaskSummary { + id: id.to_string(), + command: "npm run dev".to_string(), + status: crate::tasks::TaskStatus::Running, + exit_code: None, + started_at: 0, + finished_at: None, + timed_out: false, + truncated: false, + log_name: log_name.map(str::to_string), + intentional: None, + }; + state + .tasks + .insert(crate::tasks::TaskEntry::new(summary, Some(kill_handle))); + killed + } + + #[tokio::test] + async fn stop_kills_the_running_dev_task_and_reports_the_killed_count() { + let state = fresh_state(); + let killed_flag = insert_running_task(&state, "task-dev-1", Some("dev")); + + let res = stop(State(state.clone()), HeaderMap::new()) + .await + .expect("stop succeeds when a dev task is running"); + assert_eq!(res.0["stopped"], true); + assert_eq!(res.0["killed"], 1); + assert!( + killed_flag.load(std::sync::atomic::Ordering::SeqCst), + "the task's real KillHandle must have been invoked" + ); + assert_eq!( + state.setup.lifecycle_snapshot()["phase"], + "idle", + "stop transitions lifecycle back to idle" + ); + } + + #[tokio::test] + async fn stop_kills_a_running_start_task_too() { + // `log_name: "start"` (byte-parity script name for a starter script + // literally named "start" rather than "dev") must be treated the + // same as "dev" — mirrors `start()`'s own kill-loop filter. + let state = fresh_state(); + let killed_flag = insert_running_task(&state, "task-start-1", Some("start")); + let res = stop(State(state), HeaderMap::new()) + .await + .expect("stop succeeds for a 'start'-named task too"); + assert_eq!(res.0["killed"], 1); + assert!(killed_flag.load(std::sync::atomic::Ordering::SeqCst)); + } + + #[tokio::test] + async fn stop_returns_a_400_when_nothing_is_running() { + let state = fresh_state(); + match stop(State(state), HeaderMap::new()).await { + Err(e) => { + assert_eq!(e.status, axum::http::StatusCode::BAD_REQUEST); + assert!(e.body["error"] + .as_str() + .unwrap() + .contains("nothing to stop")); + } + Ok(_) => panic!("stop with nothing running must error, never silently 200"), + } + } + + #[tokio::test] + async fn stop_ignores_running_tasks_that_are_not_dev_or_start() { + let state = fresh_state(); + let killed_flag = insert_running_task(&state, "task-bash-1", Some("bash")); + match stop(State(state), HeaderMap::new()).await { + Err(e) => assert_eq!(e.status, axum::http::StatusCode::BAD_REQUEST), + Ok(_) => panic!("an unrelated running task must not count as something to stop"), + } + assert!( + !killed_flag.load(std::sync::atomic::Ordering::SeqCst), + "an unrelated task must never be killed by a Stop click" + ); + } + + #[tokio::test] + async fn stop_with_an_explicit_unknown_handle_is_a_404_never_a_silent_noop() { + let state = fresh_state(); + let mut headers = HeaderMap::new(); + headers.insert( + "x-decocms-sandbox-handle", + HeaderValue::from_static("never-ensured-handle"), + ); + match stop(State(state), headers).await { + Err(e) => assert_eq!(e.status, axum::http::StatusCode::NOT_FOUND), + Ok(_) => panic!("stop must never silently succeed against an unresolvable handle"), + } + } + + #[tokio::test] + async fn stop_is_idempotent_for_a_registered_handle_after_process_restart() { + let app_root = tempfile::tempdir().unwrap(); + let handle = ensure_then_forget(app_root.path(), "stop-no-resurrect").await; + let state = fresh_state_at(app_root.path().to_path_buf()); + + let mut headers = HeaderMap::new(); + headers.insert( + "x-decocms-sandbox-handle", + HeaderValue::from_str(&handle).unwrap(), + ); + let response = stop(State(state.clone()), headers) + .await + .expect("a registered sandbox with no live process is already stopped"); + assert_eq!(response.0["stopped"], true); + assert_eq!(response.0["alreadyStopped"], true); + assert_eq!(response.0["killed"], 0); + assert!( + state.sandbox_manager.get(&handle).is_none(), + "stop must not materialize or start the persisted sandbox" + ); + let record = state + .sandbox_manager + .registry_record(&handle) + .unwrap() + .unwrap(); + assert_eq!(record.desired_status, "stopped"); + assert_eq!(record.observed_status, "stopped"); + } +} diff --git a/apps/native/crates/local-api/src/routes/tasks.rs b/apps/native/crates/local-api/src/routes/tasks.rs new file mode 100644 index 0000000000..4895147338 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/tasks.rs @@ -0,0 +1,447 @@ +//! `/_sandbox/tasks*` — byte-parity target: `daemon/routes/tasks.ts` (the +//! `TaskSummary` shape lives in `tasks/registry.rs`, ported from +//! `process/task-manager.ts`), oracle `daemon.e2e.test.ts` (`tasks` +//! describe block) + `daemon.sse-shapes.e2e.test.ts` (the `"tasks"` SSE +//! event shape, pinned indirectly here since it's `emit_tasks_event` in +//! `routes/bash.rs` that produces it — this file only serves the +//! query/kill/delete/stream surface below). +//! +//! All six handlers below operate purely against the resolved target's +//! `TaskRegistry` — they never spawn a process themselves; that's the +//! `bash`/`scripts` families' job (whichever spawned the task calls +//! `TaskRegistry::insert`/kill-handle wiring). + +use axum::body::Bytes as BodyBytes; +use axum::extract::{Path, Query, State}; +use axum::http::{header, HeaderMap, HeaderName, HeaderValue}; +use axum::response::Response; +use axum::Json; +use futures_util::stream::{self, StreamExt}; +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::sync::broadcast; + +use crate::error::{ApiError, ApiResult}; +use crate::sandbox::SandboxTarget; +use crate::state::AppState; +use crate::tasks::{KillSignal, OutputStream, StreamEvent, TaskStatus}; + +/// Resolve a task target without ever degrading an explicit sandbox identity +/// to the process-global registry. After a backend restart a known durable +/// handle is metadata-adopted (no process spawn); a genuinely unknown handle +/// is a truthful 404. Only an absent handle uses active/global compatibility. +async fn resolve(state: &AppState, handle: Option<&str>) -> ApiResult { + let Some(handle) = handle.filter(|handle| !handle.is_empty()) else { + return Ok(state.resolve_sandbox_target(None)); + }; + match state.sandbox_manager.adopt(handle).await { + Ok(Some(sandbox)) => Ok(SandboxTarget::from_sandbox(&sandbox)), + Ok(None) => Err(ApiError::not_found(format!( + "unknown sandbox handle: {handle}" + ))), + Err(error) => Err(ApiError::internal(format!( + "failed to resolve sandbox handle {handle}: {error}" + ))), + } +} + +async fn resolve_headers(state: &AppState, headers: &HeaderMap) -> ApiResult { + resolve(state, crate::sandbox::handle_from_headers(headers)).await +} + +#[derive(Deserialize)] +pub struct StatusQuery { + status: Option, +} + +/// Optional `?handle=` for the SSE `stream` route (a native `EventSource` +/// can't set the handle header) — the header still wins when both are present. +#[derive(Deserialize)] +pub struct HandleQuery { + handle: Option, +} + +/// `GET /_sandbox/tasks?status=a,b`. Byte-parity quirk carried over from +/// `tasks.ts`: if `status` is present but every comma-separated token is +/// unrecognized, the filter degrades to "no filter" (list everything) +/// rather than "match nothing". +pub async fn list( + State(state): State, + headers: HeaderMap, + Query(q): Query, +) -> ApiResult> { + let parsed: Option> = q + .status + .as_deref() + .map(|s| s.split(',').filter_map(TaskStatus::parse).collect()); + let filter: Option<&[TaskStatus]> = match &parsed { + Some(v) if !v.is_empty() => Some(v.as_slice()), + _ => None, + }; + let tasks = resolve_headers(&state, &headers).await?.tasks.list(filter); + Ok(Json(json!({ "tasks": tasks }))) +} + +/// `GET /_sandbox/tasks/:id` — `TaskSummary & {stdout,stderr,truncated}`. +pub async fn get( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> ApiResult> { + let target = resolve_headers(&state, &headers).await?; + let summary = target + .tasks + .get_exposed(&id) + .ok_or_else(|| ApiError::not_found("task not found"))?; + let (stdout, stderr, truncated) = target.tasks.output_exposed(&id).await.unwrap_or_default(); + let mut value = serde_json::to_value(&summary).unwrap_or_else(|_| json!({})); + if let Value::Object(map) = &mut value { + map.insert("stdout".to_string(), json!(stdout)); + map.insert("stderr".to_string(), json!(stderr)); + map.insert("truncated".to_string(), json!(truncated)); + } + Ok(Json(value)) +} + +#[derive(Deserialize)] +pub struct SignalQuery { + signal: Option, +} + +/// `POST /_sandbox/tasks/:id/kill?signal=SIGTERM|SIGKILL`. Byte-parity with +/// `tasks.ts`: an unknown id and a known-but-not-running id both surface as +/// `400 {"error":"task not running"}` — there is no 404 branch here. +pub async fn kill( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Query(q): Query, +) -> ApiResult> { + let signal = match q.signal.as_deref() { + Some("SIGKILL") => KillSignal::Kill, + _ => KillSignal::Term, + }; + match resolve_headers(&state, &headers) + .await? + .tasks + .kill_exposed(&id, signal) + { + Some(true) => Ok(Json(json!({"ok": true}))), + _ => Err(ApiError::bad_request("task not running")), + } +} + +/// `POST /_sandbox/tasks/kill-all`. +pub async fn kill_all(State(state): State, headers: HeaderMap) -> ApiResult> { + let killed = resolve_headers(&state, &headers) + .await? + .tasks + .kill_all(KillSignal::Term); + Ok(Json(json!({"ok": true, "killed": killed}))) +} + +/// `DELETE /_sandbox/tasks/:id`. Byte-parity with `tasks.ts`: unknown id +/// and still-running id both surface as +/// `400 {"error":"task not found or still running"}`. +pub async fn delete( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> ApiResult> { + match resolve_headers(&state, &headers) + .await? + .tasks + .remove_exposed(&id) + .await + { + Some(true) => Ok(Json(json!({"ok": true}))), + _ => Err(ApiError::bad_request("task not found or still running")), + } +} + +/// SSE: replays buffered stdout/stderr events, then live, then `end` and +/// closes. Byte-parity framing: `the native local-API contract +/// #sse-framing` (`event: \ndata: \n\n`, verbatim with +/// `sseFormat()`). +pub async fn stream( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Query(q): Query, +) -> Result { + // Handle from the header (fetch/`FetchEventSource`) OR `?handle=` (native + // `EventSource`), header winning. An explicit unknown handle is never + // allowed to fall through to active/global. + let handle = crate::sandbox::handle_from_headers(&headers) + .map(str::to_string) + .or(q.handle); + let target = resolve(&state, handle.as_deref()).await?; + + // Subscribe BEFORE reading the summary/output snapshot: if the task is + // still running at that snapshot, this subscription is guaranteed to + // see the eventual `StreamEvent::End` (finalize() sends it under the + // same lock as the terminal status flip — see `tasks/registry.rs`), no + // matter how the two events interleave with our check below. + let Some(rx) = target.tasks.subscribe_output_exposed(&id) else { + return Err(ApiError::not_found("task not found")); + }; + let Some(summary) = target.tasks.get_exposed(&id) else { + return Err(ApiError::not_found("task not found")); + }; + let (stdout, stderr, _truncated) = target.tasks.output_exposed(&id).await.unwrap_or_default(); + + let mut frames: Vec = Vec::new(); + if !stdout.is_empty() { + frames.push(sse_bytes( + OutputStream::Stdout.as_str(), + &json!({"data": stdout}), + )); + } + if !stderr.is_empty() { + frames.push(sse_bytes( + OutputStream::Stderr.as_str(), + &json!({"data": stderr}), + )); + } + + let body_stream: futures_util::stream::BoxStream< + 'static, + Result, + > = if summary.status.is_terminal() { + frames.push(end_frame( + summary.status, + summary.exit_code, + summary.timed_out, + )); + Box::pin(stream::iter(frames.into_iter().map(Ok))) + } else { + let live = stream::unfold(LiveState::Recv(rx), live_step); + Box::pin(stream::iter(frames.into_iter().map(Ok)).chain(live)) + }; + + let mut response = Response::new(axum::body::Body::from_stream(body_stream)); + let headers = response.headers_mut(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/event-stream"), + ); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache")); + headers.insert(header::CONNECTION, HeaderValue::from_static("keep-alive")); + headers.insert( + HeaderName::from_static("x-accel-buffering"), + HeaderValue::from_static("no"), + ); + Ok(response) +} + +enum LiveState { + Recv(broadcast::Receiver), + Done, +} + +async fn live_step( + state: LiveState, +) -> Option<(Result, LiveState)> { + let mut rx = match state { + LiveState::Done => return None, + LiveState::Recv(rx) => rx, + }; + loop { + match rx.recv().await { + Ok(StreamEvent::Chunk { stream, data }) => { + return Some(( + Ok(sse_bytes(stream.as_str(), &json!({"data": data}))), + LiveState::Recv(rx), + )); + } + Ok(StreamEvent::End { + status, + exit_code, + timed_out, + }) => { + return Some((Ok(end_frame(status, exit_code, timed_out)), LiveState::Done)); + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } + } +} + +fn end_frame(status: TaskStatus, exit_code: Option, timed_out: bool) -> BodyBytes { + sse_bytes( + "end", + &json!({"status": status.as_str(), "exitCode": exit_code, "timedOut": timed_out}), + ) +} + +/// `event: \ndata: \n\n` — byte-parity verbatim with +/// `sseFormat()` in `daemon/events/sse-format.ts`. +fn sse_bytes(event: &str, data: &Value) -> BodyBytes { + let payload = serde_json::to_string(data).unwrap_or_else(|_| "null".to_string()); + BodyBytes::from(format!("event: {event}\ndata: {payload}\n\n")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + fn test_state() -> AppState { + let app_root = tempfile::tempdir().unwrap().keep(); + let repo_dir = app_root.join("repo"); + let config = Arc::new(crate::config::ConfigStore::new()); + let logs = Arc::new(crate::log_store::LogStore::new(app_root.join("logs"))); + let tasks = Arc::new(crate::tasks::TaskRegistry::new(logs)); + let broadcaster = Arc::new(crate::events::Broadcaster::new()); + let setup = crate::setup::SetupOrchestrator::new( + repo_dir.clone(), + repo_dir.clone(), + config.clone(), + tasks.clone(), + broadcaster.clone(), + ); + AppState { + token: Arc::from("test-token"), + boot_id: Arc::from("test-boot"), + sandbox_manager: crate::sandbox::SandboxManager::new(app_root.clone()), + app_root, + repo_dir, + mode: crate::state::ApiMode::Strict, + config, + tasks, + broadcaster, + shutdown: Arc::new(crate::shutdown::ShutdownCoordinator::new()), + setup, + } + } + + #[test] + fn sse_bytes_matches_daemon_frame_format() { + let bytes = sse_bytes("stdout", &json!({"data": "hi"})); + assert_eq!( + std::str::from_utf8(&bytes).expect("utf8"), + "event: stdout\ndata: {\"data\":\"hi\"}\n\n" + ); + } + + #[test] + fn end_frame_matches_daemon_frame_format() { + let bytes = end_frame(TaskStatus::Exited, Some(0), false); + assert_eq!( + std::str::from_utf8(&bytes).expect("utf8"), + "event: end\ndata: {\"exitCode\":0,\"status\":\"exited\",\"timedOut\":false}\n\n" + ); + } + + #[tokio::test] + async fn internal_shutdown_task_is_invisible_and_uncontrollable_over_routes() { + let state = test_state(); + let controller = crate::tasks::ProcessController::new(); + state.tasks.insert(crate::tasks::TaskEntry::new_internal( + crate::tasks::TaskSummary { + id: "internal".to_string(), + command: "request-owned".to_string(), + status: TaskStatus::Running, + exit_code: None, + started_at: 0, + finished_at: None, + timed_out: false, + truncated: false, + log_name: None, + intentional: None, + }, + Some(controller.kill_handle()), + )); + + let Json(listed) = list( + State(state.clone()), + HeaderMap::new(), + Query(StatusQuery { status: None }), + ) + .await + .expect("global task list resolves"); + assert_eq!(listed, json!({"tasks": []})); + + let get_error = get( + State(state.clone()), + HeaderMap::new(), + Path("internal".to_string()), + ) + .await + .expect_err("internal task id behaves as unknown"); + assert_eq!(get_error.status, axum::http::StatusCode::NOT_FOUND); + + let stream_error = stream( + State(state.clone()), + HeaderMap::new(), + Path("internal".to_string()), + Query(HandleQuery { handle: None }), + ) + .await + .expect_err("internal task stream behaves as unknown"); + assert_eq!(stream_error.status, axum::http::StatusCode::NOT_FOUND); + + let kill_error = kill( + State(state.clone()), + HeaderMap::new(), + Path("internal".to_string()), + Query(SignalQuery { signal: None }), + ) + .await + .expect_err("internal task cannot be killed by guessed id"); + assert_eq!(kill_error.status, axum::http::StatusCode::BAD_REQUEST); + let Json(killed) = kill_all(State(state.clone()), HeaderMap::new()) + .await + .expect("global task registry resolves"); + assert_eq!(killed, json!({"ok": true, "killed": 0})); + assert_eq!(controller.requested(), None); + + let shutdown = state + .tasks + .kill_all_and_wait(std::time::Duration::ZERO, std::time::Duration::ZERO) + .await; + assert_eq!(shutdown.initially_running, 1); + assert_eq!(controller.requested(), Some(KillSignal::Kill)); + } + + #[tokio::test] + async fn explicit_unknown_handle_never_falls_through_to_global_tasks() { + let state = test_state(); + let controller = crate::tasks::ProcessController::new(); + state.tasks.insert(crate::tasks::TaskEntry::new( + crate::tasks::TaskSummary { + id: "global-task".to_string(), + command: "must-not-be-touched".to_string(), + status: TaskStatus::Running, + exit_code: None, + started_at: 0, + finished_at: None, + timed_out: false, + truncated: false, + log_name: Some("dev".to_string()), + intentional: None, + }, + Some(controller.kill_handle()), + )); + let mut headers = HeaderMap::new(); + headers.insert( + crate::sandbox::SANDBOX_HANDLE_HEADER, + HeaderValue::from_static("unknown-handle"), + ); + + let list_error = list( + State(state.clone()), + headers.clone(), + Query(StatusQuery { status: None }), + ) + .await + .expect_err("unknown explicit sandbox must be rejected"); + assert_eq!(list_error.status, axum::http::StatusCode::NOT_FOUND); + + let kill_error = kill_all(State(state), headers) + .await + .expect_err("unknown explicit sandbox must not kill global tasks"); + assert_eq!(kill_error.status, axum::http::StatusCode::NOT_FOUND); + assert_eq!(controller.requested(), None); + } +} diff --git a/apps/native/crates/local-api/src/routes/threads.rs b/apps/native/crates/local-api/src/routes/threads.rs new file mode 100644 index 0000000000..26856e54c8 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/threads.rs @@ -0,0 +1,260 @@ +//! `/threads*` — new, no daemon precedent. Local-only SQLite store (no +//! upstream sync — the desktop migration contract decision #6). Full shape/route table: +//! the native local-API contract, oracle +//! `apps/native/e2e/threads.e2e.test.ts`. +//! +//! Implementer notes: +//! - Storage: `/.decocms/local.db` (WAL-mode SQLite via the +//! `rusqlite` dependency already in `Cargo.toml`). Distinct from a +//! project's `.deco/tools/` catalog dir used by `fs::tools_sync` — do not +//! collide the two directories. +//! - `Thread { id, title, created_at, updated_at }`, +//! `Message { id, thread_id, role, parts: JSON array (opaque), created_at }`, +//! `Run { id, thread_id, harness_id, status, created_at, ended_at, error }` +//! — `Run.id` MUST equal the dispatch route's `runId` for the same turn. +//! Phase 2 CORRECTION to an earlier Phase 1 doc pass: `routes/dispatch.rs` +//! DOES write to this store as its SSE stream lands (see that file's +//! module doc for why — unlike the TS daemon, local-api's dispatch route +//! has no separate cluster-side consumer to hand the write off to; the +//! desktop frontend IS the SSE consumer, so dispatch persists on its +//! behalf). The write path uses [`ThreadsDb::create_message_with_id`] / +//! [`ThreadsDb::create_run`] / [`ThreadsDb::set_run_terminal_status`] +//! (idempotent-by-id variants) rather than this file's own +//! `create_message`, so a dispatch retry/re-poll can never duplicate a +//! thread's message history. +//! - `POST /threads` defaults `title` to `""` (never null) when the body +//! omits it. +//! - `DELETE /threads/:id` is idempotent (204 even if already gone, +//! matching the daemon's `unlink` idempotency precedent) and cascades to +//! the thread's messages and runs. +//! +//! The SQLite layer lives in the `db` submodule (`routes/threads/db.rs`) — +//! pure CRUD against a `rusqlite::Connection`, unit-tested there against +//! `:memory:` without touching axum or the filesystem. This file is only +//! the HTTP <-> `ThreadsDb` translation: body parsing, 400/404 shaping, +//! lazy DB handle ([`shared_db`]). + +/// `pub(crate)` (not module-private) so `routes::dispatch` — a SIBLING +/// module, Phase 2's dispatch route persisting run/message rows as a +/// stream lands (see that file's module doc) — can reach `ThreadsDb`, +/// `DbError`, and `DbResult` directly (`crate::routes::threads::db::*`) +/// without a second, independently-opened SQLite connection to the SAME +/// `.decocms/local.db` file. +pub(crate) mod db; + +use std::sync::OnceLock; + +use db::ThreadsDb; + +use axum::body::Bytes; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::error::{ApiError, ApiResult}; +use crate::state::AppState; + +/// Lazily opened once per process. `AppState` is a shared file this family +/// may not add a field to (see the native module-ownership contract), so +/// the handle lives here instead — `state.app_root` (already on +/// `AppState`) is all `ThreadsDb::open` needs, and it never changes after +/// boot, so a process-lifetime `OnceLock` is equivalent to a field. +static DB: OnceLock = OnceLock::new(); + +pub(crate) fn shared_db(state: &AppState) -> ApiResult<&'static ThreadsDb> { + if let Some(db) = DB.get() { + return Ok(db); + } + let opened = ThreadsDb::open(&state.app_root) + .map_err(|e| ApiError::internal(format!("failed to open local thread database: {e}")))?; + // Racing initializers (two concurrent first-requests) both succeed at + // `open`; only one wins `set`, the loser's connection is simply + // dropped. Harmless — SQLite tolerates multiple connections to the same + // WAL-mode file. + let _ = DB.set(opened); + DB.get() + .ok_or_else(|| ApiError::internal("thread database failed to initialize")) +} + +/// `pub(crate)` (not just module-private) for the same reason as +/// `shared_db`/`ThreadsDb` above — `routes::dispatch` maps `DbError`s from +/// its own `ThreadsDb` calls through this exact helper so a storage +/// failure surfaces identically regardless of which route hit it. +pub(crate) fn db_err(e: db::DbError) -> ApiError { + ApiError::internal(format!("thread database error: {e}")) +} + +fn parse_body(bytes: &[u8]) -> ApiResult { + if bytes.is_empty() { + return Ok(T::default()); + } + serde_json::from_slice(bytes) + .map_err(|e| ApiError::bad_request(format!("invalid JSON body: {e}"))) +} + +fn thread_not_found() -> ApiError { + ApiError::not_found("thread not found") +} + +// --- GET /threads ------------------------------------------------------------- + +pub async fn list(State(state): State) -> ApiResult> { + let db = shared_db(&state)?; + let threads = db.list_threads().map_err(db_err)?; + Ok(Json(json!({ "threads": threads }))) +} + +// --- POST /threads ------------------------------------------------------------- + +#[derive(Debug, Deserialize, Default)] +struct CreateThreadBody { + #[serde(default)] + title: Option, +} + +pub async fn create(State(state): State, body: Bytes) -> ApiResult { + let parsed: CreateThreadBody = parse_body(&body)?; + let db = shared_db(&state)?; + let thread = db + .create_thread(parsed.title.unwrap_or_default()) + .map_err(db_err)?; + Ok((StatusCode::CREATED, Json(json!({ "thread": thread }))).into_response()) +} + +// --- GET /threads/:id ----------------------------------------------------------- + +pub async fn get(State(state): State, Path(id): Path) -> ApiResult> { + let db = shared_db(&state)?; + let thread = db + .get_thread(&id) + .map_err(db_err)? + .ok_or_else(thread_not_found)?; + Ok(Json(json!({ "thread": thread }))) +} + +// --- PATCH /threads/:id ---------------------------------------------------------- + +#[derive(Deserialize, Default)] +struct UpdateThreadBody { + title: Option, +} + +pub async fn update( + State(state): State, + Path(id): Path, + body: Bytes, +) -> ApiResult> { + let parsed: UpdateThreadBody = parse_body(&body)?; + let title = parsed + .title + .ok_or_else(|| ApiError::bad_request("title is required"))?; + let db = shared_db(&state)?; + let thread = db + .update_thread_title(&id, &title) + .map_err(db_err)? + .ok_or_else(thread_not_found)?; + Ok(Json(json!({ "thread": thread }))) +} + +// --- DELETE /threads/:id --------------------------------------------------------- + +pub async fn delete( + State(state): State, + Path(id): Path, +) -> ApiResult { + let db = shared_db(&state)?; + db.delete_thread(&id).map_err(db_err)?; + Ok(StatusCode::NO_CONTENT) +} + +// --- GET /threads/:id/messages ---------------------------------------------------- + +pub async fn messages_list( + State(state): State, + Path(id): Path, +) -> ApiResult> { + let db = shared_db(&state)?; + if !db.thread_exists(&id).map_err(db_err)? { + return Err(thread_not_found()); + } + let messages = db.list_messages(&id).map_err(db_err)?; + Ok(Json(json!({ "messages": messages }))) +} + +// --- POST /threads/:id/messages --------------------------------------------------- + +#[derive(Deserialize, Default)] +struct CreateMessageBody { + role: Option, + #[serde(default)] + parts: Value, +} + +fn is_valid_role(role: &str) -> bool { + matches!(role, "user" | "assistant" | "system") +} + +pub async fn messages_create( + State(state): State, + Path(id): Path, + body: Bytes, +) -> ApiResult { + let parsed: CreateMessageBody = parse_body(&body)?; + let role = parsed.role.unwrap_or_default(); + if !is_valid_role(&role) { + return Err(ApiError::bad_request( + "role must be one of user, assistant, system", + )); + } + let db = shared_db(&state)?; + if !db.thread_exists(&id).map_err(db_err)? { + return Err(thread_not_found()); + } + let message = db + .create_message(&id, &role, &parsed.parts) + .map_err(db_err)?; + Ok((StatusCode::CREATED, Json(json!({ "message": message }))).into_response()) +} + +// --- GET /threads/:id/runs -------------------------------------------------------- + +pub async fn runs_list( + State(state): State, + Path(id): Path, +) -> ApiResult> { + let db = shared_db(&state)?; + if !db.thread_exists(&id).map_err(db_err)? { + return Err(thread_not_found()); + } + let runs = db.list_runs(&id).map_err(db_err)?; + Ok(Json(json!({ "runs": runs }))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn role_validation_matches_contract_enum() { + assert!(is_valid_role("user")); + assert!(is_valid_role("assistant")); + assert!(is_valid_role("system")); + assert!(!is_valid_role("not-a-role")); + assert!(!is_valid_role("")); + } + + #[test] + fn parse_body_defaults_on_empty_bytes() { + let parsed: CreateThreadBody = parse_body(&[]).unwrap(); + assert!(parsed.title.is_none()); + } + + #[test] + fn parse_body_rejects_invalid_json() { + let err = parse_body::(b"not json").unwrap_err(); + assert_eq!(err.status, StatusCode::BAD_REQUEST); + } +} diff --git a/apps/native/crates/local-api/src/routes/threads/db.rs b/apps/native/crates/local-api/src/routes/threads/db.rs new file mode 100644 index 0000000000..650f491574 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/threads/db.rs @@ -0,0 +1,9411 @@ +//! SQLite storage layer for `/threads*` — no daemon precedent, full shape +//! pinned in the native local-API contract. +//! Kept separate from `routes/threads.rs` (the HTTP layer) so the CRUD/ +//! query logic is unit-testable against an in-memory SQLite connection +//! without spinning up axum. +//! +//! One `Mutex` per process (see `ensure_db` in the +//! parent module). This remains one local app process even though the durable +//! rows isolate multiple signed-in account scopes, so a serialized connection +//! is simpler than a pool and avoids SQLITE_BUSY entirely (all writers already +//! funnel through one `Mutex`). WAL mode is still enabled so a future reader +//! (e.g. a CLI inspecting the db file while local-api is running) doesn't block +//! on it. + +use std::fs::{self, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +/// Native owns this schema independently from Studio's Postgres migrations. +/// The two stores intentionally share wire entities, not physical tables. +const CURRENT_SCHEMA_VERSION: u32 = 10; +const SQLITE_BUSY_TIMEOUT: Duration = Duration::from_secs(5); + +/// IDs in this namespace are owned exclusively by the native durable-turn +/// protocol. User-supplied message IDs are rejected before acceptance so an +/// unrelated message can never occupy a future assistant completion fence. +pub const NATIVE_ASSISTANT_MESSAGE_ID_PREFIX: &str = "native-assistant:"; +const NATIVE_ASSISTANT_MESSAGE_ID_V1_PREFIX: &str = "native-assistant:v1:"; + +struct Migration { + version: u32, + sql: &'static str, +} + +const MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + // This is the complete schema that shipped before migrations existed. + // `IF NOT EXISTS` lets a `user_version=0` installation adopt it without + // replacing or deleting any existing rows. + sql: r#" +CREATE TABLE IF NOT EXISTS threads ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + role TEXT NOT NULL, + parts TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS runs ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + harness_id TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + ended_at TEXT, + error TEXT +); +CREATE INDEX IF NOT EXISTS idx_messages_thread_created ON messages(thread_id, created_at); +CREATE INDEX IF NOT EXISTS idx_runs_thread_created ON runs(thread_id, created_at); + +-- `rt_*` ("real-UI threads") — a SEPARATE table pair from `threads`/ +-- `messages`/`runs` above. Those tables back the mini-app's now-dead +-- `/threads*` HTTP surface AND the daemon-parity `/_sandbox/dispatch` +-- family (`routes/dispatch.rs`) — byte-parity-gated, never touched by this +-- change. `rt_threads`/`rt_messages` back `routes/intercept/*`'s emulation +-- of the REAL production shell's wire contract instead: `ThreadEntity` +-- (`packages/shared/src/thread/schema.ts::ThreadEntitySchema`) and +-- `ThreadMessageEntity`, per the native interception contract +-- §3.1. Same db file, same connection/lock, independent tables — keeps the +-- daemon-parity-critical old schema completely unmodified while the new +-- interception layer gets the richer shape the real UI's tools expect. +CREATE TABLE IF NOT EXISTS rt_threads ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + hidden INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + created_by TEXT NOT NULL, + updated_by TEXT NOT NULL, + virtual_mcp_id TEXT NOT NULL, + trigger_id TEXT, + branch TEXT, + sandbox_provider_kind TEXT, + harness_id TEXT, + metadata TEXT, + run_config TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS rt_messages ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES rt_threads(id) ON DELETE CASCADE, + role TEXT NOT NULL, + parts TEXT NOT NULL, + metadata TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_rt_threads_org_updated ON rt_threads(organization_id, updated_at); +CREATE INDEX IF NOT EXISTS idx_rt_messages_thread_created ON rt_messages(thread_id, created_at); +"#, + }, + Migration { + version: 2, + // SQLite cannot add a genuinely NOT NULL column to a populated table + // without a synthetic default. Rebuilding inside the migration + // transaction gives every legacy row its real per-thread insertion + // ordinal while preserving ids and payloads byte-for-byte. + sql: r#" +DROP INDEX IF EXISTS idx_rt_messages_thread_created; +ALTER TABLE rt_messages RENAME TO rt_messages_v1; +CREATE TABLE rt_messages ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES rt_threads(id) ON DELETE CASCADE, + role TEXT NOT NULL, + parts TEXT NOT NULL, + metadata TEXT, + seq INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +INSERT INTO rt_messages ( + id, thread_id, role, parts, metadata, seq, created_at, updated_at +) +SELECT + id, + thread_id, + role, + parts, + metadata, + ROW_NUMBER() OVER (PARTITION BY thread_id ORDER BY rowid), + created_at, + updated_at +FROM rt_messages_v1; +DROP TABLE rt_messages_v1; +CREATE INDEX idx_rt_messages_thread_created ON rt_messages(thread_id, created_at); +CREATE UNIQUE INDEX idx_rt_messages_thread_seq ON rt_messages(thread_id, seq); +"#, + }, + Migration { + version: 3, + // A queue row carries the generation of the thread incarnation that + // accepted it. Deleting a thread cascades its pending work, and every + // old worker write is fenced from the moment deletion starts. Schema + // v5 additionally retires the deleted public id permanently. + // + // `ALTER TABLE ... ADD COLUMN` requires a constant default for an + // existing table. Every legacy row is assigned a random generation in + // this transaction, and all application inserts explicitly supply a + // fresh UUID; the empty default is only an SQLite migration bridge. + sql: r#" +ALTER TABLE rt_threads ADD COLUMN generation TEXT NOT NULL DEFAULT ''; +UPDATE rt_threads +SET generation = lower(hex(randomblob(16))) +WHERE generation = ''; +CREATE UNIQUE INDEX idx_rt_threads_org_id_generation +ON rt_threads(organization_id, id, generation); +CREATE TRIGGER rt_threads_generation_required_insert +BEFORE INSERT ON rt_threads +WHEN NEW.generation = '' +BEGIN + SELECT RAISE(ABORT, 'rt_threads.generation is required'); +END; +CREATE TRIGGER rt_threads_generation_immutable +BEFORE UPDATE OF generation ON rt_threads +WHEN NEW.generation <> OLD.generation +BEGIN + SELECT RAISE(ABORT, 'rt_threads.generation is immutable'); +END; + +CREATE TABLE rt_turn_queue ( + organization_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + thread_generation TEXT NOT NULL, + message_id TEXT NOT NULL CHECK (message_id <> ''), + workflow_id TEXT NOT NULL CHECK (workflow_id <> ''), + task_id TEXT NOT NULL CHECK (task_id <> ''), + normalized_input_json TEXT NOT NULL, + user_message_json TEXT NOT NULL, + enqueued_at INTEGER NOT NULL CHECK (enqueued_at >= 0), + fifo_ordinal INTEGER NOT NULL CHECK (fifo_ordinal > 0), + state TEXT NOT NULL CHECK (state IN ('queued', 'running', 'cancel_requested')), + claim_token TEXT, + CHECK ( + (state = 'queued' AND claim_token IS NULL) OR + (state IN ('running', 'cancel_requested') AND claim_token IS NOT NULL) + ), + PRIMARY KEY (organization_id, thread_id, thread_generation, workflow_id), + UNIQUE (organization_id, thread_id, thread_generation, message_id), + UNIQUE (organization_id, thread_id, thread_generation, fifo_ordinal), + FOREIGN KEY (organization_id, thread_id, thread_generation) + REFERENCES rt_threads(organization_id, id, generation) + ON DELETE CASCADE +); +CREATE INDEX idx_rt_turn_queue_fifo +ON rt_turn_queue(organization_id, thread_id, thread_generation, fifo_ordinal); +CREATE INDEX idx_rt_turn_queue_recovery +ON rt_turn_queue(state, organization_id, thread_id, thread_generation, fifo_ordinal); +"#, + }, + Migration { + version: 4, + // Local threads used to be scoped only by the organization slug. The + // same slug can exist on dev/staging/prod and a different user can sign + // into the same macOS account, so that boundary was insufficient. + // Empty is retained only for pre-v4 rows until a matching authenticated + // user claims them through `adopt_legacy_account_rows`. + sql: r#" +ALTER TABLE rt_threads ADD COLUMN account_scope TEXT NOT NULL DEFAULT ''; +ALTER TABLE rt_turn_queue ADD COLUMN account_scope TEXT NOT NULL DEFAULT ''; +CREATE INDEX idx_rt_threads_account_org_updated +ON rt_threads(account_scope, organization_id, updated_at); +CREATE INDEX idx_rt_turn_queue_account_recovery +ON rt_turn_queue(account_scope, state, organization_id, thread_id, thread_generation, fifo_ordinal); +CREATE TRIGGER rt_threads_account_scope_required_insert +BEFORE INSERT ON rt_threads +WHEN NEW.account_scope = '' +BEGIN + SELECT RAISE(ABORT, 'rt_threads.account_scope is required'); +END; +CREATE TRIGGER rt_turn_queue_account_scope_required_insert +BEFORE INSERT ON rt_turn_queue +WHEN NEW.account_scope = '' +BEGIN + SELECT RAISE(ABORT, 'rt_turn_queue.account_scope is required'); +END; +"#, + }, + Migration { + version: 5, + // A deleted public thread id is retired permanently inside the account + // + organization that owned it. Without this durable ABA guard, a + // request created before DELETE but delivered afterwards is + // indistinguishable from a legitimate request for a newly-created + // thread with the same public id. The tombstone intentionally carries + // no transcript or user content and has no foreign key: it must outlive + // the thread row it protects. + sql: r#" +CREATE TABLE rt_thread_tombstones ( + account_scope TEXT NOT NULL CHECK (account_scope <> ''), + organization_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + deleted_generation TEXT NOT NULL CHECK (deleted_generation <> ''), + deleted_at TEXT NOT NULL, + PRIMARY KEY (account_scope, organization_id, thread_id) +); +"#, + }, + Migration { + version: 6, + // The first native thread store shipped without a migration runner or + // an account boundary. Such a binary cannot understand `user_version` + // and would otherwise reopen this database with organization-only + // queries after a downgrade, exposing another upstream/user's rows. + // Move every current real-UI table behind names that binary has never + // seen, then leave empty old-shaped barrier tables under the legacy + // names. The old binary reads zero rows and its first attempted insert + // fails loudly instead of creating data that the current app ignores. + // + // SQLite 3.26+ rewrites child foreign-key targets on a parent-table + // rename. This workspace bundles SQLite 3.45 through rusqlite; the + // migration tests additionally pin the rewritten FK targets and run + // `foreign_key_check` before the version transaction commits. + sql: r#" +ALTER TABLE rt_threads RENAME TO native_scoped_threads; +ALTER TABLE rt_messages RENAME TO native_scoped_messages; +ALTER TABLE rt_turn_queue RENAME TO native_scoped_turn_queue; +ALTER TABLE rt_thread_tombstones RENAME TO native_scoped_thread_tombstones; + +CREATE INDEX idx_native_scoped_threads_org_updated +ON native_scoped_threads(organization_id, updated_at); +CREATE INDEX idx_native_scoped_messages_thread_created +ON native_scoped_messages(thread_id, created_at); +CREATE UNIQUE INDEX idx_native_scoped_messages_thread_seq +ON native_scoped_messages(thread_id, seq); +CREATE UNIQUE INDEX idx_native_scoped_threads_org_id_generation +ON native_scoped_threads(organization_id, id, generation); +CREATE INDEX idx_native_scoped_turn_queue_fifo +ON native_scoped_turn_queue(organization_id, thread_id, thread_generation, fifo_ordinal); +CREATE INDEX idx_native_scoped_turn_queue_recovery +ON native_scoped_turn_queue(state, organization_id, thread_id, thread_generation, fifo_ordinal); +CREATE INDEX idx_native_scoped_threads_account_org_updated +ON native_scoped_threads(account_scope, organization_id, updated_at); +CREATE INDEX idx_native_scoped_turn_queue_account_recovery +ON native_scoped_turn_queue( + account_scope, state, organization_id, thread_id, thread_generation, fifo_ordinal +); + +CREATE TRIGGER native_scoped_threads_generation_required_insert +BEFORE INSERT ON native_scoped_threads +WHEN NEW.generation = '' +BEGIN + SELECT RAISE(ABORT, 'native_scoped_threads.generation is required'); +END; +CREATE TRIGGER native_scoped_threads_generation_immutable +BEFORE UPDATE OF generation ON native_scoped_threads +WHEN NEW.generation <> OLD.generation +BEGIN + SELECT RAISE(ABORT, 'native_scoped_threads.generation is immutable'); +END; +CREATE TRIGGER native_scoped_threads_account_scope_required_insert +BEFORE INSERT ON native_scoped_threads +WHEN NEW.account_scope = '' +BEGIN + SELECT RAISE(ABORT, 'native_scoped_threads.account_scope is required'); +END; +CREATE TRIGGER native_scoped_turn_queue_account_scope_required_insert +BEFORE INSERT ON native_scoped_turn_queue +WHEN NEW.account_scope = '' +BEGIN + SELECT RAISE(ABORT, 'native_scoped_turn_queue.account_scope is required'); +END; + +-- Historical v3 builds shipped the tables and indexes but not the generation +-- triggers present in today's reconstructed migration SQL. Teardown must +-- tolerate every object that was optional across shipped versions; the new +-- native-scoped indexes/triggers above are the post-migration source of truth. +DROP TRIGGER IF EXISTS rt_threads_generation_required_insert; +DROP TRIGGER IF EXISTS rt_threads_generation_immutable; +DROP TRIGGER IF EXISTS rt_threads_account_scope_required_insert; +DROP TRIGGER IF EXISTS rt_turn_queue_account_scope_required_insert; +DROP INDEX IF EXISTS idx_rt_threads_org_updated; +DROP INDEX IF EXISTS idx_rt_messages_thread_created; +DROP INDEX IF EXISTS idx_rt_messages_thread_seq; +DROP INDEX IF EXISTS idx_rt_threads_org_id_generation; +DROP INDEX IF EXISTS idx_rt_turn_queue_fifo; +DROP INDEX IF EXISTS idx_rt_turn_queue_recovery; +DROP INDEX IF EXISTS idx_rt_threads_account_org_updated; +DROP INDEX IF EXISTS idx_rt_turn_queue_account_recovery; + +-- Exact pre-runner shapes. Keeping these as tables (rather than views) lets +-- the old `CREATE TABLE/INDEX IF NOT EXISTS` batch finish normally, while the +-- triggers below make every possible row mutation fail closed. +CREATE TABLE rt_threads ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + hidden INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + created_by TEXT NOT NULL, + updated_by TEXT NOT NULL, + virtual_mcp_id TEXT NOT NULL, + trigger_id TEXT, + branch TEXT, + sandbox_provider_kind TEXT, + harness_id TEXT, + metadata TEXT, + run_config TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE rt_messages ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES rt_threads(id) ON DELETE CASCADE, + role TEXT NOT NULL, + parts TEXT NOT NULL, + metadata TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX idx_rt_threads_org_updated ON rt_threads(organization_id, updated_at); +CREATE INDEX idx_rt_messages_thread_created ON rt_messages(thread_id, created_at); + +CREATE TRIGGER rt_threads_downgrade_block_insert +BEFORE INSERT ON rt_threads +BEGIN + SELECT RAISE(ABORT, 'native thread database requires a newer app version'); +END; +CREATE TRIGGER rt_threads_downgrade_block_update +BEFORE UPDATE ON rt_threads +BEGIN + SELECT RAISE(ABORT, 'native thread database requires a newer app version'); +END; +CREATE TRIGGER rt_threads_downgrade_block_delete +BEFORE DELETE ON rt_threads +BEGIN + SELECT RAISE(ABORT, 'native thread database requires a newer app version'); +END; +CREATE TRIGGER rt_messages_downgrade_block_insert +BEFORE INSERT ON rt_messages +BEGIN + SELECT RAISE(ABORT, 'native thread database requires a newer app version'); +END; +CREATE TRIGGER rt_messages_downgrade_block_update +BEFORE UPDATE ON rt_messages +BEGIN + SELECT RAISE(ABORT, 'native thread database requires a newer app version'); +END; +CREATE TRIGGER rt_messages_downgrade_block_delete +BEFORE DELETE ON rt_messages +BEGIN + SELECT RAISE(ABORT, 'native thread database requires a newer app version'); +END; +"#, + }, + Migration { + version: 7, + // `updated_by` has always been physically NOT NULL because the first + // native schema copied `created_by` into it on INSERT. Production's + // public entity, however, omits `updated_by` until an explicit + // COLLECTION_THREADS_UPDATE occurs. Keep the historical value in + // place (other native code may still use it internally) and track only + // whether it is public with an additive flag. This avoids rebuilding + // the v6 physical tables and leaves every downgrade barrier untouched. + sql: r#" +ALTER TABLE native_scoped_threads +ADD COLUMN updated_by_explicit INTEGER NOT NULL DEFAULT 0 +CHECK (updated_by_explicit IN (0, 1)); + +-- A differing historical actor proves an explicit update occurred. Equal +-- actors are inherently ambiguous (the creator may also have updated it), so +-- legacy rows conservatively retain the create-only wire shape. +UPDATE native_scoped_threads +SET updated_by_explicit = 1 +WHERE updated_by <> created_by; +"#, + }, + Migration { + version: 8, + // Deletion is a durable lifecycle, not merely a process-local latch: + // after a timeout or final-DELETE failure, queued work remains intact + // but may not be claimed until the caller retries the delete. Queue + // rows also reserve the deterministic assistant id before HTTP 202. + // Pre-v8 rows are backfilled with their exact legacy assistant id; + // nullable storage remains only as a defensive corruption/recovery + // boundary for databases produced by transitional builds. + sql: r#" +ALTER TABLE native_scoped_threads +ADD COLUMN delete_pending INTEGER NOT NULL DEFAULT 0 +CHECK (delete_pending IN (0, 1)); + +ALTER TABLE native_scoped_turn_queue +ADD COLUMN assistant_message_id TEXT; + +-- Preserve the exact completion identity of every accepted pre-v8 row. A +-- global unique index is deliberately not added: older schemas permitted the +-- same client message id in different scopes, and refusing to open such a +-- database would strand both queues. Claim-time arbitration lets the oldest +-- reservation finish and quarantines every loser before harness execution. +UPDATE native_scoped_turn_queue +SET assistant_message_id = 'msg-' || message_id || '-assistant'; + +-- Legacy ids may collide and are arbitrated at claim. The namespace did not +-- exist before v8, so new reservations can additionally be protected against +-- future alternate writers at the SQLite layer without bricking old queues. +CREATE UNIQUE INDEX idx_native_scoped_turn_queue_v1_assistant_message_id +ON native_scoped_turn_queue(assistant_message_id) +WHERE assistant_message_id LIKE 'native-assistant:v1:%'; +"#, + }, + Migration { + version: 9, + // Claim-time validation must survive a process crash. In particular, + // an accepted pre-v8 row can lose global message-id arbitration even + // when its JSON is otherwise valid. Persisting the quarantine reason + // makes recovery close that row without ever returning it to a harness + // or manufacturing an assistant-only transcript. + sql: r#" +ALTER TABLE native_scoped_turn_queue +ADD COLUMN quarantine_reason TEXT; + +ALTER TABLE native_scoped_turn_queue +ADD COLUMN quarantine_preserve_user INTEGER NOT NULL DEFAULT 0 +CHECK (quarantine_preserve_user IN (0, 1)); +"#, + }, + Migration { + version: 10, + // A CLI can reveal its durable conversation id long before its turn + // completes. Keep that identity on the already-claimed queue row so a + // process crash can retire the interrupted turn without discarding the + // only pointer to the CLI-owned history. The row's existing account, + // organization, thread-generation, workflow, and claim-token identity + // is the write fence; terminal finalization deletes the checkpoint + // together with the queue row after copying it into the assistant. + sql: r#" +ALTER TABLE native_scoped_turn_queue +ADD COLUMN checkpoint_harness_id TEXT +CHECK (checkpoint_harness_id IS NULL OR trim(checkpoint_harness_id) <> ''); + +ALTER TABLE native_scoped_turn_queue +ADD COLUMN checkpoint_session_id TEXT +CHECK (checkpoint_session_id IS NULL OR trim(checkpoint_session_id) <> ''); + +CREATE TRIGGER native_scoped_turn_queue_checkpoint_pair_insert +BEFORE INSERT ON native_scoped_turn_queue +WHEN (NEW.checkpoint_harness_id IS NULL) <> (NEW.checkpoint_session_id IS NULL) +BEGIN + SELECT RAISE(ABORT, 'native_scoped_turn_queue checkpoint must be a complete pair'); +END; + +CREATE TRIGGER native_scoped_turn_queue_checkpoint_pair_update +BEFORE UPDATE OF checkpoint_harness_id, checkpoint_session_id +ON native_scoped_turn_queue +WHEN (NEW.checkpoint_harness_id IS NULL) <> (NEW.checkpoint_session_id IS NULL) +BEGIN + SELECT RAISE(ABORT, 'native_scoped_turn_queue checkpoint must be a complete pair'); +END; +"#, + }, +]; + +#[derive(Debug)] +pub enum DbError { + Sqlite(rusqlite::Error), + Json(serde_json::Error), + Io(std::io::Error), + NewerSchemaVersion { + found: u32, + supported: u32, + }, + SchemaIntegrity(String), + IdempotencyConflict { + entity: &'static str, + id: String, + }, + RetiredThreadId { + account_scope: String, + organization_id: String, + thread_id: String, + }, + StaleThreadGeneration { + organization_id: String, + thread_id: String, + generation: String, + }, + ThreadDeletePending { + organization_id: String, + thread_id: String, + }, + InvalidQueueData(String), +} + +impl std::fmt::Display for DbError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DbError::Sqlite(e) => write!(f, "sqlite error: {e}"), + DbError::Json(e) => write!(f, "json error: {e}"), + DbError::Io(e) => write!(f, "io error: {e}"), + DbError::NewerSchemaVersion { found, supported } => write!( + f, + "local thread database schema version {found} is newer than this app supports ({supported}); update the app before opening it" + ), + DbError::SchemaIntegrity(message) => { + write!(f, "local thread database schema integrity error: {message}") + } + DbError::IdempotencyConflict { entity, id } => write!( + f, + "idempotency conflict: {entity} id {id} already exists with different content" + ), + DbError::RetiredThreadId { + account_scope, + organization_id, + thread_id, + } => write!( + f, + "thread id is retired in account scope {account_scope}: {organization_id}/{thread_id}" + ), + DbError::StaleThreadGeneration { + organization_id, + thread_id, + generation, + } => write!( + f, + "thread generation fence no longer matches: {organization_id}/{thread_id}@{generation}" + ), + DbError::ThreadDeletePending { + organization_id, + thread_id, + } => write!( + f, + "thread deletion is pending: {organization_id}/{thread_id}" + ), + DbError::InvalidQueueData(message) => { + write!(f, "invalid durable turn queue data: {message}") + } + } + } +} + +impl std::error::Error for DbError {} + +impl From for DbError { + fn from(e: rusqlite::Error) -> Self { + DbError::Sqlite(e) + } +} + +impl From for DbError { + fn from(e: serde_json::Error) -> Self { + DbError::Json(e) + } +} + +impl From for DbError { + fn from(e: std::io::Error) -> Self { + DbError::Io(e) + } +} + +pub type DbResult = Result; + +#[derive(Debug, Clone, Serialize)] +pub struct Thread { + pub id: String, + pub title: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Message { + pub id: String, + pub thread_id: String, + pub role: String, + pub parts: Value, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Run { + pub id: String, + pub thread_id: String, + pub harness_id: String, + pub status: String, + pub created_at: String, + pub ended_at: Option, + pub error: Option, +} + +// --- native_scoped_threads / native_scoped_messages — the REAL production shell's wire shape ---- +// +// See migration 1's schema comment for why these are a separate table +// pair from `Thread`/`Message`/`Run` above. Field names/shapes mirror +// `packages/shared/src/thread/schema.ts::ThreadEntitySchema` / +// `ThreadMessageEntitySchema` closely enough that `routes/intercept/ +// thread_tools.rs` can serialize a `RtThread`/`RtMessage` directly as the +// tool's raw (un-enveloped) JSON output — see +// the native interception contract §3.1. + +fn thread_metadata_is_absent(metadata: &Option) -> bool { + match metadata { + None => true, + Some(Value::Object(object)) => object.is_empty(), + Some(_) => false, + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct RtThread { + pub id: String, + pub organization_id: String, + pub title: String, + pub description: Option, + pub hidden: bool, + pub status: String, + pub created_by: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + pub virtual_mcp_id: String, + pub trigger_id: Option, + pub branch: Option, + pub sandbox_provider_kind: Option, + pub harness_id: Option, + #[serde(skip_serializing_if = "thread_metadata_is_absent")] + pub metadata: Option, + pub run_config: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct RtMessage { + pub id: String, + pub thread_id: String, + pub role: String, + pub parts: Value, + pub metadata: Option, + /// Durable, per-thread insert ordinal. A user turn's row is always + /// assigned a lower `seq` than the assistant reply inserted after it. + /// Surfaced on `COLLECTION_THREAD_MESSAGES_LIST` items so the chat + /// frontend can order by this stable SERVER ordinal instead of the + /// wall-clock `created_at` — which mixes the CLIENT clock (optimistic / + /// streamed rows) with the SERVER clock. Unlike SQLite's implicit + /// `rowid`, this survives table rebuilds and future storage migrations. + /// See `mergeAndSort` in + /// `apps/web/src/components/chat/store/thread-connection.ts`. + pub seq: i64, + pub created_at: String, + pub updated_at: String, +} + +/// Fields a caller may patch via `COLLECTION_THREADS_CREATE`'s `data` / +/// `COLLECTION_THREADS_UPDATE`'s `data` — every field optional so ONE +/// struct serves both "set on create" and "patch on update" (an absent +/// field on update leaves the column untouched; `Option>` fields +/// distinguish "not present in the patch" from "explicitly set to null"). +#[derive(Debug, Clone, Default)] +pub struct RtThreadPatch { + pub title: Option, + pub description: Option>, + pub hidden: Option, + pub status: Option, + pub metadata: Option>, + pub branch: Option>, + pub virtual_mcp_id: Option, +} + +/// Durable native account boundary. An organization slug is meaningful only +/// inside one upstream issuer and one authenticated subject; neither component +/// is exposed on the production-compatible thread wire entity. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RtAccountScope { + pub upstream_host: String, + pub user_id: String, +} + +impl RtAccountScope { + pub fn new(upstream_host: impl Into, user_id: impl Into) -> Option { + let upstream_host = upstream_host.into().trim().to_ascii_lowercase(); + let user_id = user_id.into(); + if upstream_host.is_empty() || user_id.is_empty() { + return None; + } + Some(Self { + upstream_host, + user_id, + }) + } + + /// Length-prefixing keeps the persisted key unambiguous even when a host or + /// subject contains punctuation. This is an internal SQLite key, not wire + /// data and not a credential. + pub fn storage_key(&self) -> String { + format!( + "v1:{}:{}{}", + self.upstream_host.len(), + self.upstream_host, + self.user_id + ) + } + + #[cfg(test)] + fn test_default() -> Self { + Self::new("test.invalid", "local-desktop-user").unwrap() + } +} + +/// Filters and pagination for a scoped native thread listing. Keeping these in +/// one typed value prevents the storage API from growing another positional +/// argument every time the production collection adds a filter. +#[derive(Debug, Clone, Copy)] +pub struct RtThreadListOptions<'a> { + pub created_by: Option<&'a str>, + pub hidden: Option, + pub search: Option<&'a str>, + pub trigger_ids: Option<&'a [String]>, + pub virtual_mcp_id: Option<&'a str>, + pub has_trigger: Option, + pub start_date: Option<&'a str>, + pub end_date: Option<&'a str>, + pub status: Option<&'a str>, + pub agent_id: Option<&'a str>, + pub limit: i64, + pub offset: i64, +} + +/// Durable identity for one live incarnation of a local thread. `generation` +/// makes every worker write prove it still targets the incarnation that +/// originally accepted the turn. Once deleted, the public id is permanently +/// retired in its account + organization scope by schema-v5 tombstones. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +pub struct RtThreadFence { + pub account_scope: String, + pub organization_id: String, + pub thread_id: String, + pub generation: String, +} + +pub fn is_native_assistant_message_id(id: &str) -> bool { + id.starts_with(NATIVE_ASSISTANT_MESSAGE_ID_PREFIX) +} + +/// Deterministic completion fence for one accepted native turn. Every scope +/// component is length-prefixed, so punctuation or embedded delimiters cannot +/// make two tuples collide. Including the thread generation prevents a stale +/// worker from sharing an assistant id with a later incarnation even if a +/// future storage policy ever permits public thread-id reuse. +pub fn native_assistant_message_id(fence: &RtThreadFence, user_message_id: &str) -> String { + let mut digest = Sha256::new(); + digest.update(b"decocms-native-assistant-message-id\0v1\0"); + for component in [ + fence.account_scope.as_str(), + fence.organization_id.as_str(), + fence.thread_id.as_str(), + fence.generation.as_str(), + user_message_id, + ] { + digest.update((component.len() as u64).to_be_bytes()); + digest.update(component.as_bytes()); + } + let digest = digest.finalize(); + let mut id = String::with_capacity(NATIVE_ASSISTANT_MESSAGE_ID_V1_PREFIX.len() + 64); + id.push_str(NATIVE_ASSISTANT_MESSAGE_ID_V1_PREFIX); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(id, "{byte:02x}"); + } + id +} + +pub fn legacy_native_assistant_message_id(user_message_id: &str) -> String { + format!("msg-{user_message_id}-assistant") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RtTurnQueueState { + Queued, + Running, + CancelRequested, +} + +impl RtTurnQueueState { + fn parse(value: &str) -> DbResult { + match value { + "queued" => Ok(Self::Queued), + "running" => Ok(Self::Running), + "cancel_requested" => Ok(Self::CancelRequested), + other => Err(DbError::InvalidQueueData(format!( + "unknown state {other:?}" + ))), + } + } +} + +/// Full durable form of one native decopilot turn. The input and user message +/// are retained as JSON so process restart recovery does not reconstruct a +/// lossy approximation of the original dispatch. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct RtTurnQueueItem { + pub fence: RtThreadFence, + pub message_id: String, + /// Reserved before acceptance for v8+ rows. `None` identifies a legacy + /// row whose completion must retain the historical derived assistant id. + pub assistant_message_id: Option, + pub workflow_id: String, + pub task_id: String, + /// Sanitized execution fields only (harness/tier/mode/approval, branch, + /// sandbox provider/config); never request headers or auth data. The full + /// current user message is retained separately below. + pub normalized_input: Value, + pub user_message: Value, + pub enqueued_at: u64, + pub fifo_ordinal: i64, + pub state: RtTurnQueueState, + /// Unique ownership token minted on each claim. A worker from an earlier + /// recovery pass cannot complete a row claimed again by another worker. + pub claim_token: Option, + /// Crash-durable pointer to the CLI-owned conversation observed by this + /// exact claim. It is intentionally never exposed by the queue HTTP + /// diagnostics: a session id is an internal continuation capability. + #[serde(skip_serializing)] + pub checkpoint_session: Option<(String, String)>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RtTurnEnqueueInput { + pub message_id: String, + pub workflow_id: String, + pub task_id: String, + pub normalized_input: Value, + pub user_message: Value, + pub enqueued_at: u64, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RtTurnEnqueueOutcome { + Inserted(RtTurnQueueItem), + Existing(RtTurnQueueItem), + /// The canonical user and its deterministic assistant already form a + /// complete persisted turn. HTTP retries return the original `202` + /// contract without creating another queue row or launching a harness. + Completed, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RtTurnCancelOutcome { + QueuedDeleted(RtTurnQueueItem), + ActiveCancelRequested(RtTurnQueueItem), + NotFound, +} + +/// The only thread states a claimed turn may commit when it relinquishes its +/// queue slot. `RequiresAction` is a durable pause; the other two end the +/// interaction. Keeping this typed prevents an accepted queue row from being +/// deleted while the parent thread is accidentally left `in_progress`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RtTurnTerminalStatus { + Completed, + RequiresAction, + Failed, +} + +impl RtTurnTerminalStatus { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Completed => "completed", + Self::RequiresAction => "requires_action", + Self::Failed => "failed", + } + } +} + +/// Result of the atomic terminal commit. `Stale` means the queue row is no +/// longer an active claim owned by the supplied generation + claim token; no +/// assistant/status/queue mutation was committed in that case. +#[derive(Debug, Clone, PartialEq)] +pub enum RtTurnTerminalOutcome { + Completed(RtMessage), + /// A corrupt/legacy-colliding accepted row was closed without inserting a + /// misleading assistant message under an identity owned by another turn. + Quarantined, + Stale, +} + +type RtMalformedTerminalRow = ( + String, + String, + Option, + bool, + Option, + Option, +); +type RtClaimedTerminalRow = ( + String, + String, + Option, + Option, + Option, +); + +/// Result of atomically persisting a claimed turn's queued user message and +/// transitioning its thread to `in_progress`. +#[derive(Debug, Clone, PartialEq)] +pub enum RtTurnBeginOutcome { + Begun(RtMessage), + CancelRequested, + Stale, +} + +/// Recovery view that preserves the claim identity of a row whose JSON body +/// cannot be decoded. Returning it beside healthy rows lets startup finalize +/// this one turn explicitly instead of failing the entire queue scan. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RtMalformedOrphanedTurn { + pub fence: RtThreadFence, + pub message_id: String, + pub assistant_message_id: Option, + pub workflow_id: String, + pub claim_token: Option, + /// A validated canonical accepted user that is safe to terminalize. This + /// is present only when the malformed field is unrelated to the user + /// payload and global user/assistant reservations remain unambiguous. + pub canonical_user: Option, + pub error: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RtOrphanedTurn { + Ready(RtTurnQueueItem), + Malformed(RtMalformedOrphanedTurn), +} + +/// Claim parses only after the row has been durably moved out of `queued`. +/// Malformed input is therefore a terminalizable head, never a poison pill +/// that rolls back into FIFO position and blocks every healthy tail. +#[derive(Debug, Clone, PartialEq)] +pub enum RtTurnClaimOutcome { + Ready(RtTurnQueueItem), + Malformed(RtMalformedOrphanedTurn), + /// A crash left both canonical messages durable but not the queued-row + /// cleanup. Claim atomically adopts that completed boundary and removes + /// the row without ever returning executable work to the harness layer. + Completed { + workflow_id: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RtCancelAllTurnsOutcome { + pub queued_retained_workflow_ids: Vec, + pub active_workflow_ids: Vec, +} + +/// Milliseconds-precision RFC3339 UTC timestamp (`2024-01-02T03:04:05.006Z`). +/// Hand-rolled (no `chrono`/`time` crate in the workspace's dependency +/// table — see `apps/native/Cargo.toml`, which is a shared file family +/// implementers don't edit) via the standard civil-from-days algorithm +/// (Howard Hinnant's `civil_from_days`, the same one libc++'s `` +/// uses). Millisecond precision (rather than seconds) keeps same-tick +/// creates orderable in `list_threads`'s `ORDER BY updated_at DESC` without +/// a secondary sort key doing all the work. +pub(crate) fn now_rfc3339() -> String { + format_rfc3339( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO), + ) +} + +pub(crate) fn format_rfc3339(d: Duration) -> String { + let secs = d.as_secs() as i64; + let millis = d.subsec_millis(); + let days = secs.div_euclid(86_400); + let secs_of_day = secs.rem_euclid(86_400); + let (y, m, day) = civil_from_days(days); + let hh = secs_of_day / 3600; + let mm = (secs_of_day % 3600) / 60; + let ss = secs_of_day % 60; + format!("{y:04}-{m:02}-{day:02}T{hh:02}:{mm:02}:{ss:02}.{millis:03}Z") +} + +/// Howard Hinnant's `civil_from_days`: days-since-epoch (1970-01-01) -> +/// (year, month, day). Proleptic Gregorian, valid for the entire range a +/// `SystemTime` can represent. +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +fn migrate(conn: &mut Connection) -> DbResult<()> { + // Acquire the writer lock before reading the version. Two racing first + // requests may both open a connection; the loser must observe the winner's + // committed version rather than replaying the same migration from a stale + // pre-lock read. + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let found: u32 = tx.pragma_query_value(None, "user_version", |row| row.get(0))?; + if found > CURRENT_SCHEMA_VERSION { + return Err(DbError::NewerSchemaVersion { + found, + supported: CURRENT_SCHEMA_VERSION, + }); + } + if found == CURRENT_SCHEMA_VERSION { + validate_foreign_keys(&tx)?; + tx.commit()?; + return Ok(()); + } + + // All pending versions share the same IMMEDIATE transaction: either the + // full forward upgrade and its final `user_version` land, or the database + // is byte-for-byte on the old schema. + let mut expected = found + 1; + for migration in MIGRATIONS.iter().filter(|m| m.version > found) { + assert_eq!( + migration.version, expected, + "native SQLite migrations must be contiguous and ordered" + ); + tx.execute_batch(migration.sql)?; + tx.pragma_update(None, "user_version", migration.version)?; + expected += 1; + } + assert_eq!( + expected, + CURRENT_SCHEMA_VERSION + 1, + "CURRENT_SCHEMA_VERSION must match the last native SQLite migration" + ); + validate_foreign_keys(&tx)?; + tx.commit()?; + Ok(()) +} + +fn validate_foreign_keys(conn: &Connection) -> DbResult<()> { + let violation: Option<(String, Option, String, i64)> = conn + .query_row( + "SELECT `table`, rowid, parent, fkid FROM pragma_foreign_key_check LIMIT 1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .optional()?; + if let Some((table, rowid, parent, foreign_key_id)) = violation { + return Err(DbError::SchemaIntegrity(format!( + "foreign key {foreign_key_id} from {table} row {rowid:?} to {parent} is violated" + ))); + } + Ok(()) +} + +fn create_private_dir(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + + let mut builder = fs::DirBuilder::new(); + builder.recursive(true).mode(0o700).create(path)?; + } + #[cfg(not(unix))] + fs::create_dir_all(path)?; + Ok(()) +} + +fn create_private_file(path: &Path) -> std::io::Result<()> { + let mut options = OpenOptions::new(); + options.create(true).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + + options.mode(0o600); + } + drop(options.open(path)?); + set_owner_only_file(path) +} + +#[cfg(unix)] +fn set_owner_only_dir(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o700)) +} + +#[cfg(not(unix))] +fn set_owner_only_dir(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn set_owner_only_file(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +fn set_owner_only_file(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + +fn sqlite_sidecar_path(db_path: &Path, suffix: &str) -> PathBuf { + let mut path = db_path.as_os_str().to_os_string(); + path.push(suffix); + PathBuf::from(path) +} + +/// Tightens a pre-existing database and any WAL files SQLite has materialized. +/// SQLite creates future `-wal`/`-shm` files with the database's mode; setting +/// the main file before enabling WAL therefore also protects later sidecars. +fn secure_sqlite_files(db_path: &Path) -> std::io::Result<()> { + for path in [ + db_path.to_path_buf(), + sqlite_sidecar_path(db_path, "-wal"), + sqlite_sidecar_path(db_path, "-shm"), + ] { + match fs::metadata(&path) { + Ok(_) => set_owner_only_file(&path)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + Ok(()) +} + +pub struct ThreadsDb { + conn: Mutex, +} + +impl ThreadsDb { + /// Opens (creating if absent) `/.decocms/local.db`. `main.rs` + /// already `mkdir -p`s `.decocms/` at boot (see its module doc), but + /// this creates it too so `ThreadsDb::open` is self-sufficient for + /// tests/tools that construct one directly. + pub fn open(app_root: &Path) -> DbResult { + let dir = app_root.join(".decocms"); + create_private_dir(&dir)?; + // This database contains chat transcripts and must never inherit the + // usual 0755/0644 defaults from a developer's umask. Do not chmod + // `app_root` itself: the standalone local-api may be pointed at an + // existing project directory owned by the user. + set_owner_only_dir(&dir)?; + + let db_path = dir.join("local.db"); + create_private_file(&db_path)?; + secure_sqlite_files(&db_path)?; + let conn = Connection::open(&db_path)?; + let db = Self::init(conn)?; + secure_sqlite_files(&db_path)?; + Ok(db) + } + + #[cfg(test)] + pub fn open_in_memory() -> DbResult { + Self::init(Connection::open_in_memory()?) + } + + fn init(mut conn: Connection) -> DbResult { + // Two app processes cannot normally share this database because the + // native instance lock fences them, but two first-open callers can + // still race during tests or startup tooling. Let SQLite wait for the + // winning IMMEDIATE migration transaction instead of surfacing a + // transient SQLITE_BUSY before it can observe the committed version. + conn.busy_timeout(SQLITE_BUSY_TIMEOUT)?; + conn.pragma_update(None, "foreign_keys", 1)?; + migrate(&mut conn)?; + // SQLite silently keeps `:memory:` databases on "memory" mode even + // when WAL is requested (it can't be persisted anyway) — this call + // still succeeds (not an error) in that case, so `?` is safe for + // both the file-backed and in-memory (test) constructors. + conn.pragma_update(None, "journal_mode", "WAL")?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Connection> { + match self.conn.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + } + } + + #[cfg(test)] + pub(crate) fn execute_batch_for_test(&self, sql: &str) -> DbResult<()> { + self.lock().execute_batch(sql)?; + Ok(()) + } + + /// Claims only attributable pre-v4 rows for this exact signed-in user. The + /// IMMEDIATE transaction makes "first matching upstream host wins" atomic: + /// legacy storage has no issuer column, so it cannot safely be shown on two + /// hosts even when both happen to use the same subject string. Rows written + /// with the old placeholder creator are deliberately left unclaimed. + fn adopt_legacy_account_rows( + &self, + scope: &RtAccountScope, + organization_id: &str, + ) -> DbResult<()> { + if scope.user_id == "local-desktop-user" { + return Ok(()); + } + let account_scope = scope.storage_key(); + let mut conn = self.lock(); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute( + "UPDATE native_scoped_threads SET account_scope = ?1 \ + WHERE account_scope = '' AND organization_id = ?2 \ + AND created_by = ?3 AND created_by <> 'local-desktop-user'", + params![account_scope, organization_id, scope.user_id], + )?; + tx.execute( + "UPDATE native_scoped_turn_queue SET account_scope = ?1 \ + WHERE account_scope = '' AND EXISTS (\ + SELECT 1 FROM native_scoped_threads t \ + WHERE t.id = native_scoped_turn_queue.thread_id \ + AND t.organization_id = native_scoped_turn_queue.organization_id \ + AND t.generation = native_scoped_turn_queue.thread_generation \ + AND t.account_scope = ?1\ + )", + params![account_scope], + )?; + tx.commit()?; + Ok(()) + } + + /// Startup variant of legacy adoption. It runs only after the Keychain has + /// yielded an authenticated subject and claims every attributable org for + /// that account before queue recovery scans. Signed-out startup performs no + /// adoption or recovery. + pub fn prepare_account_scope(&self, scope: &RtAccountScope) -> DbResult<()> { + if scope.user_id == "local-desktop-user" { + return Ok(()); + } + let account_scope = scope.storage_key(); + let mut conn = self.lock(); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute( + "UPDATE native_scoped_threads SET account_scope = ?1 \ + WHERE account_scope = '' AND created_by = ?2 \ + AND created_by <> 'local-desktop-user'", + params![account_scope, scope.user_id], + )?; + tx.execute( + "UPDATE native_scoped_turn_queue SET account_scope = ?1 \ + WHERE account_scope = '' AND EXISTS (\ + SELECT 1 FROM native_scoped_threads t \ + WHERE t.id = native_scoped_turn_queue.thread_id \ + AND t.organization_id = native_scoped_turn_queue.organization_id \ + AND t.generation = native_scoped_turn_queue.thread_generation \ + AND t.account_scope = ?1\ + )", + params![account_scope], + )?; + tx.commit()?; + Ok(()) + } + + pub fn list_threads(&self) -> DbResult> { + let conn = self.lock(); + let mut stmt = conn.prepare( + "SELECT id, title, created_at, updated_at FROM threads \ + ORDER BY updated_at DESC, rowid DESC", + )?; + let rows = stmt.query_map([], |row| { + Ok(Thread { + id: row.get(0)?, + title: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + }) + })?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + pub fn create_thread(&self, title: String) -> DbResult { + let id = Uuid::new_v4().to_string(); + let ts = now_rfc3339(); + let conn = self.lock(); + conn.execute( + "INSERT INTO threads (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?3)", + params![id, title, ts], + )?; + Ok(Thread { + id, + title, + created_at: ts.clone(), + updated_at: ts, + }) + } + + /// Idempotent variant of [`Self::create_thread`] for a CALLER-CHOSEN + /// `id` — `INSERT OR IGNORE`, then read back whichever row is current. + /// Phase 2's dispatch route (`routes/dispatch.rs`) is the one caller: + /// `input.threadId` is caller-supplied (the desktop frontend mints + /// it, not this store), and dispatch has no separate "create the + /// thread first" step to depend on — the FIRST dispatch for a given + /// `threadId` implicitly creates it (empty title), and every + /// subsequent dispatch for the same id is a harmless no-op here. + pub fn create_thread_with_id(&self, id: &str, title: &str) -> DbResult { + let ts = now_rfc3339(); + let conn = self.lock(); + conn.execute( + "INSERT OR IGNORE INTO threads (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?3)", + params![id, title, ts], + )?; + conn.query_row( + "SELECT id, title, created_at, updated_at FROM threads WHERE id = ?1", + params![id], + |row| { + Ok(Thread { + id: row.get(0)?, + title: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + }) + }, + ) + .map_err(DbError::from) + } + + pub fn get_thread(&self, id: &str) -> DbResult> { + let conn = self.lock(); + conn.query_row( + "SELECT id, title, created_at, updated_at FROM threads WHERE id = ?1", + params![id], + |row| { + Ok(Thread { + id: row.get(0)?, + title: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + }) + }, + ) + .optional() + .map_err(DbError::from) + } + + pub fn thread_exists(&self, id: &str) -> DbResult { + let conn = self.lock(); + let hit: Option = conn + .query_row("SELECT 1 FROM threads WHERE id = ?1", params![id], |r| { + r.get(0) + }) + .optional()?; + Ok(hit.is_some()) + } + + /// `None` if `id` doesn't exist. Reads the row back under the SAME lock + /// acquisition as the write (never re-enters `self.lock()` — the + /// underlying `std::sync::Mutex` isn't reentrant) so a concurrent + /// delete can't race between the `UPDATE` and the follow-up `SELECT`. + pub fn update_thread_title(&self, id: &str, title: &str) -> DbResult> { + let ts = now_rfc3339(); + let conn = self.lock(); + let changed = conn.execute( + "UPDATE threads SET title = ?1, updated_at = ?2 WHERE id = ?3", + params![title, ts, id], + )?; + if changed == 0 { + return Ok(None); + } + conn.query_row( + "SELECT id, title, created_at, updated_at FROM threads WHERE id = ?1", + params![id], + |row| { + Ok(Thread { + id: row.get(0)?, + title: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + }) + }, + ) + .optional() + .map_err(DbError::from) + } + + /// Idempotent by design of a bare `DELETE ... WHERE id = ?1` — zero rows + /// affected on an already-gone id is not an error. Cascades to + /// `messages`/`runs` via the schema's `ON DELETE CASCADE` (requires the + /// `foreign_keys` pragma, set in `init`). + pub fn delete_thread(&self, id: &str) -> DbResult<()> { + let conn = self.lock(); + conn.execute("DELETE FROM threads WHERE id = ?1", params![id])?; + Ok(()) + } + + pub fn list_messages(&self, thread_id: &str) -> DbResult> { + let conn = self.lock(); + let mut stmt = conn.prepare( + "SELECT id, thread_id, role, parts, created_at FROM messages \ + WHERE thread_id = ?1 ORDER BY created_at ASC, rowid ASC", + )?; + let rows = stmt.query_map(params![thread_id], |row| { + let parts_raw: String = row.get(3)?; + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + parts_raw, + row.get::<_, String>(4)?, + )) + })?; + let mut out = Vec::new(); + for r in rows { + let (id, thread_id, role, parts_raw, created_at) = r?; + out.push(Message { + id, + thread_id, + role, + parts: serde_json::from_str(&parts_raw)?, + created_at, + }); + } + Ok(out) + } + + /// Inserts the message AND bumps the parent thread's `updated_at` to the + /// SAME timestamp, atomically (a single SQL transaction) — so a reader + /// can never observe the new message without the thread's `updated_at` + /// reflecting it. + pub fn create_message(&self, thread_id: &str, role: &str, parts: &Value) -> DbResult { + let id = Uuid::new_v4().to_string(); + let ts = now_rfc3339(); + let parts_str = serde_json::to_string(parts)?; + let mut conn = self.lock(); + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO messages (id, thread_id, role, parts, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, thread_id, role, parts_str, ts], + )?; + tx.execute( + "UPDATE threads SET updated_at = ?1 WHERE id = ?2", + params![ts, thread_id], + )?; + tx.commit()?; + Ok(Message { + id, + thread_id: thread_id.to_string(), + role: role.to_string(), + parts: parts.clone(), + created_at: ts, + }) + } + + /// Idempotent variant of [`Self::create_message`] for a CALLER-CHOSEN + /// `id` (rather than a freshly generated UUID): `INSERT OR IGNORE`, + /// then read back whichever row is now current — the one THIS call + /// inserted, or an identical one a PRIOR call (with the same `id`) + /// already inserted. Only bumps the parent thread's `updated_at` when + /// this call is the one that actually performed the insert (a no-op + /// re-poll must not keep bumping a thread to the top of the + /// most-recently-updated list). + /// + /// Phase 2's dispatch route (`routes/dispatch.rs`) is the one caller: + /// it derives a deterministic id per run (`run--user` / + /// `run--assistant`) so a dispatch that's retried/re-polled + /// with the SAME `runId` can call this again without duplicating the + /// thread's message history — see that file's module doc. + pub fn create_message_with_id( + &self, + id: &str, + thread_id: &str, + role: &str, + parts: &Value, + ) -> DbResult { + let ts = now_rfc3339(); + let parts_str = serde_json::to_string(parts)?; + let mut conn = self.lock(); + let tx = conn.transaction()?; + let inserted = tx.execute( + "INSERT OR IGNORE INTO messages (id, thread_id, role, parts, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, thread_id, role, parts_str, ts], + )?; + if inserted > 0 { + tx.execute( + "UPDATE threads SET updated_at = ?1 WHERE id = ?2", + params![ts, thread_id], + )?; + } + let row = tx.query_row( + "SELECT id, thread_id, role, parts, created_at FROM messages WHERE id = ?1", + params![id], + |row| { + let parts_raw: String = row.get(3)?; + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + parts_raw, + row.get::<_, String>(4)?, + )) + }, + )?; + tx.commit()?; + let (id, thread_id, role, parts_raw, created_at) = row; + Ok(Message { + id, + thread_id, + role, + parts: serde_json::from_str(&parts_raw)?, + created_at, + }) + } + + /// Idempotent run creation: `INSERT OR IGNORE` keyed by `id` (dispatch's + /// `runId` — the contract's "`Run.id` MUST equal the dispatch route's + /// `runId`" coupling), then read back the current row regardless of + /// which call (this one, or an earlier re-poll of the same `runId`) + /// performed the insert. New rows always start `status:"running"` — + /// local-api's dispatch flow is synchronous (spawn → stream + /// immediately, no queue), so there's no observable `"pending"` + /// window worth persisting. + pub fn create_run(&self, id: &str, thread_id: &str, harness_id: &str) -> DbResult { + let ts = now_rfc3339(); + let conn = self.lock(); + conn.execute( + "INSERT OR IGNORE INTO runs (id, thread_id, harness_id, status, created_at) \ + VALUES (?1, ?2, ?3, 'running', ?4)", + params![id, thread_id, harness_id, ts], + )?; + conn.query_row( + "SELECT id, thread_id, harness_id, status, created_at, ended_at, error FROM runs WHERE id = ?1", + params![id], + |row| { + Ok(Run { + id: row.get(0)?, + thread_id: row.get(1)?, + harness_id: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + ended_at: row.get(5)?, + error: row.get(6)?, + }) + }, + ) + .map_err(DbError::from) + } + + /// Writes a TERMINAL status (`"completed"|"failed"|"cancelled"`) for + /// run `id` — idempotent AND one-way: the `WHERE status NOT IN + /// (...)` guard means a run already in a terminal status is left + /// completely untouched by a later call (byte-parity with the + /// contract's "once set, a run row is never mutated again"), so a + /// re-poll/retry that calls this twice for the same run is always + /// safe. `None` (no row matched — either `id` doesn't exist, or it + /// was already terminal) vs `Some` (this call performed the + /// transition) is NOT distinguished in the return value — callers + /// that need to know don't exist yet in this crate, and the + /// idempotency guarantee is symmetric either way. + pub fn set_run_terminal_status( + &self, + id: &str, + status: &str, + error: Option<&str>, + ) -> DbResult<()> { + let ts = now_rfc3339(); + let conn = self.lock(); + conn.execute( + "UPDATE runs SET status = ?1, ended_at = ?2, error = ?3 \ + WHERE id = ?4 AND status NOT IN ('completed', 'failed', 'cancelled')", + params![status, ts, error, id], + )?; + Ok(()) + } + + pub fn list_runs(&self, thread_id: &str) -> DbResult> { + let conn = self.lock(); + let mut stmt = conn.prepare( + "SELECT id, thread_id, harness_id, status, created_at, ended_at, error FROM runs \ + WHERE thread_id = ?1 ORDER BY created_at DESC, rowid DESC", + )?; + let rows = stmt.query_map(params![thread_id], |row| { + Ok(Run { + id: row.get(0)?, + thread_id: row.get(1)?, + harness_id: row.get(2)?, + status: row.get(3)?, + created_at: row.get(4)?, + ended_at: row.get(5)?, + error: row.get(6)?, + }) + })?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + // --- native_scoped_threads / native_scoped_messages --- + // See migration 1's schema comment. + + /// Idempotent-by-id creation (mirrors [`Self::create_thread_with_id`]): + /// `INSERT OR IGNORE`, then read back whichever row is current — the one + /// THIS call inserted, or a prior call with the SAME `id` (e.g. + /// `COLLECTION_THREADS_CREATE` retried, or the decopilot dispatch + /// route's own first-message-implicitly-creates-the-thread path finding + /// a row `COLLECTION_THREADS_CREATE` already made). `id` defaults to a + /// fresh UUID when the caller doesn't supply one (mirrors + /// `ThreadCreateData.id?`'s "auto-generated if not provided" contract — + /// `apps/api/src/tools/thread/create.ts`'s own doc comment). + #[allow(clippy::too_many_arguments)] + pub fn rt_create_thread_scoped( + &self, + scope: &RtAccountScope, + id: Option<&str>, + organization_id: &str, + title: &str, + description: Option<&str>, + virtual_mcp_id: &str, + branch: Option<&str>, + created_by: &str, + ) -> DbResult { + self.adopt_legacy_account_rows(scope, organization_id)?; + let account_scope = scope.storage_key(); + let id = id + .map(str::to_string) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + // Generated before the transaction: a true retry reads the original + // live row, while an id retired by DELETE is rejected rather than + // creating a second incarnation that could accept a delayed old POST. + let generation = Uuid::new_v4().to_string(); + let ts = now_rfc3339(); + let mut conn = self.lock(); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + + // Preserve the production tool's idempotent-create contract while the + // row is live. This check and the tombstone check below share the same + // writer transaction with INSERT, closing every delete/create ABA gap. + if let Some(thread) = rt_thread_by_id_in_scope(&tx, &account_scope, organization_id, &id)? { + let delete_pending: bool = tx.query_row( + "SELECT delete_pending FROM native_scoped_threads \ + WHERE account_scope = ?1 AND organization_id = ?2 AND id = ?3", + params![account_scope, organization_id, id], + |row| row.get(0), + )?; + if delete_pending { + return Err(DbError::ThreadDeletePending { + organization_id: organization_id.to_string(), + thread_id: id, + }); + } + tx.commit()?; + return Ok(thread); + } + if rt_thread_is_tombstoned(&tx, &account_scope, organization_id, &id)? { + return Err(DbError::RetiredThreadId { + account_scope, + organization_id: organization_id.to_string(), + thread_id: id, + }); + } + + let inserted = tx.execute( + "INSERT OR IGNORE INTO native_scoped_threads \ + (id, organization_id, title, description, hidden, status, created_by, updated_by, \ + updated_by_explicit, virtual_mcp_id, trigger_id, branch, sandbox_provider_kind, \ + harness_id, metadata, run_config, created_at, updated_at, generation, account_scope) \ + VALUES (?1, ?2, ?3, ?4, 0, 'completed', ?5, ?5, 0, ?6, NULL, ?7, NULL, NULL, NULL, NULL, ?8, ?8, ?9, ?10)", + params![ + id, + organization_id, + title, + description, + created_by, + virtual_mcp_id, + branch, + ts, + generation, + account_scope, + ], + )?; + if inserted == 0 { + // `native_scoped_threads.id` is globally unique in the current physical + // schema. A row in another account/org is intentionally reported + // only as an unavailable id, never disclosed to this caller. + return Err(DbError::IdempotencyConflict { + entity: "rt_thread", + id, + }); + } + let thread = rt_thread_by_id_in_scope(&tx, &account_scope, organization_id, &id)? + .ok_or_else(|| DbError::Sqlite(rusqlite::Error::QueryReturnedNoRows))?; + tx.commit()?; + Ok(thread) + } + + #[cfg(test)] + #[allow(clippy::too_many_arguments)] + pub fn rt_create_thread( + &self, + id: Option<&str>, + organization_id: &str, + title: &str, + description: Option<&str>, + virtual_mcp_id: &str, + branch: Option<&str>, + created_by: &str, + ) -> DbResult { + self.rt_create_thread_scoped( + &RtAccountScope::test_default(), + id, + organization_id, + title, + description, + virtual_mcp_id, + branch, + created_by, + ) + } + + #[cfg(test)] + pub fn rt_get_thread(&self, id: &str) -> DbResult> { + let conn = self.lock(); + rt_thread_by_id(&conn, id) + } + + /// Thread lookup for a caller that already holds an accepted turn's + /// fence. The fence carries the derived storage key rather than the + /// [`RtAccountScope`] it came from, so it cannot use + /// [`Self::rt_get_thread_in_scope`] — but it is the same scoped query, so + /// a fence for one account still cannot read another's thread. + pub fn rt_get_thread_for_fence(&self, fence: &RtThreadFence) -> DbResult> { + let conn = self.lock(); + rt_thread_by_id_in_scope( + &conn, + &fence.account_scope, + &fence.organization_id, + &fence.thread_id, + ) + } + + pub fn rt_get_thread_in_scope( + &self, + scope: &RtAccountScope, + organization_id: &str, + id: &str, + ) -> DbResult> { + self.adopt_legacy_account_rows(scope, organization_id)?; + let conn = self.lock(); + rt_thread_by_id_in_scope(&conn, &scope.storage_key(), organization_id, id) + } + + #[cfg(test)] + pub fn rt_get_thread_in_org( + &self, + organization_id: &str, + id: &str, + ) -> DbResult> { + self.rt_get_thread_in_scope(&RtAccountScope::test_default(), organization_id, id) + } + + pub fn rt_thread_fence_in_scope( + &self, + scope: &RtAccountScope, + organization_id: &str, + id: &str, + ) -> DbResult> { + self.adopt_legacy_account_rows(scope, organization_id)?; + let conn = self.lock(); + rt_thread_fence_by_id_in_scope(&conn, &scope.storage_key(), organization_id, id) + } + + /// Reads the complete production-compatible thread row only while the + /// durable generation fence still owns that public id. Status events use + /// this after commit so a live-cache upsert receives the same owner, agent, + /// branch, title, and timestamp fields as the hosted event factory. + pub fn rt_thread_fenced(&self, fence: &RtThreadFence) -> DbResult> { + let conn = self.lock(); + conn.query_row( + &format!( + "SELECT {RT_THREAD_COLUMNS} FROM native_scoped_threads \ + WHERE account_scope = ?1 AND organization_id = ?2 AND id = ?3 \ + AND generation = ?4" + ), + params![ + fence.account_scope, + fence.organization_id, + fence.thread_id, + fence.generation, + ], + row_to_rt_thread, + ) + .optional() + .map_err(DbError::from) + } + + /// Reads the harness pin only when the complete durable thread incarnation + /// still matches. The outer `Option` is the generation fence (missing means + /// stale/deleted); the inner `Option` is the first-turn lock (missing means + /// this incarnation has not selected a harness yet). + pub fn rt_harness_id_fenced(&self, fence: &RtThreadFence) -> DbResult>> { + let conn = self.lock(); + conn.query_row( + "SELECT harness_id FROM native_scoped_threads \ + WHERE account_scope = ?1 AND organization_id = ?2 AND id = ?3 \ + AND generation = ?4", + params![ + fence.account_scope, + fence.organization_id, + fence.thread_id, + fence.generation, + ], + |row| row.get(0), + ) + .optional() + .map_err(DbError::from) + } + + #[cfg(test)] + pub fn rt_thread_fence_in_org( + &self, + organization_id: &str, + id: &str, + ) -> DbResult> { + self.rt_thread_fence_in_scope(&RtAccountScope::test_default(), organization_id, id) + } + + /// Applies the production `COLLECTION_THREADS_LIST` filters. A non-empty + /// `trigger_ids` takes the storage method's intentionally-special branch: + /// only account/org, `hidden = false`, and `trigger_id IN (...)` apply; + /// every other supplied filter is ignored. Otherwise `virtual_mcp_id` + /// wins over the legacy `agent_id` fallback and the remaining predicates + /// compose normally. `search` is a case-insensitive literal substring + /// match on `title`. + /// Returns `(items, total_count)` so the caller can compute `hasMore` + /// (`offset + limit < total_count`) without a second round trip. + pub fn rt_list_threads_scoped( + &self, + scope: &RtAccountScope, + organization_id: &str, + options: RtThreadListOptions<'_>, + ) -> DbResult<(Vec, i64)> { + self.adopt_legacy_account_rows(scope, organization_id)?; + let conn = self.lock(); + let mut clauses = vec![ + "account_scope = ?1".to_string(), + "organization_id = ?2".to_string(), + ]; + let mut sql_params: Vec> = vec![ + Box::new(scope.storage_key()), + Box::new(organization_id.to_string()), + ]; + let trigger_ids = options.trigger_ids.filter(|ids| !ids.is_empty()); + if let Some(trigger_ids) = trigger_ids { + clauses.push("hidden = 0".to_string()); + let mut placeholders = Vec::with_capacity(trigger_ids.len()); + for trigger_id in trigger_ids { + placeholders.push(format!("?{}", sql_params.len() + 1)); + sql_params.push(Box::new(trigger_id.clone())); + } + clauses.push(format!("trigger_id IN ({})", placeholders.join(", "))); + } else { + if let Some(user_id) = options.created_by { + clauses.push(format!("created_by = ?{}", sql_params.len() + 1)); + sql_params.push(Box::new(user_id.to_string())); + } + if let Some(hidden) = options.hidden { + clauses.push(format!("hidden = ?{}", sql_params.len() + 1)); + sql_params.push(Box::new(hidden as i64)); + } + let virtual_mcp_id = options.virtual_mcp_id.or(options.agent_id); + if let Some(virtual_mcp_id) = virtual_mcp_id { + clauses.push(format!("virtual_mcp_id = ?{}", sql_params.len() + 1)); + sql_params.push(Box::new(virtual_mcp_id.to_string())); + } + if let Some(has_trigger) = options.has_trigger { + clauses.push(if has_trigger { + "trigger_id IS NOT NULL".to_string() + } else { + "trigger_id IS NULL".to_string() + }); + } + if let Some(start_date) = options.start_date { + clauses.push(format!( + "julianday(updated_at) >= julianday(?{})", + sql_params.len() + 1 + )); + sql_params.push(Box::new(start_date.to_string())); + } + if let Some(end_date) = options.end_date { + clauses.push(format!( + "julianday(updated_at) <= julianday(?{})", + sql_params.len() + 1 + )); + sql_params.push(Box::new(end_date.to_string())); + } + if let Some(s) = options.search.filter(|s| !s.is_empty()) { + clauses.push(format!("title LIKE ?{} ESCAPE '\\'", sql_params.len() + 1)); + sql_params.push(Box::new(format!("%{}%", like_escape(s)))); + } + if let Some(status) = options.status { + clauses.push(format!("status = ?{}", sql_params.len() + 1)); + sql_params.push(Box::new(status.to_string())); + } + } + let where_sql = clauses.join(" AND "); + + let count_sql = format!("SELECT COUNT(*) FROM native_scoped_threads WHERE {where_sql}"); + let total: i64 = conn.query_row( + &count_sql, + rusqlite::params_from_iter(sql_params.iter().map(|b| b.as_ref())), + |row| row.get(0), + )?; + + let list_sql = format!( + "SELECT {RT_THREAD_COLUMNS} FROM native_scoped_threads WHERE {where_sql} \ + ORDER BY updated_at DESC, rowid DESC LIMIT ?{} OFFSET ?{}", + sql_params.len() + 1, + sql_params.len() + 2, + ); + sql_params.push(Box::new(options.limit)); + sql_params.push(Box::new(options.offset)); + let mut stmt = conn.prepare(&list_sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(sql_params.iter().map(|b| b.as_ref())), + row_to_rt_thread, + )?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok((out, total)) + } + + /// Applies `patch`'s present fields, bumps `updated_at`, and returns the + /// resulting row — `None` if `id` doesn't exist. A patch with every + /// field `None` still bumps `updated_at` (matches + /// `COLLECTION_THREADS_UPDATE`'s "any call is a real update" semantics; + /// callers that want a true no-op simply don't call this). + pub fn rt_update_thread_in_scope( + &self, + scope: &RtAccountScope, + organization_id: &str, + id: &str, + updated_by: &str, + patch: &RtThreadPatch, + ) -> DbResult> { + self.adopt_legacy_account_rows(scope, organization_id)?; + let ts = now_rfc3339(); + let mut sets = vec![ + "updated_at = ?1".to_string(), + "updated_by = ?2".to_string(), + "updated_by_explicit = 1".to_string(), + ]; + let mut sql_params: Vec> = + vec![Box::new(ts), Box::new(updated_by.to_string())]; + if let Some(v) = &patch.title { + sets.push(format!("title = ?{}", sql_params.len() + 1)); + sql_params.push(Box::new(v.clone())); + } + if let Some(v) = &patch.description { + sets.push(format!("description = ?{}", sql_params.len() + 1)); + sql_params.push(Box::new(v.clone())); + } + if let Some(v) = patch.hidden { + sets.push(format!("hidden = ?{}", sql_params.len() + 1)); + sql_params.push(Box::new(v as i64)); + } + if let Some(v) = &patch.status { + sets.push(format!("status = ?{}", sql_params.len() + 1)); + sql_params.push(Box::new(v.clone())); + } + if let Some(v) = &patch.metadata { + sets.push(format!("metadata = ?{}", sql_params.len() + 1)); + sql_params.push(Box::new(v.as_ref().map(|v| v.to_string()))); + } + if let Some(v) = &patch.branch { + sets.push(format!("branch = ?{}", sql_params.len() + 1)); + sql_params.push(Box::new(v.clone())); + } + if let Some(v) = &patch.virtual_mcp_id { + sets.push(format!("virtual_mcp_id = ?{}", sql_params.len() + 1)); + sql_params.push(Box::new(v.clone())); + } + let id_placeholder = sql_params.len() + 1; + sql_params.push(Box::new(id.to_string())); + let org_placeholder = sql_params.len() + 1; + sql_params.push(Box::new(organization_id.to_string())); + let scope_placeholder = sql_params.len() + 1; + let account_scope = scope.storage_key(); + sql_params.push(Box::new(account_scope.clone())); + let sql = format!( + "UPDATE native_scoped_threads SET {} \ + WHERE id = ?{id_placeholder} AND organization_id = ?{org_placeholder} \ + AND account_scope = ?{scope_placeholder}", + sets.join(", "), + ); + let mut conn = self.lock(); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let delete_pending: Option = tx + .query_row( + "SELECT delete_pending FROM native_scoped_threads \ + WHERE id = ?1 AND organization_id = ?2 AND account_scope = ?3", + params![id, organization_id, account_scope], + |row| row.get(0), + ) + .optional()?; + let Some(delete_pending) = delete_pending else { + tx.commit()?; + return Ok(None); + }; + if delete_pending { + return Err(DbError::ThreadDeletePending { + organization_id: organization_id.to_string(), + thread_id: id.to_string(), + }); + } + let changed = tx.execute( + &sql, + rusqlite::params_from_iter(sql_params.iter().map(|b| b.as_ref())), + )?; + if changed == 0 { + return Err(DbError::InvalidQueueData(format!( + "thread {organization_id}/{id} disappeared during an exclusive update" + ))); + } + let thread = rt_thread_by_id_in_scope(&tx, &account_scope, organization_id, id)?; + tx.commit()?; + Ok(thread) + } + + #[cfg(test)] + pub fn rt_update_thread_in_org( + &self, + organization_id: &str, + id: &str, + updated_by: &str, + patch: &RtThreadPatch, + ) -> DbResult> { + self.rt_update_thread_in_scope( + &RtAccountScope::test_default(), + organization_id, + id, + updated_by, + patch, + ) + } + + /// Deletes only when the id belongs to `organization_id`. Returns whether + /// a row was removed; another tenant's known id is indistinguishable from + /// an unknown id. + #[cfg(test)] + pub fn rt_delete_thread_in_org(&self, organization_id: &str, id: &str) -> DbResult { + let Some(fence) = + self.rt_thread_fence_in_scope(&RtAccountScope::test_default(), organization_id, id)? + else { + return Ok(false); + }; + self.rt_delete_thread_in_org_if_generation(&fence) + } + + /// Generation-fenced delete for lifecycle shutdown. The durable tombstone, + /// thread deletion, and queue/message cascades commit atomically: after a + /// successful return, neither a delayed request nor a process restart can + /// recreate this public id inside the same account + organization scope. + pub fn rt_delete_thread_in_org_if_generation(&self, fence: &RtThreadFence) -> DbResult { + let mut conn = self.lock(); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let owns_generation: bool = tx.query_row( + "SELECT EXISTS(\ + SELECT 1 FROM native_scoped_threads \ + WHERE id = ?1 AND organization_id = ?2 AND generation = ?3 \ + AND account_scope = ?4\ + )", + params![ + fence.thread_id, + fence.organization_id, + fence.generation, + fence.account_scope, + ], + |row| row.get(0), + )?; + if !owns_generation { + tx.commit()?; + return Ok(false); + } + + tx.execute( + "INSERT INTO native_scoped_thread_tombstones \ + (account_scope, organization_id, thread_id, deleted_generation, deleted_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + fence.account_scope, + fence.organization_id, + fence.thread_id, + fence.generation, + now_rfc3339(), + ], + )?; + let deleted = tx.execute( + "DELETE FROM native_scoped_threads \ + WHERE id = ?1 AND organization_id = ?2 AND generation = ?3 \ + AND account_scope = ?4", + params![ + fence.thread_id, + fence.organization_id, + fence.generation, + fence.account_scope, + ], + )?; + debug_assert_eq!( + deleted, 1, + "IMMEDIATE transaction lost its owned thread row" + ); + tx.commit()?; + Ok(deleted == 1) + } + + /// Durably closes one live generation to new queue admission/claims before + /// process-local cancellation begins. The marker is intentionally never + /// cleared on failure: retrying DELETE is the only operation that may + /// resume this lifecycle, and the final thread cascade removes it. + pub fn rt_mark_thread_delete_pending(&self, fence: &RtThreadFence) -> DbResult { + let conn = self.lock(); + Ok(conn.execute( + "UPDATE native_scoped_threads SET delete_pending = 1 \ + WHERE account_scope = ?1 AND organization_id = ?2 AND id = ?3 \ + AND generation = ?4", + params![ + fence.account_scope, + fence.organization_id, + fence.thread_id, + fence.generation, + ], + )? == 1) + } + + #[cfg(test)] + pub fn rt_thread_delete_pending(&self, fence: &RtThreadFence) -> DbResult { + let conn = self.lock(); + conn.query_row( + "SELECT delete_pending FROM native_scoped_threads \ + WHERE account_scope = ?1 AND organization_id = ?2 AND id = ?3 \ + AND generation = ?4", + params![ + fence.account_scope, + fence.organization_id, + fence.thread_id, + fence.generation, + ], + |row| row.get(0), + ) + .optional() + .map(|value| value.unwrap_or(true)) + .map_err(DbError::from) + } + + /// First-message thread-lock pin (map §3.2 "Locked-thread pin values"): + /// sets `harness_id`/`sandbox_provider_kind`/`branch` ONLY when each is + /// currently `NULL` — a later dispatch on the same (now-locked) thread + /// leaves them untouched even if it names a different harness, mirroring + /// `decopilot/routes.ts::applyThreadLock`'s "pinned on first message, + /// immutable after" contract. + #[cfg(test)] + pub fn rt_pin_harness_if_unset_in_org( + &self, + organization_id: &str, + id: &str, + harness_id: &str, + sandbox_provider_kind: Option<&str>, + branch: Option<&str>, + ) -> DbResult<()> { + let conn = self.lock(); + conn.execute( + "UPDATE native_scoped_threads SET \ + harness_id = COALESCE(harness_id, ?1), \ + sandbox_provider_kind = COALESCE(sandbox_provider_kind, ?2), \ + branch = COALESCE(branch, ?3) \ + WHERE id = ?4 AND organization_id = ?5", + params![ + harness_id, + sandbox_provider_kind, + branch, + id, + organization_id + ], + )?; + Ok(()) + } + + pub fn rt_pin_harness_if_unset_fenced( + &self, + fence: &RtThreadFence, + harness_id: &str, + sandbox_provider_kind: Option<&str>, + branch: Option<&str>, + ) -> DbResult { + let conn = self.lock(); + Ok(conn.execute( + "UPDATE native_scoped_threads SET \ + harness_id = COALESCE(harness_id, ?1), \ + sandbox_provider_kind = COALESCE(sandbox_provider_kind, ?2), \ + branch = COALESCE(branch, ?3) \ + WHERE id = ?4 AND organization_id = ?5 AND generation = ?6 \ + AND account_scope = ?7", + params![ + harness_id, + sandbox_provider_kind, + branch, + fence.thread_id, + fence.organization_id, + fence.generation, + fence.account_scope, + ], + )? > 0) + } + + #[cfg(test)] + pub fn rt_list_messages( + &self, + thread_id: &str, + limit: i64, + offset: i64, + desc: bool, + ) -> DbResult<(Vec, i64)> { + self.rt_list_messages_inner(None, thread_id, limit, offset, desc) + } + + pub fn rt_list_messages_in_scope( + &self, + scope: &RtAccountScope, + organization_id: &str, + thread_id: &str, + limit: i64, + offset: i64, + desc: bool, + ) -> DbResult<(Vec, i64)> { + self.adopt_legacy_account_rows(scope, organization_id)?; + self.rt_list_messages_inner( + Some((&scope.storage_key(), organization_id)), + thread_id, + limit, + offset, + desc, + ) + } + + #[cfg(test)] + pub fn rt_list_messages_in_org( + &self, + organization_id: &str, + thread_id: &str, + limit: i64, + offset: i64, + desc: bool, + ) -> DbResult<(Vec, i64)> { + self.rt_list_messages_in_scope( + &RtAccountScope::test_default(), + organization_id, + thread_id, + limit, + offset, + desc, + ) + } + + fn rt_list_messages_inner( + &self, + account_org: Option<(&str, &str)>, + thread_id: &str, + limit: i64, + offset: i64, + desc: bool, + ) -> DbResult<(Vec, i64)> { + let conn = self.lock(); + let total: i64 = match account_org { + Some((account_scope, organization_id)) => conn.query_row( + "SELECT COUNT(*) FROM native_scoped_messages \ + WHERE thread_id = ?1 AND EXISTS (\ + SELECT 1 FROM native_scoped_threads \ + WHERE id = native_scoped_messages.thread_id AND organization_id = ?2 \ + AND account_scope = ?3\ + )", + params![thread_id, organization_id, account_scope], + |row| row.get(0), + )?, + None => conn.query_row( + "SELECT COUNT(*) FROM native_scoped_messages WHERE thread_id = ?1", + params![thread_id], + |row| row.get(0), + )?, + }; + // Honor the caller's requested direction so the chat's "latest page" + // request (orderBy created_at desc on the wire) returns the NEWEST + // `limit` rows, not the oldest. Local order is the durable per-thread + // insertion sequence; wall-clock timestamps are payload, not an + // ordering primitive. `order` is a fixed literal (never user text), so + // string-interpolating it is safe. + // Why this matters: a desc page that dropped the newest assistant turn + // out of the window let the deduped user row (server clock) sort after a + // same-turn assistant left on the client clock, so the assistant + // rendered above its own user message (and reloading a >limit thread + // showed the oldest page instead of the recent conversation). + let order = if desc { "DESC" } else { "ASC" }; + // `seq` is appended last so existing column indices in + // `row_to_rt_message` are unchanged. + let (sql, sql_params): (String, Vec>) = match account_org { + Some((account_scope, organization_id)) => ( + format!( + "SELECT id, thread_id, role, parts, metadata, created_at, updated_at, seq \ + FROM native_scoped_messages \ + WHERE thread_id = ?1 AND EXISTS (\ + SELECT 1 FROM native_scoped_threads \ + WHERE id = native_scoped_messages.thread_id AND organization_id = ?2 \ + AND account_scope = ?3\ + ) \ + ORDER BY seq {order} LIMIT ?4 OFFSET ?5" + ), + vec![ + Box::new(thread_id.to_string()), + Box::new(organization_id.to_string()), + Box::new(account_scope.to_string()), + Box::new(limit), + Box::new(offset), + ], + ), + None => ( + format!( + "SELECT id, thread_id, role, parts, metadata, created_at, updated_at, seq \ + FROM native_scoped_messages \ + WHERE thread_id = ?1 ORDER BY seq {order} LIMIT ?2 OFFSET ?3" + ), + vec![ + Box::new(thread_id.to_string()), + Box::new(limit), + Box::new(offset), + ], + ), + }; + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(sql_params.iter().map(|value| value.as_ref())), + row_to_rt_message, + )?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok((out, total)) + } + + /// Reads a deterministic message id before dispatch starts side effects. + /// Callers must compare the returned thread/role/parts/metadata with their + /// intended append (the same exact-identity rule enforced by + /// [`Self::rt_append_message`]) rather than treating any id hit as a match. + #[cfg(test)] + pub fn rt_get_message(&self, id: &str) -> DbResult> { + let conn = self.lock(); + rt_message_by_id(&conn, id) + } + + #[cfg(test)] + pub fn rt_get_message_in_scope( + &self, + scope: &RtAccountScope, + organization_id: &str, + id: &str, + ) -> DbResult> { + self.adopt_legacy_account_rows(scope, organization_id)?; + let conn = self.lock(); + conn.query_row( + "SELECT id, thread_id, role, parts, metadata, created_at, updated_at, seq \ + FROM native_scoped_messages \ + WHERE id = ?1 AND EXISTS (\ + SELECT 1 FROM native_scoped_threads \ + WHERE id = native_scoped_messages.thread_id AND organization_id = ?2 \ + AND account_scope = ?3\ + )", + params![id, organization_id, scope.storage_key()], + row_to_rt_message, + ) + .optional() + .map_err(DbError::from) + } + + pub fn rt_get_message_fenced( + &self, + fence: &RtThreadFence, + id: &str, + ) -> DbResult> { + let conn = self.lock(); + conn.query_row( + "SELECT id, thread_id, role, parts, metadata, created_at, updated_at, seq \ + FROM native_scoped_messages \ + WHERE id = ?1 AND EXISTS (\ + SELECT 1 FROM native_scoped_threads \ + WHERE id = native_scoped_messages.thread_id AND account_scope = ?2 \ + AND organization_id = ?3 AND generation = ?4\ + )", + params![ + id, + fence.account_scope, + fence.organization_id, + fence.generation, + ], + row_to_rt_message, + ) + .optional() + .map_err(DbError::from) + } + + #[cfg(test)] + pub fn rt_get_message_in_org( + &self, + organization_id: &str, + id: &str, + ) -> DbResult> { + self.rt_get_message_in_scope(&RtAccountScope::test_default(), organization_id, id) + } + + /// Appends a message and bumps the parent thread's `updated_at` + /// atomically, same pattern as [`Self::create_message`]. `id` is + /// caller-chosen (not auto-generated) so dispatch can mint deterministic + /// ids (`msg--user` / `msg--assistant`). Repeating an id is + /// idempotent only when thread/role/parts/metadata are semantically equal: + /// the original row is returned, its `seq` is not consumed again, and the + /// parent thread is not bumped a second time. Reusing an id for different + /// content is an explicit [`DbError::IdempotencyConflict`]. + /// Test-only compatibility entry point without an organization or + /// generation fence. Production workers persist claimed turns through + /// [`Self::rt_begin_claimed_turn`] and [`Self::rt_finalize_claimed_turn`]. + #[cfg(test)] + pub fn rt_append_message( + &self, + id: &str, + thread_id: &str, + role: &str, + parts: &Value, + metadata: Option<&Value>, + ) -> DbResult { + self.rt_append_message_inner(None, None, id, thread_id, role, parts, metadata) + } + + #[allow(clippy::too_many_arguments)] + #[cfg(test)] + pub fn rt_append_message_in_org( + &self, + organization_id: &str, + id: &str, + thread_id: &str, + role: &str, + parts: &Value, + metadata: Option<&Value>, + ) -> DbResult { + self.rt_append_message_inner( + Some(organization_id), + None, + id, + thread_id, + role, + parts, + metadata, + ) + } + + #[cfg(test)] + #[allow(clippy::too_many_arguments)] + fn rt_append_message_inner( + &self, + organization_id: Option<&str>, + generation: Option<&str>, + id: &str, + thread_id: &str, + role: &str, + parts: &Value, + metadata: Option<&Value>, + ) -> DbResult { + let ts = now_rfc3339(); + let parts_str = serde_json::to_string(parts)?; + let metadata_str = metadata.map(|m| m.to_string()); + let mut conn = self.lock(); + // Reserve the writer slot before reading MAX(seq), so even a future + // second process/connection cannot allocate the same per-thread value. + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + if let Some(organization_id) = organization_id { + let owns_thread: bool = match generation { + Some(generation) => tx.query_row( + "SELECT EXISTS(\ + SELECT 1 FROM native_scoped_threads \ + WHERE id = ?1 AND organization_id = ?2 AND generation = ?3\ + )", + params![thread_id, organization_id, generation], + |row| row.get(0), + )?, + None => tx.query_row( + "SELECT EXISTS(\ + SELECT 1 FROM native_scoped_threads WHERE id = ?1 AND organization_id = ?2\ + )", + params![thread_id, organization_id], + |row| row.get(0), + )?, + }; + if !owns_thread { + if let Some(generation) = generation { + return Err(DbError::StaleThreadGeneration { + organization_id: organization_id.to_string(), + thread_id: thread_id.to_string(), + generation: generation.to_string(), + }); + } + return Err(DbError::Sqlite(rusqlite::Error::QueryReturnedNoRows)); + } + } + let inserted = tx.execute( + "INSERT OR IGNORE INTO native_scoped_messages \ + (id, thread_id, role, parts, metadata, seq, created_at, updated_at) \ + VALUES (\ + ?1, ?2, ?3, ?4, ?5, \ + (SELECT COALESCE(MAX(seq), 0) + 1 FROM native_scoped_messages WHERE thread_id = ?2), \ + ?6, ?6\ + )", + params![id, thread_id, role, parts_str, metadata_str, ts], + )?; + if inserted > 0 { + match (organization_id, generation) { + (Some(organization_id), Some(generation)) => tx.execute( + "UPDATE native_scoped_threads SET updated_at = ?1 \ + WHERE id = ?2 AND organization_id = ?3 AND generation = ?4", + params![ts, thread_id, organization_id, generation], + )?, + (Some(organization_id), None) => tx.execute( + "UPDATE native_scoped_threads SET updated_at = ?1 \ + WHERE id = ?2 AND organization_id = ?3", + params![ts, thread_id, organization_id], + )?, + (None, _) => tx.execute( + "UPDATE native_scoped_threads SET updated_at = ?1 WHERE id = ?2", + params![ts, thread_id], + )?, + }; + } + let message = match (organization_id, generation) { + (Some(organization_id), Some(generation)) => tx.query_row( + "SELECT id, thread_id, role, parts, metadata, created_at, updated_at, seq \ + FROM native_scoped_messages \ + WHERE id = ?1 AND EXISTS (\ + SELECT 1 FROM native_scoped_threads \ + WHERE id = native_scoped_messages.thread_id AND organization_id = ?2 \ + AND generation = ?3\ + )", + params![id, organization_id, generation], + row_to_rt_message, + )?, + (Some(organization_id), None) => tx.query_row( + "SELECT id, thread_id, role, parts, metadata, created_at, updated_at, seq \ + FROM native_scoped_messages \ + WHERE id = ?1 AND EXISTS (\ + SELECT 1 FROM native_scoped_threads \ + WHERE id = native_scoped_messages.thread_id AND organization_id = ?2\ + )", + params![id, organization_id], + row_to_rt_message, + )?, + (None, _) => tx.query_row( + "SELECT id, thread_id, role, parts, metadata, created_at, updated_at, seq \ + FROM native_scoped_messages WHERE id = ?1", + params![id], + row_to_rt_message, + )?, + }; + if inserted == 0 + && (message.thread_id != thread_id + || message.role != role + || message.parts != *parts + || message.metadata.as_ref() != metadata) + { + return Err(DbError::IdempotencyConflict { + entity: "rt_message", + id: id.to_string(), + }); + } + tx.commit()?; + Ok(message) + } + + #[cfg(test)] + pub fn rt_set_thread_status_in_org( + &self, + organization_id: &str, + id: &str, + status: &str, + ) -> DbResult<()> { + let ts = now_rfc3339(); + let conn = self.lock(); + conn.execute( + "UPDATE native_scoped_threads SET status = ?1, updated_at = ?2 \ + WHERE id = ?3 AND organization_id = ?4", + params![status, ts, id, organization_id], + )?; + Ok(()) + } + + /// Scans assistant messages newest-first for the most recent valid + /// `data-harness-session` resume-token pseudo-part `harness::parts` + /// appends (see that crate's module doc's "Resume token convention"). + /// + /// A failed/cancelled turn may persist an assistant row before the CLI + /// reports a session id. That row must not hide the previous valid resume + /// token: the CLI still owns the earlier on-disk conversation and the next + /// turn must continue it rather than silently starting a new session. + #[cfg(test)] + pub fn rt_last_assistant_session_in_org( + &self, + organization_id: &str, + thread_id: &str, + ) -> DbResult> { + self.rt_last_assistant_session_inner(organization_id, thread_id, None, None, None) + } + + #[cfg(test)] + pub fn rt_last_assistant_session_for_harness_in_org( + &self, + organization_id: &str, + thread_id: &str, + harness_id: &str, + ) -> DbResult> { + self.rt_last_assistant_session_inner( + organization_id, + thread_id, + None, + None, + Some(harness_id), + ) + } + + pub fn rt_last_assistant_session_fenced( + &self, + fence: &RtThreadFence, + harness_id: &str, + ) -> DbResult> { + self.rt_last_assistant_session_inner( + &fence.organization_id, + &fence.thread_id, + Some(&fence.generation), + Some(&fence.account_scope), + Some(harness_id), + ) + } + + fn rt_last_assistant_session_inner( + &self, + organization_id: &str, + thread_id: &str, + generation: Option<&str>, + account_scope: Option<&str>, + expected_harness_id: Option<&str>, + ) -> DbResult> { + let conn = self.lock(); + let mut statement = match generation { + Some(_) => conn.prepare( + "SELECT parts FROM native_scoped_messages \ + WHERE thread_id = ?1 AND role = 'assistant' AND EXISTS (\ + SELECT 1 FROM native_scoped_threads \ + WHERE id = native_scoped_messages.thread_id AND organization_id = ?2 \ + AND generation = ?3 AND account_scope = ?4\ + ) \ + ORDER BY seq DESC", + )?, + None => conn.prepare( + "SELECT parts FROM native_scoped_messages \ + WHERE thread_id = ?1 AND role = 'assistant' AND EXISTS (\ + SELECT 1 FROM native_scoped_threads \ + WHERE id = native_scoped_messages.thread_id AND organization_id = ?2\ + ) \ + ORDER BY seq DESC", + )?, + }; + let mut rows = match generation { + Some(generation) => statement.query(params![ + thread_id, + organization_id, + generation, + account_scope + ])?, + None => statement.query(params![thread_id, organization_id])?, + }; + + while let Some(row) = rows.next()? { + let parts_json: String = row.get(0)?; + let parts: Value = serde_json::from_str(&parts_json)?; + let Some(parts) = parts.as_array() else { + continue; + }; + for part in parts.iter().rev() { + if part.get("type").and_then(Value::as_str) != Some("data-harness-session") { + continue; + } + let Some(harness_id) = part + .get("harnessId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + if expected_harness_id.is_some_and(|expected| expected != harness_id) { + continue; + } + let Some(session_id) = part + .get("sessionId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + return Ok(Some((harness_id.to_string(), session_id.to_string()))); + } + } + Ok(None) + } + + /// Durably accepts a turn before its HTTP handler returns `202`. + /// Repeating either identity (`message_id` or `workflow_id`) is a no-op + /// only when every caller-controlled field is semantically identical. + pub fn rt_enqueue_turn_scoped( + &self, + scope: &RtAccountScope, + organization_id: &str, + thread_id: &str, + input: &RtTurnEnqueueInput, + ) -> DbResult { + self.adopt_legacy_account_rows(scope, organization_id)?; + let account_scope = scope.storage_key(); + for (name, value) in [ + ("organization_id", organization_id), + ("thread_id", thread_id), + ("message_id", input.message_id.as_str()), + ("workflow_id", input.workflow_id.as_str()), + ("task_id", input.task_id.as_str()), + ] { + if value.is_empty() { + return Err(DbError::InvalidQueueData(format!( + "{name} must not be empty" + ))); + } + } + if input.user_message.get("id").and_then(Value::as_str) != Some(input.message_id.as_str()) + || input.user_message.get("role").and_then(Value::as_str) != Some("user") + { + return Err(DbError::InvalidQueueData( + "user_message must be a user role with id equal to message_id".to_string(), + )); + } + if is_native_assistant_message_id(&input.message_id) { + return Err(DbError::InvalidQueueData(format!( + "message_id uses reserved namespace {NATIVE_ASSISTANT_MESSAGE_ID_PREFIX}" + ))); + } + let enqueued_at = i64::try_from(input.enqueued_at).map_err(|_| { + DbError::InvalidQueueData("enqueued_at does not fit SQLite INTEGER".to_string()) + })?; + let normalized_input_json = serde_json::to_string(&input.normalized_input)?; + let user_message_json = serde_json::to_string(&input.user_message)?; + let mut conn = self.lock(); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let Some(fence) = + rt_thread_fence_by_id_in_scope(&tx, &account_scope, organization_id, thread_id)? + else { + return Err(DbError::Sqlite(rusqlite::Error::QueryReturnedNoRows)); + }; + let delete_pending: bool = tx.query_row( + "SELECT delete_pending FROM native_scoped_threads \ + WHERE account_scope = ?1 AND organization_id = ?2 AND id = ?3 \ + AND generation = ?4", + params![account_scope, organization_id, thread_id, fence.generation], + |row| row.get(0), + )?; + if delete_pending { + return Err(DbError::ThreadDeletePending { + organization_id: organization_id.to_string(), + thread_id: thread_id.to_string(), + }); + } + let assistant_message_id = native_assistant_message_id(&fence, &input.message_id); + + let existing = rt_turn_queue_query( + &tx, + "account_scope = ?1 AND organization_id = ?2 AND thread_id = ?3 \ + AND thread_generation = ?4 AND (message_id = ?5 OR workflow_id = ?6)", + params![ + account_scope, + organization_id, + thread_id, + fence.generation, + input.message_id, + input.workflow_id, + ], + )?; + if !existing.is_empty() { + if existing.len() == 1 { + let item = existing.into_iter().next().expect("one existing row"); + if item.message_id == input.message_id + && item.workflow_id == input.workflow_id + && item.task_id == input.task_id + && item.normalized_input == input.normalized_input + && item.user_message == input.user_message + { + tx.commit()?; + return Ok(RtTurnEnqueueOutcome::Existing(item)); + } + } + return Err(DbError::IdempotencyConflict { + entity: "rt_turn_queue", + id: input.message_id.clone(), + }); + } + + // The message table's primary key is intentionally global, while a + // queue row is tenant/thread scoped. Reserve both eventual global ids + // in this same IMMEDIATE transaction so no request can receive `202` + // and discover a collision only after its harness performs side + // effects. A current-protocol crash window always retains its durable + // queue row and was handled by the exact-identity lookup above. A + // persisted user with no queue ownership is therefore legacy or + // corrupt state, never permission to rerun side-effecting work. + let user_parts = input + .user_message + .get("parts") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + let user_metadata = input.user_message.get("metadata"); + let persisted_user = rt_message_by_id(&tx, &input.message_id)?; + if let Some(user) = &persisted_user { + if user.thread_id != thread_id + || user.role != "user" + || user.parts != user_parts + || user.metadata.as_ref() != user_metadata + { + return Err(DbError::IdempotencyConflict { + entity: "rt_message", + id: input.message_id.clone(), + }); + } + } + + let persisted_assistant = rt_message_by_id(&tx, &assistant_message_id)?; + if let Some(assistant) = &persisted_assistant { + if !is_exact_completed_turn_pair(persisted_user.as_ref(), assistant, thread_id) { + return Err(DbError::IdempotencyConflict { + entity: "rt_message", + id: assistant_message_id, + }); + } + tx.commit()?; + return Ok(RtTurnEnqueueOutcome::Completed); + } + + // Transitional queue builds used a user-seeded assistant id. Recognize + // that exact same-thread pair for retry only; new queue rows always + // reserve the namespaced v1 id above. The shipped pre-migration app + // instead seeded assistant ids from a fresh server task id, which is + // not recoverably linked to its user row. + if persisted_user.is_some() { + let legacy_assistant_id = legacy_native_assistant_message_id(&input.message_id); + if let Some(assistant) = rt_message_by_id(&tx, &legacy_assistant_id)? { + if !is_exact_completed_turn_pair(persisted_user.as_ref(), &assistant, thread_id) { + return Err(DbError::IdempotencyConflict { + entity: "rt_message", + id: legacy_assistant_id, + }); + } + tx.commit()?; + return Ok(RtTurnEnqueueOutcome::Completed); + } + + // Fail closed for an unowned persisted user. In the shipped + // pre-migration implementation, `msg-{task_id}-assistant` cannot be + // derived from the client message id, so absence of either + // provable completion id does NOT prove the old harness failed to + // run. Enqueuing here could repeat arbitrary CLI side effects. + // Preserve every historical row and require a new user-message id + // for new work instead. + return Err(DbError::IdempotencyConflict { + entity: "rt_message", + id: input.message_id.clone(), + }); + } + + let reserved_by_another_queue: Option = tx + .query_row( + "SELECT workflow_id FROM native_scoped_turn_queue \ + WHERE message_id IN (?1, ?2) \ + OR assistant_message_id IN (?1, ?2) \ + LIMIT 1", + params![input.message_id, assistant_message_id], + |row| row.get(0), + ) + .optional()?; + if reserved_by_another_queue.is_some() { + return Err(DbError::IdempotencyConflict { + entity: "rt_turn_queue", + id: input.message_id.clone(), + }); + } + + let fifo_ordinal: i64 = tx.query_row( + "SELECT COALESCE(MAX(fifo_ordinal), 0) + 1 FROM native_scoped_turn_queue \ + WHERE account_scope = ?1 AND organization_id = ?2 AND thread_id = ?3 \ + AND thread_generation = ?4", + params![account_scope, organization_id, thread_id, fence.generation], + |row| row.get(0), + )?; + tx.execute( + "INSERT INTO native_scoped_turn_queue (\ + account_scope, organization_id, thread_id, thread_generation, message_id, \ + assistant_message_id, workflow_id, task_id, normalized_input_json, \ + user_message_json, enqueued_at, fifo_ordinal, state, claim_token\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'queued', NULL)", + params![ + account_scope, + organization_id, + thread_id, + fence.generation, + input.message_id, + assistant_message_id, + input.workflow_id, + input.task_id, + normalized_input_json, + user_message_json, + enqueued_at, + fifo_ordinal, + ], + )?; + let item = rt_turn_queue_by_workflow(&tx, &fence, &input.workflow_id)? + .ok_or_else(|| DbError::Sqlite(rusqlite::Error::QueryReturnedNoRows))?; + tx.commit()?; + Ok(RtTurnEnqueueOutcome::Inserted(item)) + } + + #[cfg(test)] + pub fn rt_enqueue_turn_in_org( + &self, + organization_id: &str, + thread_id: &str, + input: &RtTurnEnqueueInput, + ) -> DbResult { + self.rt_enqueue_turn_scoped( + &RtAccountScope::test_default(), + organization_id, + thread_id, + input, + ) + } + + pub fn rt_list_turn_queue_scoped( + &self, + scope: &RtAccountScope, + organization_id: &str, + thread_id: &str, + ) -> DbResult> { + self.adopt_legacy_account_rows(scope, organization_id)?; + let account_scope = scope.storage_key(); + let conn = self.lock(); + let Some(fence) = + rt_thread_fence_by_id_in_scope(&conn, &account_scope, organization_id, thread_id)? + else { + return Ok(Vec::new()); + }; + rt_turn_queue_query( + &conn, + "account_scope = ?1 AND organization_id = ?2 AND thread_id = ?3 \ + AND thread_generation = ?4", + params![account_scope, organization_id, thread_id, fence.generation], + ) + } + + #[cfg(test)] + pub fn rt_list_turn_queue_in_org( + &self, + organization_id: &str, + thread_id: &str, + ) -> DbResult> { + self.rt_list_turn_queue_scoped(&RtAccountScope::test_default(), organization_id, thread_id) + } + + /// Claims the FIFO head only when no active row exists. A persisted + /// `running` row after process restart is deliberately NOT reset/retried: + /// its CLI may already have performed side effects. Recovery must finalize + /// it via [`Self::rt_list_orphaned_active_turns`] before claiming the safe + /// queued tail. + #[cfg(test)] + pub fn rt_claim_turn_queue_head_in_org( + &self, + organization_id: &str, + thread_id: &str, + ) -> DbResult> { + match self.rt_claim_turn_queue_head_inner( + &RtAccountScope::test_default().storage_key(), + organization_id, + thread_id, + None, + )? { + Some(RtTurnClaimOutcome::Ready(item)) => Ok(Some(item)), + Some(RtTurnClaimOutcome::Malformed(item)) => Err(DbError::InvalidQueueData(format!( + "malformed claimed workflow {}: {}", + item.workflow_id, item.error + ))), + Some(RtTurnClaimOutcome::Completed { .. }) => Ok(None), + None => Ok(None), + } + } + + /// Same claim operation, but refuses to cross a stale-generation/delete + /// boundary. + /// Drain workers should use this after their first claim establishes which + /// thread incarnation they own. + pub fn rt_claim_turn_queue_head_fenced( + &self, + fence: &RtThreadFence, + ) -> DbResult> { + self.rt_claim_turn_queue_head_inner( + &fence.account_scope, + &fence.organization_id, + &fence.thread_id, + Some(&fence.generation), + ) + } + + fn rt_claim_turn_queue_head_inner( + &self, + account_scope: &str, + organization_id: &str, + thread_id: &str, + expected_generation: Option<&str>, + ) -> DbResult> { + let mut conn = self.lock(); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let Some(fence) = + rt_thread_fence_by_id_in_scope(&tx, account_scope, organization_id, thread_id)? + else { + tx.commit()?; + return Ok(None); + }; + if let Some(expected) = expected_generation { + if expected != fence.generation.as_str() { + return Err(DbError::StaleThreadGeneration { + organization_id: organization_id.to_string(), + thread_id: thread_id.to_string(), + generation: expected.to_string(), + }); + } + } + let delete_pending: bool = tx.query_row( + "SELECT delete_pending FROM native_scoped_threads \ + WHERE account_scope = ?1 AND organization_id = ?2 AND id = ?3 \ + AND generation = ?4", + params![account_scope, organization_id, thread_id, fence.generation], + |row| row.get(0), + )?; + if delete_pending { + tx.commit()?; + return Ok(None); + } + let active: bool = tx.query_row( + "SELECT EXISTS(\ + SELECT 1 FROM native_scoped_turn_queue \ + WHERE account_scope = ?1 AND organization_id = ?2 AND thread_id = ?3 \ + AND thread_generation = ?4 \ + AND state IN ('running', 'cancel_requested')\ + )", + params![account_scope, organization_id, thread_id, fence.generation], + |row| row.get(0), + )?; + if active { + tx.commit()?; + return Ok(None); + } + let raw: Option = tx + .query_row( + &format!( + "SELECT {RT_TURN_QUEUE_COLUMNS} FROM native_scoped_turn_queue \ + WHERE account_scope = ?1 AND organization_id = ?2 AND thread_id = ?3 \ + AND thread_generation = ?4 AND state = 'queued' \ + ORDER BY fifo_ordinal ASC LIMIT 1" + ), + params![account_scope, organization_id, thread_id, fence.generation], + row_to_raw_rt_turn_queue, + ) + .optional()?; + let Some(mut raw) = raw else { + tx.commit()?; + return Ok(None); + }; + let candidate_assistant_id = raw + .assistant_message_id + .clone() + .unwrap_or_else(|| legacy_native_assistant_message_id(&raw.message_id)); + let claim_token = Uuid::new_v4().to_string(); + let changed = tx.execute( + "UPDATE native_scoped_turn_queue SET state = 'running', claim_token = ?1 \ + WHERE account_scope = ?2 AND organization_id = ?3 AND thread_id = ?4 \ + AND thread_generation = ?5 AND workflow_id = ?6 AND state = 'queued'", + params![ + claim_token, + account_scope, + organization_id, + thread_id, + fence.generation, + raw.workflow_id, + ], + )?; + if changed != 1 { + return Err(DbError::InvalidQueueData(format!( + "could not exclusively claim workflow {}", + raw.workflow_id + ))); + } + raw.state = "running".to_string(); + raw.claim_token = Some(claim_token); + let parsed = parse_raw_rt_turn_queue(raw.clone()); + let canonical_user = parse_canonical_queued_user(&raw.message_id, &raw.user_message_json); + let reservation_state = validate_raw_turn_reservations( + &tx, + &raw, + &candidate_assistant_id, + canonical_user.as_ref().ok(), + )?; + let (claim_error, completed_persisted_pair) = match reservation_state { + RawTurnReservationState::Safe => (None, false), + RawTurnReservationState::Completed => (None, true), + RawTurnReservationState::Quarantine(error) => (Some(error), false), + }; + if completed_persisted_pair { + adopt_completed_raw_turn(&tx, &raw)?; + let workflow_id = raw.workflow_id; + tx.commit()?; + return Ok(Some(RtTurnClaimOutcome::Completed { workflow_id })); + } + let preserve_canonical_user = claim_error.is_none() + && parsed.is_err() + && canonical_user.is_ok() + && !completed_persisted_pair; + if let Some(reason) = claim_error + .as_ref() + .or_else(|| parsed.as_ref().err()) + .map(ToString::to_string) + { + // Persist the quarantine decision before returning it. A process + // crash between claim and finalization must not turn a valid-JSON + // pre-v8 collision back into executable harness work on recovery. + // Keep the original assistant reservation untouched: changing it + // can itself violate the v1 partial unique index when another + // queued turn already owns the isolation id. + let changed = tx.execute( + "UPDATE native_scoped_turn_queue SET quarantine_reason = ?1, \ + quarantine_preserve_user = ?2 \ + WHERE account_scope = ?3 AND organization_id = ?4 AND thread_id = ?5 \ + AND thread_generation = ?6 AND workflow_id = ?7 \ + AND state = 'running' AND claim_token = ?8", + params![ + reason, + preserve_canonical_user, + raw.account_scope, + raw.organization_id, + raw.thread_id, + raw.thread_generation, + raw.workflow_id, + raw.claim_token, + ], + )?; + if changed != 1 { + return Err(DbError::InvalidQueueData(format!( + "could not persist quarantine for workflow {}", + raw.workflow_id + ))); + } + raw.quarantine_reason = Some(reason); + raw.quarantine_preserve_user = preserve_canonical_user; + } + let claimed = match (claim_error, parsed) { + (Some(error), _) | (None, Err(error)) => { + RtTurnClaimOutcome::Malformed(raw.malformed_orphan(error)) + } + (None, Ok(item)) => RtTurnClaimOutcome::Ready(item), + }; + tx.commit()?; + Ok(Some(claimed)) + } + + /// Starts an owned durable turn in one write transaction. The canonical + /// user message comes from the queue row itself (never a reconstructed RAM + /// copy), is inserted with the same exact-idempotency rule as terminal + /// assistants, and the thread becomes `in_progress` only if both persist. + /// + /// A cancellation that wins before this boundary is reported separately + /// and writes nothing. The caller can then use + /// [`Self::rt_finalize_claimed_turn`] to publish its failed/cancelled + /// terminal result without ever starting the side-effecting harness. + pub fn rt_begin_claimed_turn(&self, claimed: &RtTurnQueueItem) -> DbResult { + let claim_token = claimed.claim_token.as_deref().ok_or_else(|| { + DbError::InvalidQueueData(format!( + "workflow {} has no claim token", + claimed.workflow_id + )) + })?; + let ts = now_rfc3339(); + let mut conn = self.lock(); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let persisted: Option<(String, String, String)> = tx + .query_row( + "SELECT message_id, user_message_json, state \ + FROM native_scoped_turn_queue \ + WHERE account_scope = ?1 AND organization_id = ?2 AND thread_id = ?3 \ + AND thread_generation = ?4 AND workflow_id = ?5 \ + AND state IN ('running', 'cancel_requested') AND claim_token = ?6 \ + AND EXISTS (\ + SELECT 1 FROM native_scoped_threads \ + WHERE account_scope = ?1 AND organization_id = ?2 \ + AND id = ?3 AND generation = ?4\ + )", + params![ + claimed.fence.account_scope, + claimed.fence.organization_id, + claimed.fence.thread_id, + claimed.fence.generation, + claimed.workflow_id, + claim_token, + ], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + let Some((message_id, user_message_json, state)) = persisted else { + tx.commit()?; + return Ok(RtTurnBeginOutcome::Stale); + }; + if state == "cancel_requested" { + tx.commit()?; + return Ok(RtTurnBeginOutcome::CancelRequested); + } + + let user = rt_insert_canonical_queued_user( + &tx, + &claimed.fence.thread_id, + &message_id, + &user_message_json, + &ts, + )?; + let status_changed = tx.execute( + "UPDATE native_scoped_threads SET status = 'in_progress', updated_at = ?1 \ + WHERE id = ?2 AND organization_id = ?3 AND generation = ?4 \ + AND account_scope = ?5", + params![ + ts, + claimed.fence.thread_id, + claimed.fence.organization_id, + claimed.fence.generation, + claimed.fence.account_scope, + ], + )?; + if status_changed != 1 { + return Ok(RtTurnBeginOutcome::Stale); + } + tx.commit()?; + Ok(RtTurnBeginOutcome::Begun(user)) + } + + /// Durably checkpoints the CLI-owned conversation identity as soon as the + /// harness reports it. The write is owned by the complete active-claim + /// fence; a worker from a deleted generation or previous process cannot + /// mutate the replacement row. Repeating the same pair is idempotent, + /// while a different pair is corruption and fails closed. + /// + /// `false` means the active claim no longer exists. Callers must stop the + /// harness rather than continue a generation whose continuation token + /// cannot be made durable. + pub fn rt_checkpoint_claimed_turn_session( + &self, + claimed: &RtTurnQueueItem, + harness_id: &str, + session_id: &str, + ) -> DbResult { + let claim_token = claimed.claim_token.as_deref().ok_or_else(|| { + DbError::InvalidQueueData(format!( + "workflow {} has no claim token", + claimed.workflow_id + )) + })?; + let harness_id = harness_id.trim(); + let session_id = session_id.trim(); + if harness_id.is_empty() || session_id.is_empty() { + return Err(DbError::InvalidQueueData( + "harness session checkpoint contains an empty value".to_string(), + )); + } + + let mut conn = self.lock(); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let persisted: Option<(Option, Option, Option)> = tx + .query_row( + "SELECT q.checkpoint_harness_id, q.checkpoint_session_id, t.harness_id \ + FROM native_scoped_turn_queue q \ + JOIN native_scoped_threads t \ + ON t.account_scope = q.account_scope \ + AND t.organization_id = q.organization_id \ + AND t.id = q.thread_id \ + AND t.generation = q.thread_generation \ + WHERE q.account_scope = ?1 AND q.organization_id = ?2 \ + AND q.thread_id = ?3 AND q.thread_generation = ?4 \ + AND q.workflow_id = ?5 \ + AND q.state IN ('running', 'cancel_requested') \ + AND q.claim_token = ?6", + params![ + claimed.fence.account_scope, + claimed.fence.organization_id, + claimed.fence.thread_id, + claimed.fence.generation, + claimed.workflow_id, + claim_token, + ], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + let Some((existing_harness_id, existing_session_id, pinned_harness_id)) = persisted else { + tx.commit()?; + return Ok(false); + }; + if pinned_harness_id.as_deref().map(str::trim) != Some(harness_id) { + return Err(DbError::InvalidQueueData(format!( + "workflow {} cannot checkpoint harness {harness_id:?} against thread pin {:?}", + claimed.workflow_id, pinned_harness_id + ))); + } + + match validated_checkpoint_pair( + existing_harness_id.as_deref(), + existing_session_id.as_deref(), + )? { + Some((existing_harness_id, existing_session_id)) + if existing_harness_id == harness_id && existing_session_id == session_id => + { + tx.commit()?; + Ok(true) + } + Some((existing_harness_id, existing_session_id)) => { + Err(DbError::InvalidQueueData(format!( + "workflow {} changed its harness session checkpoint from {existing_harness_id:?}/{existing_session_id:?} to {harness_id:?}/{session_id:?}", + claimed.workflow_id + ))) + } + None => { + let changed = tx.execute( + "UPDATE native_scoped_turn_queue \ + SET checkpoint_harness_id = ?1, checkpoint_session_id = ?2 \ + WHERE account_scope = ?3 AND organization_id = ?4 AND thread_id = ?5 \ + AND thread_generation = ?6 AND workflow_id = ?7 \ + AND state IN ('running', 'cancel_requested') \ + AND claim_token = ?8 \ + AND checkpoint_harness_id IS NULL AND checkpoint_session_id IS NULL", + params![ + harness_id, + session_id, + claimed.fence.account_scope, + claimed.fence.organization_id, + claimed.fence.thread_id, + claimed.fence.generation, + claimed.workflow_id, + claim_token, + ], + )?; + if changed != 1 { + return Err(DbError::InvalidQueueData(format!( + "could not checkpoint harness session for workflow {}", + claimed.workflow_id + ))); + } + tx.commit()?; + Ok(true) + } + } + } + + /// Commits a claimed turn's entire terminal storage boundary atomically: + /// exact-idempotent canonical queued-user insert, assistant insert, + /// terminal thread status, and claimed queue-row deletion. Persisting the + /// user here as well as at begin is required for cancellation that wins + /// before begin: an accepted user turn must never disappear merely because + /// its harness was never started. The queue row is the ownership fence, so + /// neither a deleted/recreated thread generation nor an old worker claim + /// can publish a terminal result. + /// + /// An identical pre-existing assistant is accepted. That is the recovery + /// path for databases written by older app versions that committed the + /// assistant before crashing prior to queue cleanup. A different message + /// under the same id is an idempotency conflict, and the status + queue row + /// remain untouched. Both `running` and `cancel_requested` are terminally + /// owned by the same claim token; cancellation must not make the worker + /// lose its ability to persist the terminal response. + #[allow(clippy::too_many_arguments)] + pub fn rt_finalize_claimed_turn( + &self, + claimed: &RtTurnQueueItem, + assistant_parts: &Value, + assistant_metadata: Option<&Value>, + terminal_status: RtTurnTerminalStatus, + ) -> DbResult { + let claim_token = claimed.claim_token.as_deref().ok_or_else(|| { + DbError::InvalidQueueData(format!( + "workflow {} has no claim token", + claimed.workflow_id + )) + })?; + self.rt_finalize_claimed_turn_inner( + &claimed.fence, + &claimed.workflow_id, + claim_token, + assistant_parts, + assistant_metadata, + terminal_status, + ) + } + + /// Finalizes one malformed active recovery row without decoding the field + /// that failed claim validation. The exact claim identity fences one + /// atomic transition. If claim proved that the canonical user JSON and both + /// global message reservations are safe, the accepted user and interrupted + /// assistant are persisted in sequence before the row is removed. Invalid + /// user payloads and reservation collisions instead close with no messages, + /// preventing assistant-only or cross-thread transcripts. + pub fn rt_finalize_malformed_orphan( + &self, + orphan: &RtMalformedOrphanedTurn, + assistant_parts: &Value, + assistant_metadata: Option<&Value>, + ) -> DbResult { + let claim_token = orphan.claim_token.as_deref().ok_or_else(|| { + DbError::InvalidQueueData(format!( + "malformed workflow {} has no claim token", + orphan.workflow_id + )) + })?; + let ts = now_rfc3339(); + let mut conn = self.lock(); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let claimed: Option = tx + .query_row( + "SELECT q.message_id, q.user_message_json, q.assistant_message_id, \ + q.quarantine_preserve_user, q.checkpoint_harness_id, \ + q.checkpoint_session_id \ + FROM native_scoped_turn_queue q \ + JOIN native_scoped_threads t \ + ON t.account_scope = q.account_scope \ + AND t.organization_id = q.organization_id \ + AND t.id = q.thread_id \ + AND t.generation = q.thread_generation \ + WHERE q.account_scope = ?1 AND q.organization_id = ?2 \ + AND q.thread_id = ?3 AND q.thread_generation = ?4 \ + AND q.workflow_id = ?5 \ + AND q.state IN ('running', 'cancel_requested') \ + AND q.claim_token = ?6", + params![ + orphan.fence.account_scope, + orphan.fence.organization_id, + orphan.fence.thread_id, + orphan.fence.generation, + orphan.workflow_id, + claim_token, + ], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get::<_, i64>(3)? != 0, + row.get(4)?, + row.get(5)?, + )) + }, + ) + .optional()?; + let Some(( + message_id, + user_message_json, + reserved_assistant_id, + preserve_user, + checkpoint_harness_id, + checkpoint_session_id, + )) = claimed + else { + tx.commit()?; + return Ok(RtTurnTerminalOutcome::Stale); + }; + let checkpoint_session = validated_checkpoint_pair( + checkpoint_harness_id.as_deref(), + checkpoint_session_id.as_deref(), + )?; + + let assistant = if preserve_user { + if orphan.canonical_user.is_none() { + return Err(DbError::InvalidQueueData(format!( + "malformed workflow {} lost its validated canonical user", + orphan.workflow_id + ))); + } + let canonical_user = rt_insert_canonical_queued_user( + &tx, + &orphan.fence.thread_id, + &message_id, + &user_message_json, + &ts, + )?; + let assistant_id = reserved_assistant_id + .unwrap_or_else(|| legacy_native_assistant_message_id(&message_id)); + let assistant_parts = + assistant_parts_with_checkpoint(assistant_parts, checkpoint_session.as_ref())?; + let assistant = rt_insert_exact_message( + &tx, + &orphan.fence.thread_id, + &assistant_id, + "assistant", + &assistant_parts, + assistant_metadata, + &ts, + )?; + if canonical_user.seq >= assistant.seq { + return Err(DbError::InvalidQueueData(format!( + "workflow {} has assistant sequence {} before canonical user sequence {}", + orphan.workflow_id, assistant.seq, canonical_user.seq + ))); + } + Some(assistant) + } else { + None + }; + + let status_changed = tx.execute( + "UPDATE native_scoped_threads SET status = 'failed', updated_at = ?1 \ + WHERE id = ?2 AND organization_id = ?3 AND generation = ?4 \ + AND account_scope = ?5", + params![ + ts, + orphan.fence.thread_id, + orphan.fence.organization_id, + orphan.fence.generation, + orphan.fence.account_scope, + ], + )?; + let deleted = tx.execute( + "DELETE FROM native_scoped_turn_queue \ + WHERE account_scope = ?1 AND organization_id = ?2 AND thread_id = ?3 \ + AND thread_generation = ?4 AND workflow_id = ?5 \ + AND state IN ('running', 'cancel_requested') AND claim_token = ?6", + params![ + orphan.fence.account_scope, + orphan.fence.organization_id, + orphan.fence.thread_id, + orphan.fence.generation, + orphan.workflow_id, + claim_token, + ], + )?; + if status_changed != 1 || deleted != 1 { + return Err(DbError::InvalidQueueData(format!( + "could not quarantine malformed workflow {}", + orphan.workflow_id + ))); + } + tx.commit()?; + Ok(match assistant { + Some(assistant) => RtTurnTerminalOutcome::Completed(assistant), + None => RtTurnTerminalOutcome::Quarantined, + }) + } + + #[allow(clippy::too_many_arguments)] + fn rt_finalize_claimed_turn_inner( + &self, + fence: &RtThreadFence, + workflow_id: &str, + claim_token: &str, + assistant_parts: &Value, + assistant_metadata: Option<&Value>, + terminal_status: RtTurnTerminalStatus, + ) -> DbResult { + let ts = now_rfc3339(); + let mut conn = self.lock(); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + + let claimed_user: Option = tx + .query_row( + "SELECT q.message_id, q.user_message_json, q.assistant_message_id, \ + q.checkpoint_harness_id, q.checkpoint_session_id \ + FROM native_scoped_turn_queue q \ + JOIN native_scoped_threads t \ + ON t.account_scope = q.account_scope \ + AND t.organization_id = q.organization_id \ + AND t.id = q.thread_id \ + AND t.generation = q.thread_generation \ + WHERE q.account_scope = ?1 AND q.organization_id = ?2 \ + AND q.thread_id = ?3 AND q.thread_generation = ?4 \ + AND q.workflow_id = ?5 \ + AND q.state IN ('running', 'cancel_requested') \ + AND q.claim_token = ?6", + params![ + fence.account_scope, + fence.organization_id, + fence.thread_id, + fence.generation, + workflow_id, + claim_token, + ], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + ) + .optional()?; + let Some(( + message_id, + user_message_json, + reserved_assistant_id, + checkpoint_harness_id, + checkpoint_session_id, + )) = claimed_user + else { + tx.commit()?; + return Ok(RtTurnTerminalOutcome::Stale); + }; + let checkpoint_session = validated_checkpoint_pair( + checkpoint_harness_id.as_deref(), + checkpoint_session_id.as_deref(), + )?; + let assistant_id = reserved_assistant_id + .unwrap_or_else(|| legacy_native_assistant_message_id(&message_id)); + + let canonical_user = rt_insert_canonical_queued_user( + &tx, + &fence.thread_id, + &message_id, + &user_message_json, + &ts, + )?; + + let assistant_parts = + assistant_parts_with_checkpoint(assistant_parts, checkpoint_session.as_ref())?; + let assistant = rt_insert_exact_message( + &tx, + &fence.thread_id, + &assistant_id, + "assistant", + &assistant_parts, + assistant_metadata, + &ts, + )?; + if canonical_user.seq >= assistant.seq { + return Err(DbError::InvalidQueueData(format!( + "workflow {workflow_id} has assistant sequence {} before canonical user sequence {}", + assistant.seq, canonical_user.seq + ))); + } + + let status_changed = tx.execute( + "UPDATE native_scoped_threads SET status = ?1, updated_at = ?2 \ + WHERE id = ?3 AND organization_id = ?4 AND generation = ?5 \ + AND account_scope = ?6", + params![ + terminal_status.as_str(), + ts, + fence.thread_id, + fence.organization_id, + fence.generation, + fence.account_scope, + ], + )?; + if status_changed != 1 { + return Ok(RtTurnTerminalOutcome::Stale); + } + + let deleted = tx.execute( + "DELETE FROM native_scoped_turn_queue \ + WHERE account_scope = ?1 AND organization_id = ?2 AND thread_id = ?3 \ + AND thread_generation = ?4 AND workflow_id = ?5 \ + AND state IN ('running', 'cancel_requested') AND claim_token = ?6", + params![ + fence.account_scope, + fence.organization_id, + fence.thread_id, + fence.generation, + workflow_id, + claim_token, + ], + )?; + if deleted != 1 { + return Ok(RtTurnTerminalOutcome::Stale); + } + + tx.commit()?; + Ok(RtTurnTerminalOutcome::Completed(assistant)) + } + + pub fn rt_cancel_turn_scoped( + &self, + scope: &RtAccountScope, + organization_id: &str, + thread_id: &str, + workflow_id: &str, + ) -> DbResult { + self.adopt_legacy_account_rows(scope, organization_id)?; + let account_scope = scope.storage_key(); + let mut conn = self.lock(); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let Some(fence) = + rt_thread_fence_by_id_in_scope(&tx, &account_scope, organization_id, thread_id)? + else { + tx.commit()?; + return Ok(RtTurnCancelOutcome::NotFound); + }; + let Some(mut item) = rt_turn_queue_by_workflow(&tx, &fence, workflow_id)? else { + tx.commit()?; + return Ok(RtTurnCancelOutcome::NotFound); + }; + match item.state { + RtTurnQueueState::Queued => { + tx.execute( + "DELETE FROM native_scoped_turn_queue \ + WHERE account_scope = ?1 AND organization_id = ?2 AND thread_id = ?3 \ + AND thread_generation = ?4 AND workflow_id = ?5 AND state = 'queued'", + params![ + fence.account_scope, + fence.organization_id, + fence.thread_id, + fence.generation, + workflow_id, + ], + )?; + tx.commit()?; + Ok(RtTurnCancelOutcome::QueuedDeleted(item)) + } + RtTurnQueueState::Running | RtTurnQueueState::CancelRequested => { + tx.execute( + "UPDATE native_scoped_turn_queue SET state = 'cancel_requested' \ + WHERE account_scope = ?1 AND organization_id = ?2 AND thread_id = ?3 \ + AND thread_generation = ?4 AND workflow_id = ?5 \ + AND state IN ('running', 'cancel_requested')", + params![ + fence.account_scope, + fence.organization_id, + fence.thread_id, + fence.generation, + workflow_id, + ], + )?; + item.state = RtTurnQueueState::CancelRequested; + tx.commit()?; + Ok(RtTurnCancelOutcome::ActiveCancelRequested(item)) + } + } + } + + #[cfg(test)] + pub fn rt_cancel_turn_in_org( + &self, + organization_id: &str, + thread_id: &str, + workflow_id: &str, + ) -> DbResult { + self.rt_cancel_turn_scoped( + &RtAccountScope::test_default(), + organization_id, + thread_id, + workflow_id, + ) + } + + /// Prevents any queued tail from being promoted while lifecycle deletion + /// or shutdown requests cancellation of the active harness. + pub fn rt_cancel_all_turns_in_org( + &self, + fence: &RtThreadFence, + ) -> DbResult { + let mut conn = self.lock(); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + // Lifecycle deletion needs only durable identities. Do not parse the + // queued JSON here: one malformed accepted tail must not prevent a + // safe close, and every queued row remains until the final FK cascade. + let mut stmt = tx.prepare( + "SELECT workflow_id, state FROM native_scoped_turn_queue \ + WHERE account_scope = ?1 AND organization_id = ?2 AND thread_id = ?3 \ + AND thread_generation = ?4 \ + ORDER BY fifo_ordinal ASC", + )?; + let rows = stmt.query_map( + params![ + fence.account_scope, + fence.organization_id, + fence.thread_id, + fence.generation, + ], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + )?; + let mut queued_retained_workflow_ids = Vec::new(); + let mut active_workflow_ids = Vec::new(); + for row in rows { + let (workflow_id, state) = row?; + match state.as_str() { + "queued" => queued_retained_workflow_ids.push(workflow_id), + "running" | "cancel_requested" => active_workflow_ids.push(workflow_id), + _ => {} + } + } + drop(stmt); + tx.execute( + "UPDATE native_scoped_turn_queue SET state = 'cancel_requested' \ + WHERE account_scope = ?1 AND organization_id = ?2 AND thread_id = ?3 \ + AND thread_generation = ?4 AND state = 'running'", + params![ + fence.account_scope, + fence.organization_id, + fence.thread_id, + fence.generation, + ], + )?; + tx.commit()?; + Ok(RtCancelAllTurnsOutcome { + queued_retained_workflow_ids, + active_workflow_ids, + }) + } + + /// Recovery scan that isolates malformed active rows instead of failing on + /// the first corrupt JSON payload. Use + /// [`Self::rt_finalize_malformed_orphan`] for each `Malformed` item; healthy + /// `Ready` items retain the ordinary exact recovery path. + pub fn rt_list_orphaned_active_turns_scoped( + &self, + scope: &RtAccountScope, + ) -> DbResult> { + self.prepare_account_scope(scope)?; + let account_scope = scope.storage_key(); + let mut conn = self.lock(); + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let mut stmt = tx.prepare(&format!( + "SELECT {RT_TURN_QUEUE_COLUMNS} FROM native_scoped_turn_queue \ + WHERE account_scope = ?1 AND state IN ('running', 'cancel_requested') \ + ORDER BY enqueued_at ASC, organization_id ASC, thread_id ASC, fifo_ordinal ASC" + ))?; + let rows = stmt + .query_map(params![account_scope], row_to_raw_rt_turn_queue)? + .collect::, _>>()?; + drop(stmt); + let mut items = Vec::new(); + for mut raw in rows { + if raw.quarantine_reason.is_some() { + let error = match parse_raw_rt_turn_queue(raw.clone()) { + Err(error) => error, + Ok(_) => DbError::InvalidQueueData(format!( + "workflow {} has an ineffective persisted quarantine", + raw.workflow_id + )), + }; + items.push(RtOrphanedTurn::Malformed(raw.malformed_orphan(error))); + continue; + } + + let parsed = parse_raw_rt_turn_queue(raw.clone()); + let canonical_user = + parse_canonical_queued_user(&raw.message_id, &raw.user_message_json); + let assistant_id = raw + .assistant_message_id + .clone() + .unwrap_or_else(|| legacy_native_assistant_message_id(&raw.message_id)); + let reservation_state = validate_raw_turn_reservations( + &tx, + &raw, + &assistant_id, + canonical_user.as_ref().ok(), + )?; + let (error, preserve_canonical_user) = match reservation_state { + RawTurnReservationState::Quarantine(error) => (Some(error), false), + RawTurnReservationState::Completed => { + adopt_completed_raw_turn(&tx, &raw)?; + continue; + } + RawTurnReservationState::Safe => match parsed.as_ref() { + Ok(_) => (None, false), + Err(error) => ( + Some(DbError::InvalidQueueData(error.to_string())), + canonical_user.is_ok(), + ), + }, + }; + if let Some(error) = error { + let reason = error.to_string(); + let changed = tx.execute( + "UPDATE native_scoped_turn_queue SET quarantine_reason = ?1, \ + quarantine_preserve_user = ?2 \ + WHERE account_scope = ?3 AND organization_id = ?4 AND thread_id = ?5 \ + AND thread_generation = ?6 AND workflow_id = ?7 \ + AND state IN ('running', 'cancel_requested') AND claim_token IS ?8", + params![ + reason, + preserve_canonical_user, + raw.account_scope, + raw.organization_id, + raw.thread_id, + raw.thread_generation, + raw.workflow_id, + raw.claim_token, + ], + )?; + if changed != 1 { + return Err(DbError::InvalidQueueData(format!( + "could not persist recovery quarantine for workflow {}", + raw.workflow_id + ))); + } + raw.quarantine_reason = Some(reason); + raw.quarantine_preserve_user = preserve_canonical_user; + items.push(RtOrphanedTurn::Malformed(raw.malformed_orphan(error))); + } else if let Ok(item) = parsed { + items.push(RtOrphanedTurn::Ready(item)); + } + } + tx.commit()?; + Ok(items) + } + + #[cfg(test)] + pub fn rt_list_orphaned_active_turns_isolated(&self) -> DbResult> { + self.rt_list_orphaned_active_turns_scoped(&RtAccountScope::test_default()) + } + + /// Enumerates thread incarnations with untouched queued work. The first + /// item of each may be claimed after any orphan active row for that thread + /// has been finalized. + pub fn rt_list_recoverable_turn_queues_scoped( + &self, + scope: &RtAccountScope, + ) -> DbResult> { + self.prepare_account_scope(scope)?; + let account_scope = scope.storage_key(); + let conn = self.lock(); + let mut stmt = conn.prepare( + "SELECT q.account_scope, q.organization_id, q.thread_id, q.thread_generation \ + FROM native_scoped_turn_queue q \ + JOIN native_scoped_threads t \ + ON t.account_scope = q.account_scope \ + AND t.organization_id = q.organization_id \ + AND t.id = q.thread_id \ + AND t.generation = q.thread_generation \ + WHERE q.account_scope = ?1 AND q.state = 'queued' AND t.delete_pending = 0 \ + GROUP BY q.account_scope, q.organization_id, q.thread_id, q.thread_generation \ + ORDER BY MIN(q.enqueued_at) ASC, q.organization_id ASC, q.thread_id ASC, \ + q.thread_generation ASC", + )?; + let rows = stmt.query_map(params![account_scope], |row| { + Ok(RtThreadFence { + account_scope: row.get(0)?, + organization_id: row.get(1)?, + thread_id: row.get(2)?, + generation: row.get(3)?, + }) + })?; + let mut fences = Vec::new(); + for row in rows { + fences.push(row?); + } + Ok(fences) + } + + #[cfg(test)] + pub fn rt_list_recoverable_turn_queues(&self) -> DbResult> { + self.rt_list_recoverable_turn_queues_scoped(&RtAccountScope::test_default()) + } +} + +const RT_THREAD_COLUMNS: &str = "id, organization_id, title, description, hidden, status, \ + created_by, updated_by, virtual_mcp_id, trigger_id, branch, sandbox_provider_kind, \ + harness_id, metadata, run_config, created_at, updated_at, updated_by_explicit"; + +fn parse_stored_json(raw: String, column: usize) -> rusqlite::Result { + serde_json::from_str(&raw).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + column, + rusqlite::types::Type::Text, + Box::new(error), + ) + }) +} + +fn parse_optional_stored_json( + raw: Option, + column: usize, +) -> rusqlite::Result> { + raw.map(|raw| parse_stored_json(raw, column)).transpose() +} + +fn row_to_rt_thread(row: &rusqlite::Row) -> rusqlite::Result { + let metadata_raw: Option = row.get(13)?; + let run_config_raw: Option = row.get(14)?; + let updated_by_raw: String = row.get(7)?; + let updated_by_explicit = row.get::<_, i64>(17)? != 0; + Ok(RtThread { + id: row.get(0)?, + organization_id: row.get(1)?, + title: row.get(2)?, + description: row.get(3)?, + hidden: row.get::<_, i64>(4)? != 0, + status: row.get(5)?, + created_by: row.get(6)?, + updated_by: updated_by_explicit.then_some(updated_by_raw), + virtual_mcp_id: row.get(8)?, + trigger_id: row.get(9)?, + branch: row.get(10)?, + sandbox_provider_kind: row.get(11)?, + harness_id: row.get(12)?, + metadata: parse_optional_stored_json(metadata_raw, 13)?, + run_config: parse_optional_stored_json(run_config_raw, 14)?, + created_at: row.get(15)?, + updated_at: row.get(16)?, + }) +} + +fn row_to_rt_message(row: &rusqlite::Row) -> rusqlite::Result { + let parts_raw: String = row.get(3)?; + let metadata_raw: Option = row.get(4)?; + Ok(RtMessage { + id: row.get(0)?, + thread_id: row.get(1)?, + role: row.get(2)?, + parts: parse_stored_json(parts_raw, 3)?, + metadata: parse_optional_stored_json(metadata_raw, 4)?, + // Column 7: durable `seq` (appended last by message SELECTs). + seq: row.get(7)?, + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) +} + +/// Decodes and exact-inserts the canonical user message stored in a durable +/// queue row. Both begin and terminal completion use this helper so the +/// cancellation-before-begin path cannot silently omit the accepted user. +fn rt_insert_canonical_queued_user( + tx: &rusqlite::Transaction<'_>, + thread_id: &str, + message_id: &str, + user_message_json: &str, + timestamp: &str, +) -> DbResult { + let user_message = parse_canonical_queued_user(message_id, user_message_json)?; + let user_parts = user_message + .get("parts") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + rt_insert_exact_message( + tx, + thread_id, + message_id, + "user", + &user_parts, + user_message.get("metadata"), + timestamp, + ) +} + +fn assistant_parts_with_checkpoint( + assistant_parts: &Value, + checkpoint: Option<&(String, String)>, +) -> DbResult { + let Some((checkpoint_harness_id, checkpoint_session_id)) = checkpoint else { + return Ok(assistant_parts.clone()); + }; + let mut parts = assistant_parts.as_array().cloned().ok_or_else(|| { + DbError::InvalidQueueData( + "assistant parts must be an array when a harness session is checkpointed".to_string(), + ) + })?; + let mut matching_checkpoint_found = false; + for part in &parts { + if part.get("type").and_then(Value::as_str) != Some("data-harness-session") { + continue; + } + let persisted = validated_checkpoint_pair( + part.get("harnessId").and_then(Value::as_str), + part.get("sessionId").and_then(Value::as_str), + )? + .ok_or_else(|| { + DbError::InvalidQueueData( + "assistant harness session part does not contain a session pair".to_string(), + ) + })?; + if persisted.0 != *checkpoint_harness_id || persisted.1 != *checkpoint_session_id { + return Err(DbError::InvalidQueueData( + "assistant harness session conflicts with the durable queue checkpoint".to_string(), + )); + } + matching_checkpoint_found = true; + } + if !matching_checkpoint_found { + parts.push(serde_json::json!({ + "type": "data-harness-session", + "harnessId": checkpoint_harness_id, + "sessionId": checkpoint_session_id, + })); + } + Ok(Value::Array(parts)) +} + +/// Inserts a message inside a caller-owned transaction, or validates that an +/// existing row under the deterministic id is semantically identical. Unlike +/// the general read mapper above, this parses persisted JSON strictly: corrupt +/// storage must abort a begin/terminal transaction, never masquerade as +/// `null`/missing metadata and get accepted as an idempotent match. +#[allow(clippy::too_many_arguments)] +fn rt_insert_exact_message( + tx: &rusqlite::Transaction<'_>, + thread_id: &str, + id: &str, + role: &str, + parts: &Value, + metadata: Option<&Value>, + timestamp: &str, +) -> DbResult { + let parts_json = serde_json::to_string(parts)?; + let metadata_json = metadata.map(serde_json::to_string).transpose()?; + let inserted = tx.execute( + "INSERT OR IGNORE INTO native_scoped_messages \ + (id, thread_id, role, parts, metadata, seq, created_at, updated_at) \ + VALUES (\ + ?1, ?2, ?3, ?4, ?5, \ + (SELECT COALESCE(MAX(seq), 0) + 1 FROM native_scoped_messages WHERE thread_id = ?2), \ + ?6, ?6\ + )", + params![id, thread_id, role, parts_json, metadata_json, timestamp], + )?; + let ( + persisted_id, + persisted_thread_id, + persisted_role, + persisted_parts_json, + persisted_metadata_json, + persisted_created_at, + persisted_updated_at, + persisted_seq, + ): ( + String, + String, + String, + String, + Option, + String, + String, + i64, + ) = tx.query_row( + "SELECT id, thread_id, role, parts, metadata, created_at, updated_at, seq \ + FROM native_scoped_messages WHERE id = ?1", + params![id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + )) + }, + )?; + let message = RtMessage { + id: persisted_id, + thread_id: persisted_thread_id, + role: persisted_role, + parts: serde_json::from_str(&persisted_parts_json)?, + metadata: persisted_metadata_json + .as_deref() + .map(serde_json::from_str) + .transpose()?, + seq: persisted_seq, + created_at: persisted_created_at, + updated_at: persisted_updated_at, + }; + if inserted == 0 + && (message.thread_id != thread_id + || message.role != role + || message.parts != *parts + || message.metadata.as_ref() != metadata) + { + return Err(DbError::IdempotencyConflict { + entity: "rt_message", + id: id.to_string(), + }); + } + Ok(message) +} + +fn rt_message_by_id(conn: &Connection, id: &str) -> DbResult> { + conn.query_row( + "SELECT id, thread_id, role, parts, metadata, created_at, updated_at, seq \ + FROM native_scoped_messages WHERE id = ?1", + params![id], + row_to_rt_message, + ) + .optional() + .map_err(DbError::from) +} + +#[cfg(test)] +fn rt_thread_by_id(conn: &Connection, id: &str) -> DbResult> { + conn.query_row( + &format!("SELECT {RT_THREAD_COLUMNS} FROM native_scoped_threads WHERE id = ?1"), + params![id], + row_to_rt_thread, + ) + .optional() + .map_err(DbError::from) +} + +fn rt_thread_is_tombstoned( + conn: &Connection, + account_scope: &str, + organization_id: &str, + id: &str, +) -> DbResult { + conn.query_row( + "SELECT EXISTS(\ + SELECT 1 FROM native_scoped_thread_tombstones \ + WHERE account_scope = ?1 AND organization_id = ?2 AND thread_id = ?3\ + )", + params![account_scope, organization_id, id], + |row| row.get(0), + ) + .map_err(DbError::from) +} + +fn rt_thread_by_id_in_scope( + conn: &Connection, + account_scope: &str, + organization_id: &str, + id: &str, +) -> DbResult> { + conn.query_row( + &format!( + "SELECT {RT_THREAD_COLUMNS} FROM native_scoped_threads \ + WHERE id = ?1 AND organization_id = ?2 AND account_scope = ?3" + ), + params![id, organization_id, account_scope], + row_to_rt_thread, + ) + .optional() + .map_err(DbError::from) +} + +fn rt_thread_fence_by_id_in_scope( + conn: &Connection, + account_scope: &str, + organization_id: &str, + id: &str, +) -> DbResult> { + conn.query_row( + "SELECT account_scope, organization_id, id, generation FROM native_scoped_threads \ + WHERE id = ?1 AND organization_id = ?2 AND account_scope = ?3", + params![id, organization_id, account_scope], + |row| { + Ok(RtThreadFence { + account_scope: row.get(0)?, + organization_id: row.get(1)?, + thread_id: row.get(2)?, + generation: row.get(3)?, + }) + }, + ) + .optional() + .map_err(DbError::from) +} + +const RT_TURN_QUEUE_COLUMNS: &str = + "account_scope, organization_id, thread_id, thread_generation, \ + message_id, assistant_message_id, workflow_id, task_id, normalized_input_json, \ + user_message_json, enqueued_at, fifo_ordinal, state, claim_token, quarantine_reason, \ + quarantine_preserve_user, checkpoint_harness_id, checkpoint_session_id"; + +#[derive(Clone)] +struct RawRtTurnQueueItem { + account_scope: String, + organization_id: String, + thread_id: String, + thread_generation: String, + message_id: String, + assistant_message_id: Option, + workflow_id: String, + task_id: String, + normalized_input_json: String, + user_message_json: String, + enqueued_at: i64, + fifo_ordinal: i64, + state: String, + claim_token: Option, + quarantine_reason: Option, + quarantine_preserve_user: bool, + checkpoint_harness_id: Option, + checkpoint_session_id: Option, +} + +impl RawRtTurnQueueItem { + fn malformed_orphan(self, error: DbError) -> RtMalformedOrphanedTurn { + let canonical_user = self + .quarantine_preserve_user + .then(|| parse_canonical_queued_user(&self.message_id, &self.user_message_json)) + .and_then(Result::ok); + RtMalformedOrphanedTurn { + fence: RtThreadFence { + account_scope: self.account_scope, + organization_id: self.organization_id, + thread_id: self.thread_id, + generation: self.thread_generation, + }, + message_id: self.message_id, + assistant_message_id: self.assistant_message_id, + workflow_id: self.workflow_id, + claim_token: self.claim_token, + canonical_user, + error: error.to_string(), + } + } +} + +fn row_to_raw_rt_turn_queue(row: &rusqlite::Row) -> rusqlite::Result { + Ok(RawRtTurnQueueItem { + account_scope: row.get(0)?, + organization_id: row.get(1)?, + thread_id: row.get(2)?, + thread_generation: row.get(3)?, + message_id: row.get(4)?, + assistant_message_id: row.get(5)?, + workflow_id: row.get(6)?, + task_id: row.get(7)?, + normalized_input_json: row.get(8)?, + user_message_json: row.get(9)?, + enqueued_at: row.get(10)?, + fifo_ordinal: row.get(11)?, + state: row.get(12)?, + claim_token: row.get(13)?, + quarantine_reason: row.get(14)?, + quarantine_preserve_user: row.get::<_, i64>(15)? != 0, + checkpoint_harness_id: row.get(16)?, + checkpoint_session_id: row.get(17)?, + }) +} + +fn validated_checkpoint_pair( + harness_id: Option<&str>, + session_id: Option<&str>, +) -> DbResult> { + match (harness_id, session_id) { + (None, None) => Ok(None), + (Some(harness_id), Some(session_id)) => { + let harness_id = harness_id.trim(); + let session_id = session_id.trim(); + if harness_id.is_empty() || session_id.is_empty() { + return Err(DbError::InvalidQueueData( + "harness session checkpoint contains an empty value".to_string(), + )); + } + Ok(Some((harness_id.to_string(), session_id.to_string()))) + } + _ => Err(DbError::InvalidQueueData( + "harness session checkpoint is not a complete pair".to_string(), + )), + } +} + +fn parse_canonical_queued_user(message_id: &str, user_message_json: &str) -> DbResult { + let user_message: Value = serde_json::from_str(user_message_json)?; + if user_message.get("id").and_then(Value::as_str) != Some(message_id) + || user_message.get("role").and_then(Value::as_str) != Some("user") + { + return Err(DbError::InvalidQueueData(format!( + "queue message {message_id} has an invalid durable user message" + ))); + } + Ok(user_message) +} + +enum RawTurnReservationState { + Safe, + Completed, + Quarantine(DbError), +} + +/// A durable pair is complete only when both identities belong to this thread, +/// carry their canonical roles, and the user was allocated before its reply. +/// Enqueue retry, claim, and recovery must share this predicate: weakening any +/// one path could adopt a reversed transcript and suppress the harness without +/// ever repairing message order. +fn is_exact_completed_turn_pair( + user: Option<&RtMessage>, + assistant: &RtMessage, + thread_id: &str, +) -> bool { + user.is_some_and(|user| { + user.thread_id == thread_id + && user.role == "user" + && assistant.thread_id == thread_id + && assistant.role == "assistant" + && user.seq < assistant.seq + }) +} + +fn validate_raw_turn_reservations( + conn: &Connection, + raw: &RawRtTurnQueueItem, + candidate_assistant_id: &str, + canonical_user: Option<&Value>, +) -> DbResult { + let fence = RtThreadFence { + account_scope: raw.account_scope.clone(), + organization_id: raw.organization_id.clone(), + thread_id: raw.thread_id.clone(), + generation: raw.thread_generation.clone(), + }; + let expected_v1 = native_assistant_message_id(&fence, &raw.message_id); + let expected_legacy = legacy_native_assistant_message_id(&raw.message_id); + if candidate_assistant_id != expected_v1 && candidate_assistant_id != expected_legacy { + return Ok(RawTurnReservationState::Quarantine( + DbError::InvalidQueueData(format!( + "workflow {} has an invalid assistant reservation", + raw.workflow_id + )), + )); + } + let reservation_winner: (String, String, String, String, String) = conn.query_row( + "SELECT account_scope, organization_id, thread_id, thread_generation, workflow_id \ + FROM native_scoped_turn_queue \ + WHERE message_id IN (?1, ?2) \ + OR COALESCE(assistant_message_id, 'msg-' || message_id || '-assistant') \ + IN (?1, ?2) \ + ORDER BY enqueued_at ASC, account_scope ASC, organization_id ASC, thread_id ASC, \ + thread_generation ASC, fifo_ordinal ASC, workflow_id ASC \ + LIMIT 1", + params![raw.message_id, candidate_assistant_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + )?; + if reservation_winner + != ( + raw.account_scope.clone(), + raw.organization_id.clone(), + raw.thread_id.clone(), + raw.thread_generation.clone(), + raw.workflow_id.clone(), + ) + { + return Ok(RawTurnReservationState::Quarantine( + DbError::InvalidQueueData(format!( + "workflow {} loses a pre-v8 global message-id reservation collision", + raw.workflow_id + )), + )); + } + + let Some(user_message) = canonical_user else { + return Ok(RawTurnReservationState::Safe); + }; + let user_parts = user_message + .get("parts") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + let user_metadata = user_message.get("metadata"); + let exact_persisted_user = match rt_message_by_id(conn, &raw.message_id)? { + Some(user) + if user.thread_id == raw.thread_id + && user.role == "user" + && user.parts == user_parts + && user.metadata.as_ref() == user_metadata => + { + Some(user) + } + Some(_) => { + return Ok(RawTurnReservationState::Quarantine( + DbError::InvalidQueueData(format!( + "workflow {} collides with a persisted global user message id", + raw.workflow_id + )), + )); + } + None => None, + }; + match rt_message_by_id(conn, candidate_assistant_id)? { + Some(assistant) + if is_exact_completed_turn_pair( + exact_persisted_user.as_ref(), + &assistant, + &raw.thread_id, + ) => + { + Ok(RawTurnReservationState::Completed) + } + Some(assistant) + if assistant.thread_id == raw.thread_id + && assistant.role == "assistant" + && exact_persisted_user.is_some() => + { + Ok(RawTurnReservationState::Quarantine( + DbError::InvalidQueueData(format!( + "workflow {} has a persisted assistant before its exact user", + raw.workflow_id + )), + )) + } + Some(_) if exact_persisted_user.is_none() => Ok(RawTurnReservationState::Quarantine( + DbError::InvalidQueueData(format!( + "workflow {} has a persisted assistant without its exact user", + raw.workflow_id + )), + )), + Some(_) => Ok(RawTurnReservationState::Quarantine( + DbError::InvalidQueueData(format!( + "workflow {} collides with a persisted global assistant message id", + raw.workflow_id + )), + )), + None => Ok(RawTurnReservationState::Safe), + } +} + +fn adopt_completed_raw_turn( + tx: &rusqlite::Transaction<'_>, + raw: &RawRtTurnQueueItem, +) -> DbResult<()> { + let ts = now_rfc3339(); + let status_changed = tx.execute( + "UPDATE native_scoped_threads SET \ + status = CASE \ + WHEN status IN ('completed', 'requires_action', 'failed') THEN status \ + ELSE 'failed' \ + END, \ + updated_at = ?1 \ + WHERE account_scope = ?2 AND organization_id = ?3 AND id = ?4 \ + AND generation = ?5", + params![ + ts, + raw.account_scope, + raw.organization_id, + raw.thread_id, + raw.thread_generation, + ], + )?; + let deleted = tx.execute( + "DELETE FROM native_scoped_turn_queue \ + WHERE account_scope = ?1 AND organization_id = ?2 AND thread_id = ?3 \ + AND thread_generation = ?4 AND workflow_id = ?5 \ + AND state IN ('running', 'cancel_requested') AND claim_token IS ?6", + params![ + raw.account_scope, + raw.organization_id, + raw.thread_id, + raw.thread_generation, + raw.workflow_id, + raw.claim_token, + ], + )?; + if status_changed != 1 || deleted != 1 { + return Err(DbError::InvalidQueueData(format!( + "could not adopt completed workflow {}", + raw.workflow_id + ))); + } + Ok(()) +} + +fn parse_raw_rt_turn_queue(raw: RawRtTurnQueueItem) -> DbResult { + if let Some(reason) = &raw.quarantine_reason { + return Err(DbError::InvalidQueueData(format!( + "workflow {} was quarantined at claim: {reason}", + raw.workflow_id + ))); + } + let enqueued_at = u64::try_from(raw.enqueued_at).map_err(|_| { + DbError::InvalidQueueData(format!( + "negative enqueued_at {} for workflow {}", + raw.enqueued_at, raw.workflow_id + )) + })?; + let state = RtTurnQueueState::parse(&raw.state)?; + let normalized_input = serde_json::from_str(&raw.normalized_input_json)?; + let user_message = parse_canonical_queued_user(&raw.message_id, &raw.user_message_json)?; + let checkpoint_session = validated_checkpoint_pair( + raw.checkpoint_harness_id.as_deref(), + raw.checkpoint_session_id.as_deref(), + )?; + Ok(RtTurnQueueItem { + fence: RtThreadFence { + account_scope: raw.account_scope, + organization_id: raw.organization_id, + thread_id: raw.thread_id, + generation: raw.thread_generation, + }, + message_id: raw.message_id, + assistant_message_id: raw.assistant_message_id, + workflow_id: raw.workflow_id, + task_id: raw.task_id, + normalized_input, + user_message, + enqueued_at, + fifo_ordinal: raw.fifo_ordinal, + state, + claim_token: raw.claim_token, + checkpoint_session, + }) +} + +fn rt_turn_queue_query( + conn: &Connection, + where_clause: &str, + params: P, +) -> DbResult> { + let sql = format!( + "SELECT {RT_TURN_QUEUE_COLUMNS} FROM native_scoped_turn_queue \ + WHERE {where_clause} ORDER BY fifo_ordinal ASC" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params, row_to_raw_rt_turn_queue)?; + let mut items = Vec::new(); + for row in rows { + items.push(parse_raw_rt_turn_queue(row?)?); + } + Ok(items) +} + +fn rt_turn_queue_by_workflow( + conn: &Connection, + fence: &RtThreadFence, + workflow_id: &str, +) -> DbResult> { + let mut items = rt_turn_queue_query( + conn, + "account_scope = ?1 AND organization_id = ?2 AND thread_id = ?3 \ + AND thread_generation = ?4 AND workflow_id = ?5", + params![ + fence.account_scope, + fence.organization_id, + fence.thread_id, + fence.generation, + workflow_id, + ], + )?; + Ok(items.pop()) +} + +/// Escapes `%`/`_`/`\` for a `LIKE ... ESCAPE '\'` pattern — `search` is +/// caller/user-supplied free text, and without this a search containing a +/// literal `%` or `_` would silently behave as a wildcard instead of a +/// literal character match. +fn like_escape(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") +} + +#[cfg(test)] +mod tests { + use super::*; + + type TableShapeRow = (i64, String, String, i64, Option, i64); + type ForeignKeyShapeRow = (i64, i64, String, String, String, String, String, String); + + fn local_db_path(app_root: &Path) -> PathBuf { + app_root.join(".decocms/local.db") + } + + fn schema_version(path: &Path) -> u32 { + let conn = Connection::open(path).unwrap(); + conn.pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap() + } + + fn table_shape(conn: &Connection, table: &str) -> Vec { + let mut statement = conn + .prepare( + "SELECT cid, name, type, `notnull`, dflt_value, pk \ + FROM pragma_table_info(?1) ORDER BY cid", + ) + .unwrap(); + statement + .query_map([table], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }) + .unwrap() + .map(Result::unwrap) + .collect() + } + + fn foreign_key_shape(conn: &Connection, table: &str) -> Vec { + let mut statement = conn + .prepare( + "SELECT id, seq, `table`, `from`, `to`, on_update, on_delete, `match` \ + FROM pragma_foreign_key_list(?1) ORDER BY id, seq", + ) + .unwrap(); + statement + .query_map([table], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + )) + }) + .unwrap() + .map(Result::unwrap) + .collect() + } + + fn index_shape(conn: &Connection, table: &str) -> Vec<(String, i64, String, i64)> { + let mut statement = conn + .prepare( + "SELECT name, `unique`, origin, partial \ + FROM pragma_index_list(?1) ORDER BY name", + ) + .unwrap(); + statement + .query_map([table], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) + .unwrap() + .map(Result::unwrap) + .collect() + } + + fn create_rt_thread(db: &ThreadsDb, organization_id: &str, thread_id: &str) { + db.rt_create_thread( + Some(thread_id), + organization_id, + "", + None, + "vmcp", + None, + "user", + ) + .unwrap(); + } + + fn turn_input(message_id: &str, text: &str, enqueued_at: u64) -> RtTurnEnqueueInput { + RtTurnEnqueueInput { + message_id: message_id.to_string(), + workflow_id: format!("workflow-{message_id}"), + task_id: "thread-task".to_string(), + normalized_input: serde_json::json!({ + "harnessId": "claude-code", + "messages": [{ + "id": message_id, + "role": "user", + "parts": [{"type": "text", "text": text}], + }], + }), + user_message: serde_json::json!({ + "id": message_id, + "role": "user", + "parts": [{"type": "text", "text": text}], + }), + enqueued_at, + } + } + + fn ready_claim(outcome: RtTurnClaimOutcome) -> RtTurnQueueItem { + match outcome { + RtTurnClaimOutcome::Ready(item) => item, + RtTurnClaimOutcome::Malformed(item) => { + panic!("expected healthy claim, got malformed: {item:?}") + } + RtTurnClaimOutcome::Completed { workflow_id } => { + panic!("expected executable claim, got completed {workflow_id}") + } + } + } + + fn claimed_assistant_id(item: &RtTurnQueueItem) -> String { + item.assistant_message_id + .clone() + .unwrap_or_else(|| legacy_native_assistant_message_id(&item.message_id)) + } + + /// Creates the exact unversioned schema deployed before this migration + /// runner, with timestamps deliberately opposed to insertion order. + fn create_legacy_v0_fixture(app_root: &Path) { + let dir = app_root.join(".decocms"); + fs::create_dir_all(&dir).unwrap(); + let conn = Connection::open(local_db_path(app_root)).unwrap(); + conn.pragma_update(None, "foreign_keys", 1).unwrap(); + conn.execute_batch(MIGRATIONS[0].sql).unwrap(); + conn.execute( + "INSERT INTO threads (id, title, created_at, updated_at) \ + VALUES ('legacy-mini-thread', 'also kept', ?1, ?1)", + ["2025-01-01T00:00:00.000Z"], + ) + .unwrap(); + conn.execute( + "INSERT INTO rt_threads (\ + id, organization_id, title, hidden, status, created_by, updated_by, \ + virtual_mcp_id, created_at, updated_at\ + ) VALUES ('legacy-thread', 'org', 'kept', 0, 'idle', 'user', 'user', \ + 'vmcp', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO rt_messages (\ + id, thread_id, role, parts, metadata, created_at, updated_at\ + ) VALUES (?1, 'legacy-thread', ?2, ?3, ?4, ?5, ?5)", + params![ + "legacy-user", + "user", + r#"[{"type":"text","text":"preserved"}]"#, + r#"{"source":"fixture"}"#, + "2025-01-02T00:00:00.000Z" + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO rt_messages (\ + id, thread_id, role, parts, metadata, created_at, updated_at\ + ) VALUES (?1, 'legacy-thread', ?2, ?3, NULL, ?4, ?4)", + params![ + "legacy-assistant", + "assistant", + r#"[{"type":"text","text":"also preserved"}]"#, + // Older wall clock than the row inserted above: migration + // and query order must follow legacy rowid, not this clock. + "2025-01-01T00:00:00.000Z" + ], + ) + .unwrap(); + assert_eq!( + conn.pragma_query_value::(None, "user_version", |row| row.get(0)) + .unwrap(), + 0 + ); + } + + /// Exact schema shape observed in a shipped native schema-v3 database. + /// It intentionally does not replay today's `MIGRATIONS[0..=2]`: the + /// historical v3 release had the generation/queue indexes and FKs but no + /// generation triggers, and its queue table predates the later CHECK + /// constraints now present in the reconstructed migration text. + fn create_shipped_v3_fixture(app_root: &Path) { + let dir = app_root.join(".decocms"); + fs::create_dir_all(&dir).unwrap(); + let conn = Connection::open(local_db_path(app_root)).unwrap(); + conn.pragma_update(None, "foreign_keys", 1).unwrap(); + conn.execute_batch( + r#" +CREATE TABLE threads ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE messages ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + role TEXT NOT NULL, + parts TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX idx_messages_thread_created ON messages(thread_id, created_at); +CREATE TABLE runs ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + harness_id TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + ended_at TEXT, + error TEXT +); +CREATE INDEX idx_runs_thread_created ON runs(thread_id, created_at); + +CREATE TABLE rt_threads ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + hidden INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + created_by TEXT NOT NULL, + updated_by TEXT NOT NULL, + virtual_mcp_id TEXT NOT NULL, + trigger_id TEXT, + branch TEXT, + sandbox_provider_kind TEXT, + harness_id TEXT, + metadata TEXT, + run_config TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + generation TEXT NOT NULL DEFAULT '' +); +CREATE INDEX idx_rt_threads_org_updated ON rt_threads(organization_id, updated_at); +CREATE UNIQUE INDEX idx_rt_threads_org_id_generation +ON rt_threads(organization_id, id, generation); +CREATE TABLE rt_messages ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES rt_threads(id) ON DELETE CASCADE, + role TEXT NOT NULL, + parts TEXT NOT NULL, + metadata TEXT, + seq INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX idx_rt_messages_thread_created ON rt_messages(thread_id, created_at); +CREATE UNIQUE INDEX idx_rt_messages_thread_seq ON rt_messages(thread_id, seq); +CREATE TABLE rt_turn_queue ( + organization_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + thread_generation TEXT NOT NULL, + message_id TEXT NOT NULL, + workflow_id TEXT NOT NULL, + task_id TEXT NOT NULL, + normalized_input_json TEXT NOT NULL, + user_message_json TEXT NOT NULL, + enqueued_at INTEGER NOT NULL, + fifo_ordinal INTEGER NOT NULL, + state TEXT NOT NULL CHECK (state IN ('queued', 'running', 'cancel_requested')), + claim_token TEXT, + PRIMARY KEY (organization_id, thread_id, thread_generation, workflow_id), + UNIQUE (organization_id, thread_id, thread_generation, message_id), + UNIQUE (organization_id, thread_id, thread_generation, fifo_ordinal), + FOREIGN KEY (organization_id, thread_id, thread_generation) + REFERENCES rt_threads(organization_id, id, generation) + ON DELETE CASCADE +); +CREATE INDEX idx_rt_turn_queue_fifo +ON rt_turn_queue(organization_id, thread_id, thread_generation, fifo_ordinal); +CREATE INDEX idx_rt_turn_queue_recovery +ON rt_turn_queue(state, organization_id, thread_id, thread_generation, fifo_ordinal); +"#, + ) + .unwrap(); + conn.execute( + "INSERT INTO threads (id, title, created_at, updated_at) \ + VALUES ('mini-v3', 'mini preserved', ?1, ?1)", + ["2025-01-01T00:00:00.000Z"], + ) + .unwrap(); + conn.execute( + "INSERT INTO messages (id, thread_id, role, parts, created_at) \ + VALUES ('mini-v3-message', 'mini-v3', 'user', '[]', ?1)", + ["2025-01-01T00:00:00.000Z"], + ) + .unwrap(); + conn.execute( + "INSERT INTO runs (id, thread_id, harness_id, status, created_at) \ + VALUES ('mini-v3-run', 'mini-v3', 'claude-code', 'completed', ?1)", + ["2025-01-01T00:00:00.000Z"], + ) + .unwrap(); + conn.execute( + "INSERT INTO rt_threads (\ + id, organization_id, title, hidden, status, created_by, updated_by, \ + virtual_mcp_id, created_at, updated_at, generation\ + ) VALUES ('shipped-v3-thread', 'shipped-v3-org', 'preserved', 0, 'completed', \ + 'shipped-v3-user', 'shipped-v3-user', 'vmcp', ?1, ?1, \ + 'shipped-v3-generation')", + ["2025-01-02T00:00:00.000Z"], + ) + .unwrap(); + // Preserve an already-ambiguous historical transcript too. Shipped v3 + // could execute two accepted turns concurrently and therefore persist + // user,user,assistant,assistant. There is no durable reply-to edge in + // that schema, so migration must retain the exact order rather than + // guess which assistant belongs to which user. + for (id, role, seq) in [ + ("shipped-v3-user-message-1", "user", 1_i64), + ("shipped-v3-user-message-2", "user", 2_i64), + ("shipped-v3-assistant-message-1", "assistant", 3_i64), + ("shipped-v3-assistant-message-2", "assistant", 4_i64), + ] { + conn.execute( + "INSERT INTO rt_messages (\ + id, thread_id, role, parts, metadata, seq, created_at, updated_at\ + ) VALUES (?1, 'shipped-v3-thread', ?2, '[]', NULL, ?3, ?4, ?4)", + params![id, role, seq, "2025-01-02T00:00:00.000Z"], + ) + .unwrap(); + } + conn.execute( + "INSERT INTO rt_turn_queue (\ + organization_id, thread_id, thread_generation, message_id, workflow_id, \ + task_id, normalized_input_json, user_message_json, enqueued_at, fifo_ordinal, \ + state, claim_token\ + ) VALUES ('shipped-v3-org', 'shipped-v3-thread', 'shipped-v3-generation', \ + 'shipped-v3-queued', 'shipped-v3-workflow', 'shipped-v3-task', '{}', \ + '{\"id\":\"shipped-v3-queued\",\"role\":\"user\",\"parts\":[]}', \ + 3, 1, 'queued', NULL)", + [], + ) + .unwrap(); + conn.pragma_update(None, "user_version", 3).unwrap(); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'trigger'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0, + "the shipped v3 fixture must not accidentally inherit today's triggers" + ); + validate_foreign_keys(&conn).unwrap(); + } + + fn create_pre_account_scope_fixture(app_root: &Path) { + let dir = app_root.join(".decocms"); + fs::create_dir_all(&dir).unwrap(); + let conn = Connection::open(local_db_path(app_root)).unwrap(); + conn.pragma_update(None, "foreign_keys", 1).unwrap(); + for migration in MIGRATIONS.iter().filter(|migration| migration.version <= 3) { + conn.execute_batch(migration.sql).unwrap(); + conn.pragma_update(None, "user_version", migration.version) + .unwrap(); + } + for (id, creator, generation) in [ + ("owned-legacy", "user-a", "generation-owned"), + ( + "placeholder-legacy", + "local-desktop-user", + "generation-placeholder", + ), + ] { + conn.execute( + "INSERT INTO rt_threads (\ + id, organization_id, title, hidden, status, created_by, updated_by, \ + virtual_mcp_id, created_at, updated_at, generation\ + ) VALUES (?1, 'shared-org', ?1, 0, 'completed', ?2, ?2, \ + 'vmcp', '2025-01-01T00:00:00.000Z', \ + '2025-01-01T00:00:00.000Z', ?3)", + params![id, creator, generation], + ) + .unwrap(); + } + conn.execute( + "INSERT INTO rt_turn_queue (\ + organization_id, thread_id, thread_generation, message_id, workflow_id, \ + task_id, normalized_input_json, user_message_json, enqueued_at, fifo_ordinal, \ + state, claim_token\ + ) VALUES ('shared-org', 'owned-legacy', 'generation-owned', 'legacy-message', \ + 'legacy-workflow', 'owned-legacy', '{}', \ + '{\"id\":\"legacy-message\",\"role\":\"user\",\"parts\":[]}', \ + 1, 1, 'queued', NULL)", + [], + ) + .unwrap(); + } + + fn create_v4_fixture( + app_root: &Path, + scope: &RtAccountScope, + organization_id: &str, + thread_id: &str, + ) { + let dir = app_root.join(".decocms"); + fs::create_dir_all(&dir).unwrap(); + let conn = Connection::open(local_db_path(app_root)).unwrap(); + conn.pragma_update(None, "foreign_keys", 1).unwrap(); + for migration in MIGRATIONS.iter().filter(|migration| migration.version <= 4) { + conn.execute_batch(migration.sql).unwrap(); + conn.pragma_update(None, "user_version", migration.version) + .unwrap(); + } + conn.execute( + "INSERT INTO rt_threads (\ + id, organization_id, title, hidden, status, created_by, updated_by, \ + virtual_mcp_id, created_at, updated_at, generation, account_scope\ + ) VALUES (?1, ?2, 'v4 thread', 0, 'completed', ?3, ?3, 'vmcp', \ + '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z', \ + 'v4-generation', ?4)", + params![ + thread_id, + organization_id, + scope.user_id, + scope.storage_key() + ], + ) + .unwrap(); + assert_eq!( + conn.pragma_query_value::(None, "user_version", |row| row.get(0)) + .unwrap(), + 4 + ); + } + + fn create_v5_fixture( + app_root: &Path, + scope: &RtAccountScope, + organization_id: &str, + thread_id: &str, + ) { + create_v4_fixture(app_root, scope, organization_id, thread_id); + let conn = Connection::open(local_db_path(app_root)).unwrap(); + conn.pragma_update(None, "foreign_keys", 1).unwrap(); + conn.execute_batch(MIGRATIONS[4].sql).unwrap(); + conn.pragma_update(None, "user_version", 5).unwrap(); + conn.execute( + "INSERT INTO rt_messages (\ + id, thread_id, role, parts, metadata, seq, created_at, updated_at\ + ) VALUES ('v5-user-message', ?1, 'user', ?2, ?3, 7, ?4, ?4)", + params![ + thread_id, + r#"[{"type":"text","text":"preserved by v6"}]"#, + r#"{"source":"v5-fixture"}"#, + "2025-01-02T00:00:00.000Z", + ], + ) + .unwrap(); + for (message_id, workflow_id, fifo_ordinal, state, claim_token) in [ + ( + "v5-running-message", + "v5-running", + 8, + "running", + Some("v5-claim"), + ), + ("v5-queued-message", "v5-queued", 9, "queued", None), + ] { + let user_message = serde_json::json!({ + "id": message_id, + "role": "user", + "parts": [{"type": "text", "text": message_id}], + }); + conn.execute( + "INSERT INTO rt_turn_queue (\ + organization_id, thread_id, thread_generation, message_id, workflow_id, \ + task_id, normalized_input_json, user_message_json, enqueued_at, fifo_ordinal, \ + state, claim_token, account_scope\ + ) VALUES (?1, ?2, 'v4-generation', ?3, ?4, 'v5-task', '{}', ?5, \ + ?6, ?7, ?8, ?9, ?10)", + params![ + organization_id, + thread_id, + message_id, + workflow_id, + user_message.to_string(), + fifo_ordinal, + fifo_ordinal, + state, + claim_token, + scope.storage_key(), + ], + ) + .unwrap(); + } + conn.execute( + "INSERT INTO rt_thread_tombstones (\ + account_scope, organization_id, thread_id, deleted_generation, deleted_at\ + ) VALUES (?1, ?2, 'already-retired', 'retired-generation', ?3)", + params![ + scope.storage_key(), + organization_id, + "2025-01-03T00:00:00.000Z" + ], + ) + .unwrap(); + validate_foreign_keys(&conn).unwrap(); + assert_eq!(schema_version(&local_db_path(app_root)), 5); + } + + fn create_v6_fixture( + app_root: &Path, + scope: &RtAccountScope, + organization_id: &str, + thread_id: &str, + ) { + create_v5_fixture(app_root, scope, organization_id, thread_id); + let conn = Connection::open(local_db_path(app_root)).unwrap(); + conn.pragma_update(None, "foreign_keys", 1).unwrap(); + conn.execute_batch(MIGRATIONS[5].sql).unwrap(); + conn.pragma_update(None, "user_version", 6).unwrap(); + validate_foreign_keys(&conn).unwrap(); + assert_eq!(schema_version(&local_db_path(app_root)), 6); + } + + fn create_v7_fixture( + app_root: &Path, + scope: &RtAccountScope, + organization_id: &str, + thread_id: &str, + ) { + create_v6_fixture(app_root, scope, organization_id, thread_id); + let conn = Connection::open(local_db_path(app_root)).unwrap(); + conn.pragma_update(None, "foreign_keys", 1).unwrap(); + conn.execute_batch(MIGRATIONS[6].sql).unwrap(); + conn.pragma_update(None, "user_version", 7).unwrap(); + validate_foreign_keys(&conn).unwrap(); + assert_eq!(schema_version(&local_db_path(app_root)), 7); + } + + #[test] + fn fresh_database_runs_all_migrations() { + let dir = tempfile::tempdir().unwrap(); + let db = ThreadsDb::open(dir.path()).unwrap(); + assert_eq!( + schema_version(&local_db_path(dir.path())), + CURRENT_SCHEMA_VERSION + ); + let conn = db.lock(); + let seq_not_null: i64 = conn + .query_row( + "SELECT `notnull` FROM pragma_table_info('native_scoped_messages') WHERE name = 'seq'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(seq_not_null, 1); + assert!(index_shape(&conn, "native_scoped_turn_queue").iter().any( + |(name, unique, _, partial)| { + name == "idx_native_scoped_turn_queue_v1_assistant_message_id" + && *unique == 1 + && *partial == 1 + } + )); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('native_scoped_turn_queue') \ + WHERE name IN ('checkpoint_harness_id', 'checkpoint_session_id')", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 2 + ); + } + + #[test] + fn legacy_v0_adoption_preserves_rows_and_backfills_sequence_by_rowid() { + let dir = tempfile::tempdir().unwrap(); + create_legacy_v0_fixture(dir.path()); + + let db = ThreadsDb::open(dir.path()).unwrap(); + assert_eq!( + schema_version(&local_db_path(dir.path())), + CURRENT_SCHEMA_VERSION + ); + assert_eq!( + db.rt_get_thread("legacy-thread").unwrap().unwrap().title, + "kept" + ); + assert_eq!( + db.get_thread("legacy-mini-thread").unwrap().unwrap().title, + "also kept" + ); + let (messages, total) = db.rt_list_messages("legacy-thread", 100, 0, false).unwrap(); + assert_eq!(total, 2); + assert_eq!( + messages + .iter() + .map(|message| message.id.as_str()) + .collect::>(), + ["legacy-user", "legacy-assistant"] + ); + assert_eq!(messages[0].seq, 1); + assert_eq!(messages[1].seq, 2); + assert_eq!(messages[0].parts[0]["text"], "preserved"); + assert_eq!(messages[0].metadata.as_ref().unwrap()["source"], "fixture"); + } + + #[test] + fn migrated_task_seeded_legacy_turn_retries_fail_closed_without_rerun() { + let dir = tempfile::tempdir().unwrap(); + create_legacy_v0_fixture(dir.path()); + + // The shipped pre-migration app generated a fresh server task id for + // every POST and used THAT value for the assistant id. It was unrelated + // to the frontend-owned user-message id, so migration cannot prove this + // pair merely by deriving a name from `legacy-user`. + let legacy_assistant_id = "msg-legacy-server-task-id-assistant"; + let conn = Connection::open(local_db_path(dir.path())).unwrap(); + conn.execute( + "UPDATE rt_messages SET id = ?1 WHERE id = 'legacy-assistant'", + [legacy_assistant_id], + ) + .unwrap(); + drop(conn); + + let db = ThreadsDb::open(dir.path()).unwrap(); + let scope = RtAccountScope::new("test.invalid", "user").unwrap(); + let input = RtTurnEnqueueInput { + message_id: "legacy-user".to_string(), + workflow_id: "retry-workflow".to_string(), + task_id: "retry-task".to_string(), + normalized_input: serde_json::json!({ + "harnessId": "claude-code", + "messages": [{ + "id": "legacy-user", + "role": "user", + "parts": [{"type": "text", "text": "preserved"}], + "metadata": {"source": "fixture"}, + }], + }), + user_message: serde_json::json!({ + "id": "legacy-user", + "role": "user", + "parts": [{"type": "text", "text": "preserved"}], + "metadata": {"source": "fixture"}, + }), + enqueued_at: 1, + }; + + for _ in 0..2 { + assert!(matches!( + db.rt_enqueue_turn_scoped(&scope, "org", "legacy-thread", &input), + Err(DbError::IdempotencyConflict { + entity: "rt_message", + ref id, + }) if id == "legacy-user" + )); + } + + assert!(db + .rt_list_turn_queue_scoped(&scope, "org", "legacy-thread") + .unwrap() + .is_empty()); + let (messages, total) = db + .rt_list_messages_in_scope(&scope, "org", "legacy-thread", 100, 0, false) + .unwrap(); + assert_eq!(total, 2); + assert_eq!( + messages + .iter() + .map(|message| (message.id.as_str(), message.seq)) + .collect::>(), + [("legacy-user", 1), (legacy_assistant_id, 2)] + ); + } + + #[test] + fn version_one_database_migrates_through_sequence_and_queue_versions() { + let dir = tempfile::tempdir().unwrap(); + create_legacy_v0_fixture(dir.path()); + let conn = Connection::open(local_db_path(dir.path())).unwrap(); + conn.pragma_update(None, "user_version", 1).unwrap(); + drop(conn); + + let db = ThreadsDb::open(dir.path()).unwrap(); + assert_eq!( + schema_version(&local_db_path(dir.path())), + CURRENT_SCHEMA_VERSION + ); + let (messages, _) = db.rt_list_messages("legacy-thread", 100, 0, false).unwrap(); + assert_eq!( + messages + .iter() + .map(|message| message.seq) + .collect::>(), + [1, 2] + ); + let legacy_scope = RtAccountScope::new("test.invalid", "user").unwrap(); + assert!(!db + .rt_thread_fence_in_scope(&legacy_scope, "org", "legacy-thread") + .unwrap() + .unwrap() + .generation + .is_empty()); + } + + #[test] + fn version_two_database_gets_thread_generations_and_durable_queue() { + let dir = tempfile::tempdir().unwrap(); + let db_dir = dir.path().join(".decocms"); + fs::create_dir_all(&db_dir).unwrap(); + let path = local_db_path(dir.path()); + let conn = Connection::open(&path).unwrap(); + conn.pragma_update(None, "foreign_keys", 1).unwrap(); + conn.execute_batch(MIGRATIONS[0].sql).unwrap(); + conn.pragma_update(None, "user_version", 1).unwrap(); + conn.execute_batch(MIGRATIONS[1].sql).unwrap(); + conn.pragma_update(None, "user_version", 2).unwrap(); + conn.execute( + "INSERT INTO rt_threads (\ + id, organization_id, title, hidden, status, created_by, updated_by, \ + virtual_mcp_id, created_at, updated_at\ + ) VALUES ('v2-thread', 'org', '', 0, 'completed', 'user', 'user', \ + 'vmcp', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')", + [], + ) + .unwrap(); + drop(conn); + + let db = ThreadsDb::open(dir.path()).unwrap(); + let legacy_scope = RtAccountScope::new("test.invalid", "user").unwrap(); + let fence = db + .rt_thread_fence_in_scope(&legacy_scope, "org", "v2-thread") + .unwrap() + .unwrap(); + assert!(!fence.generation.is_empty()); + assert_eq!(schema_version(&path), CURRENT_SCHEMA_VERSION); + let outcome = db + .rt_enqueue_turn_scoped( + &legacy_scope, + "org", + "v2-thread", + &turn_input("m1", "hello", 1), + ) + .unwrap(); + assert!(matches!(outcome, RtTurnEnqueueOutcome::Inserted(_))); + } + + #[test] + fn shipped_version_three_without_triggers_migrates_to_current_intact() { + let dir = tempfile::tempdir().unwrap(); + let path = local_db_path(dir.path()); + create_shipped_v3_fixture(dir.path()); + assert_eq!(schema_version(&path), 3); + + let db = ThreadsDb::open(dir.path()).unwrap(); + assert_eq!(schema_version(&path), CURRENT_SCHEMA_VERSION); + let conn = db.lock(); + + for (table, expected) in [ + ("threads", 1_i64), + ("messages", 1), + ("runs", 1), + ("native_scoped_threads", 1), + ("native_scoped_messages", 4), + ("native_scoped_turn_queue", 1), + ("native_scoped_thread_tombstones", 0), + ("rt_threads", 0), + ("rt_messages", 0), + ] { + assert_eq!( + conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + expected, + "schema-v3 migration changed row count for {table}" + ); + } + assert_eq!( + conn.query_row( + "SELECT title FROM native_scoped_threads WHERE id = 'shipped-v3-thread'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "preserved" + ); + let preserved_roles = conn + .prepare( + "SELECT role FROM native_scoped_messages \ + WHERE thread_id = 'shipped-v3-thread' ORDER BY seq", + ) + .unwrap() + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!( + preserved_roles, + ["user", "user", "assistant", "assistant"], + "migration preserves ambiguous v3 history instead of inventing reply provenance" + ); + assert_eq!( + conn.query_row( + "SELECT assistant_message_id FROM native_scoped_turn_queue \ + WHERE workflow_id = 'shipped-v3-workflow'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "msg-shipped-v3-queued-assistant" + ); + assert_eq!( + conn.query_row( + "SELECT `table` FROM pragma_foreign_key_list('native_scoped_messages') LIMIT 1", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "native_scoped_threads" + ); + assert_eq!( + conn.query_row( + "SELECT `table` FROM pragma_foreign_key_list('native_scoped_turn_queue') LIMIT 1", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "native_scoped_threads" + ); + validate_foreign_keys(&conn).unwrap(); + assert_eq!( + conn.pragma_query_value::(None, "integrity_check", |row| row.get(0)) + .unwrap(), + "ok" + ); + for trigger in [ + "native_scoped_threads_generation_required_insert", + "native_scoped_threads_generation_immutable", + "native_scoped_threads_account_scope_required_insert", + "native_scoped_turn_queue_account_scope_required_insert", + ] { + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'trigger' AND name = ?1", + [trigger], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 1, + "current migration must create missing historical trigger {trigger}" + ); + } + } + + #[test] + fn version_four_database_gets_durable_tombstones_that_survive_reopen() { + let dir = tempfile::tempdir().unwrap(); + let scope = RtAccountScope::new("studio.decocms.com", "v4-user").unwrap(); + create_v4_fixture(dir.path(), &scope, "v4-org", "retired-v4-thread"); + + { + let db = ThreadsDb::open(dir.path()).unwrap(); + assert_eq!( + schema_version(&local_db_path(dir.path())), + CURRENT_SCHEMA_VERSION + ); + let fence = db + .rt_thread_fence_in_scope(&scope, "v4-org", "retired-v4-thread") + .unwrap() + .unwrap(); + assert!(db.rt_delete_thread_in_org_if_generation(&fence).unwrap()); + } + + let reopened = ThreadsDb::open(dir.path()).unwrap(); + let recreate = reopened.rt_create_thread_scoped( + &scope, + Some("retired-v4-thread"), + "v4-org", + "must not return", + None, + "vmcp", + None, + "v4-user", + ); + assert!(matches!( + recreate, + Err(DbError::RetiredThreadId { + ref organization_id, + ref thread_id, + .. + }) if organization_id == "v4-org" && thread_id == "retired-v4-thread" + )); + assert_eq!( + reopened + .lock() + .query_row( + "SELECT COUNT(*) FROM native_scoped_thread_tombstones", + [], + |row| { row.get::<_, i64>(0) } + ) + .unwrap(), + 1 + ); + } + + #[test] + fn version_five_migration_preserves_all_scoped_data_and_blocks_old_binary_writes() { + let dir = tempfile::tempdir().unwrap(); + let scope = RtAccountScope::new("studio.decocms.com", "v5-user").unwrap(); + create_v5_fixture(dir.path(), &scope, "v5-org", "v5-live-thread"); + + let db = ThreadsDb::open(dir.path()).unwrap(); + assert_eq!( + schema_version(&local_db_path(dir.path())), + CURRENT_SCHEMA_VERSION + ); + assert_eq!( + db.rt_get_thread_in_scope(&scope, "v5-org", "v5-live-thread") + .unwrap() + .unwrap() + .title, + "v4 thread" + ); + let (messages, total) = db + .rt_list_messages_in_scope(&scope, "v5-org", "v5-live-thread", 100, 0, false) + .unwrap(); + assert_eq!(total, 1); + assert_eq!(messages[0].id, "v5-user-message"); + assert_eq!(messages[0].seq, 7); + assert_eq!(messages[0].parts[0]["text"], "preserved by v6"); + assert_eq!( + messages[0].metadata.as_ref().unwrap()["source"], + "v5-fixture" + ); + let queue = db + .rt_list_turn_queue_scoped(&scope, "v5-org", "v5-live-thread") + .unwrap(); + assert_eq!(queue.len(), 2); + assert_eq!(queue[0].workflow_id, "v5-running"); + assert_eq!(queue[0].claim_token.as_deref(), Some("v5-claim")); + assert_eq!(queue[1].workflow_id, "v5-queued"); + assert!(matches!( + db.rt_create_thread_scoped( + &scope, + Some("already-retired"), + "v5-org", + "must stay retired", + None, + "vmcp", + None, + "v5-user", + ), + Err(DbError::RetiredThreadId { .. }) + )); + + let conn = db.lock(); + validate_foreign_keys(&conn).unwrap(); + for barrier in ["rt_threads", "rt_messages"] { + assert_eq!( + conn.query_row(&format!("SELECT COUNT(*) FROM {barrier}"), [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 0, + "downgrade barrier {barrier} must stay empty" + ); + } + for trigger in [ + "rt_threads_downgrade_block_insert", + "rt_threads_downgrade_block_update", + "rt_threads_downgrade_block_delete", + "rt_messages_downgrade_block_insert", + "rt_messages_downgrade_block_update", + "rt_messages_downgrade_block_delete", + "native_scoped_threads_generation_required_insert", + "native_scoped_threads_generation_immutable", + "native_scoped_threads_account_scope_required_insert", + "native_scoped_turn_queue_account_scope_required_insert", + ] { + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'trigger' AND name = ?1", + [trigger], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 1, + "schema v6 must recreate trigger {trigger} on its intended table" + ); + } + for index in [ + "idx_native_scoped_threads_org_updated", + "idx_native_scoped_messages_thread_created", + "idx_native_scoped_messages_thread_seq", + "idx_native_scoped_threads_org_id_generation", + "idx_native_scoped_turn_queue_fifo", + "idx_native_scoped_turn_queue_recovery", + "idx_native_scoped_threads_account_org_updated", + "idx_native_scoped_turn_queue_account_recovery", + "idx_rt_threads_org_updated", + "idx_rt_messages_thread_created", + ] { + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'index' AND name = ?1", + [index], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 1, + "schema v6 must preserve or recreate index {index}" + ); + } + let message_parent: String = conn + .query_row( + "SELECT `table` FROM pragma_foreign_key_list('native_scoped_messages') LIMIT 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(message_parent, "native_scoped_threads"); + let queue_parent: String = conn + .query_row( + "SELECT `table` FROM pragma_foreign_key_list('native_scoped_turn_queue') LIMIT 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(queue_parent, "native_scoped_threads"); + + // This is the exact schema batch the shipped pre-runner binary runs on + // every open. Compare the table, FK, and index structures against a + // pristine copy before proving that its open batch succeeds: SQLite's + // `IF NOT EXISTS` alone would not detect a subtly incompatible barrier. + let oracle = Connection::open_in_memory().unwrap(); + oracle.execute_batch(MIGRATIONS[0].sql).unwrap(); + for table in ["rt_threads", "rt_messages"] { + assert_eq!(table_shape(&conn, table), table_shape(&oracle, table)); + assert_eq!( + foreign_key_shape(&conn, table), + foreign_key_shape(&oracle, table) + ); + assert_eq!(index_shape(&conn, table), index_shape(&oracle, table)); + } + + // The old open must succeed without ever exposing the renamed data. + conn.execute_batch(MIGRATIONS[0].sql).unwrap(); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM rt_threads", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 0 + ); + let downgrade_write = conn.execute( + "INSERT OR IGNORE INTO rt_threads (\ + id, organization_id, title, hidden, status, created_by, updated_by, \ + virtual_mcp_id, created_at, updated_at\ + ) VALUES ('old-write', 'v5-org', 'blocked', 0, 'completed', 'v5-user', \ + 'v5-user', 'vmcp', '2025-01-01T00:00:00.000Z', \ + '2025-01-01T00:00:00.000Z')", + [], + ); + assert!(downgrade_write + .unwrap_err() + .to_string() + .contains("requires a newer app version")); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM native_scoped_threads", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 1 + ); + } + + #[test] + fn version_six_migration_preserves_internal_updater_and_tracks_explicit_updates() { + let dir = tempfile::tempdir().unwrap(); + let scope = RtAccountScope::new("studio.decocms.com", "v6-user").unwrap(); + create_v6_fixture(dir.path(), &scope, "v6-org", "v6-live-thread"); + + let db = ThreadsDb::open(dir.path()).unwrap(); + assert_eq!( + schema_version(&local_db_path(dir.path())), + CURRENT_SCHEMA_VERSION + ); + let migrated = db + .rt_get_thread_in_scope(&scope, "v6-org", "v6-live-thread") + .unwrap() + .unwrap(); + assert_eq!(migrated.updated_by, None); + assert!(!serde_json::to_value(&migrated) + .unwrap() + .as_object() + .unwrap() + .contains_key("updated_by")); + + { + let conn = db.lock(); + let (internal_updater, explicit): (String, i64) = conn + .query_row( + "SELECT updated_by, updated_by_explicit \ + FROM native_scoped_threads WHERE id = 'v6-live-thread'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(internal_updater, "v6-user"); + assert_eq!(explicit, 0); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM rt_threads", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 0, + "v7 must not disturb the v6 downgrade barrier" + ); + } + + let updated = db + .rt_update_thread_in_scope( + &scope, + "v6-org", + "v6-live-thread", + "v6-user", + &RtThreadPatch::default(), + ) + .unwrap() + .unwrap(); + assert_eq!(updated.updated_by.as_deref(), Some("v6-user")); + assert_eq!( + serde_json::to_value(&updated).unwrap()["updated_by"], + "v6-user" + ); + assert_eq!( + db.lock() + .query_row( + "SELECT updated_by_explicit FROM native_scoped_threads \ + WHERE id = 'v6-live-thread'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 1 + ); + } + + #[test] + fn version_seven_upgrade_backfills_queue_ids_and_adds_durable_quarantine_without_changing_message_pk( + ) { + let dir = tempfile::tempdir().unwrap(); + let scope = RtAccountScope::new("studio.decocms.com", "v7-user").unwrap(); + create_v7_fixture(dir.path(), &scope, "v7-org", "v7-thread"); + + drop(ThreadsDb::open(dir.path()).unwrap()); + assert_eq!( + schema_version(&local_db_path(dir.path())), + CURRENT_SCHEMA_VERSION + ); + let conn = Connection::open(local_db_path(dir.path())).unwrap(); + let mut statement = conn + .prepare( + "SELECT message_id, assistant_message_id FROM native_scoped_turn_queue \ + ORDER BY fifo_ordinal", + ) + .unwrap(); + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .unwrap() + .map(Result::unwrap) + .collect::>(); + assert_eq!( + rows, + [ + ( + "v5-running-message".to_string(), + "msg-v5-running-message-assistant".to_string(), + ), + ( + "v5-queued-message".to_string(), + "msg-v5-queued-message-assistant".to_string(), + ), + ] + ); + assert_eq!( + conn.query_row( + "SELECT pk FROM pragma_table_info('native_scoped_messages') WHERE name = 'id'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 1, + "message ids remain one global primary key" + ); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM rt_threads", [], |row| row + .get::<_, i64>(0),) + .unwrap(), + 0, + "v8/v9 must preserve the v6 downgrade barrier" + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('native_scoped_turn_queue') \ + WHERE name IN ('quarantine_reason', 'quarantine_preserve_user')", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 2 + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM native_scoped_turn_queue \ + WHERE checkpoint_harness_id IS NOT NULL OR checkpoint_session_id IS NOT NULL", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0, + "v10 preserves every older queue row with no invented session" + ); + drop(statement); + drop(conn); + drop(ThreadsDb::open(dir.path()).unwrap()); + assert_eq!( + schema_version(&local_db_path(dir.path())), + CURRENT_SCHEMA_VERSION + ); + } + + #[test] + fn version_seven_backfill_marks_only_provably_explicit_historical_updaters() { + let dir = tempfile::tempdir().unwrap(); + let scope = RtAccountScope::new("studio.decocms.com", "creator").unwrap(); + create_v6_fixture(dir.path(), &scope, "org", "thread"); + let conn = Connection::open(local_db_path(dir.path())).unwrap(); + conn.execute( + "UPDATE native_scoped_threads SET updated_by = 'different-updater' \ + WHERE id = 'thread'", + [], + ) + .unwrap(); + drop(conn); + + let db = ThreadsDb::open(dir.path()).unwrap(); + assert_eq!( + db.rt_get_thread_in_scope(&scope, "org", "thread") + .unwrap() + .unwrap() + .updated_by + .as_deref(), + Some("different-updater") + ); + // Same-actor historical updates are indistinguishable from the + // create-time copied value and intentionally remain implicit/omitted. + assert_eq!( + db.lock() + .query_row( + "SELECT updated_by_explicit FROM native_scoped_threads WHERE id = 'thread'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 1 + ); + } + + #[test] + fn current_rt_apis_never_read_or_write_downgrade_barrier_rows() { + let db = ThreadsDb::open_in_memory().unwrap(); + let scope = RtAccountScope::new("studio.decocms.com", "scoped-user").unwrap(); + { + let conn = db.lock(); + conn.execute_batch( + "DROP TRIGGER rt_threads_downgrade_block_insert; \ + DROP TRIGGER rt_messages_downgrade_block_insert; \ + INSERT INTO rt_threads (\ + id, organization_id, title, hidden, status, created_by, updated_by, \ + virtual_mcp_id, created_at, updated_at\ + ) VALUES ('same-public-id', 'same-org', 'barrier decoy', 0, 'completed', \ + 'old-user', 'old-user', 'old-vmcp', '2025-01-01T00:00:00.000Z', \ + '2025-01-01T00:00:00.000Z'); \ + INSERT INTO rt_messages (\ + id, thread_id, role, parts, created_at, updated_at\ + ) VALUES ('same-message-id', 'same-public-id', 'user', '[]', \ + '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z');", + ) + .unwrap(); + } + + assert!(db + .rt_get_thread_in_scope(&scope, "same-org", "same-public-id") + .unwrap() + .is_none()); + let current = db + .rt_create_thread_scoped( + &scope, + Some("same-public-id"), + "same-org", + "current scoped row", + None, + "current-vmcp", + None, + "scoped-user", + ) + .unwrap(); + assert_eq!(current.title, "current scoped row"); + assert_eq!( + db.rt_list_messages_in_scope(&scope, "same-org", ¤t.id, 100, 0, false) + .unwrap() + .1, + 0, + "a barrier message with the same public id must remain invisible" + ); + let appended = db + .rt_append_message( + "same-message-id", + ¤t.id, + "user", + &serde_json::json!([]), + None, + ) + .unwrap(); + assert_eq!(appended.thread_id, current.id); + let conn = db.lock(); + assert_eq!( + conn.query_row( + "SELECT title FROM rt_threads WHERE id = 'same-public-id'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "barrier decoy" + ); + assert_eq!( + conn.query_row( + "SELECT title FROM native_scoped_threads WHERE id = 'same-public-id'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "current scoped row" + ); + } + + #[test] + fn failed_version_six_rename_rolls_back_every_table_and_version() { + let dir = tempfile::tempdir().unwrap(); + let scope = RtAccountScope::new("studio.decocms.com", "v5-user").unwrap(); + create_v5_fixture(dir.path(), &scope, "v5-org", "v5-live-thread"); + let path = local_db_path(dir.path()); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TABLE native_scoped_messages (sentinel TEXT NOT NULL); \ + INSERT INTO native_scoped_messages VALUES ('kept');", + ) + .unwrap(); + drop(conn); + + assert!(ThreadsDb::open(dir.path()).is_err()); + let conn = Connection::open(&path).unwrap(); + assert_eq!(schema_version(&path), 5); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM rt_threads", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM sqlite_schema \ + WHERE type = 'table' AND name = 'native_scoped_threads'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0, + "the first rename must roll back when the second rename fails" + ); + assert_eq!( + conn.query_row("SELECT sentinel FROM native_scoped_messages", [], |row| { + row.get::<_, String>(0) + }) + .unwrap(), + "kept" + ); + let message_parent: String = conn + .query_row( + "SELECT `table` FROM pragma_foreign_key_list('rt_messages') LIMIT 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(message_parent, "rt_threads"); + } + + #[test] + fn current_schema_reopen_refuses_foreign_key_corruption_without_mutation() { + let dir = tempfile::tempdir().unwrap(); + let path = local_db_path(dir.path()); + drop(ThreadsDb::open(dir.path()).unwrap()); + let conn = Connection::open(&path).unwrap(); + conn.pragma_update(None, "foreign_keys", 0).unwrap(); + conn.execute( + "INSERT INTO native_scoped_messages (\ + id, thread_id, role, parts, seq, created_at, updated_at\ + ) VALUES ('orphan', 'missing-thread', 'user', '[]', 1, ?1, ?1)", + ["2025-01-01T00:00:00.000Z"], + ) + .unwrap(); + drop(conn); + + let error = match ThreadsDb::open(dir.path()) { + Ok(_) => panic!("foreign-key corruption must fail closed"), + Err(error) => error, + }; + assert!(matches!(error, DbError::SchemaIntegrity(_))); + let conn = Connection::open(&path).unwrap(); + assert_eq!(schema_version(&path), CURRENT_SCHEMA_VERSION); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM native_scoped_messages WHERE id = 'orphan'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 1, + "validation must report corruption, not rewrite user data" + ); + } + + #[test] + fn reopening_current_database_is_an_idempotent_no_op() { + let dir = tempfile::tempdir().unwrap(); + create_legacy_v0_fixture(dir.path()); + { + let db = ThreadsDb::open(dir.path()).unwrap(); + let appended = db + .rt_append_message( + "after-migration", + "legacy-thread", + "user", + &serde_json::json!([{"type": "text", "text": "new"}]), + None, + ) + .unwrap(); + assert_eq!(appended.seq, 3); + } + + let reopened = ThreadsDb::open(dir.path()).unwrap(); + let (messages, total) = reopened + .rt_list_messages("legacy-thread", 100, 0, false) + .unwrap(); + assert_eq!( + schema_version(&local_db_path(dir.path())), + CURRENT_SCHEMA_VERSION + ); + assert_eq!(total, 3); + assert_eq!( + messages + .iter() + .map(|message| message.seq) + .collect::>(), + [1, 2, 3] + ); + } + + #[test] + fn racing_first_opens_serialize_migration_version_checks() { + let dir = tempfile::tempdir().unwrap(); + let root = std::sync::Arc::new(dir.path().to_path_buf()); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(3)); + let mut workers = Vec::new(); + for _ in 0..2 { + let root = root.clone(); + let barrier = barrier.clone(); + workers.push(std::thread::spawn(move || { + barrier.wait(); + ThreadsDb::open(&root).map(drop) + })); + } + barrier.wait(); + for worker in workers { + worker.join().unwrap().unwrap(); + } + assert_eq!( + schema_version(&local_db_path(&root)), + CURRENT_SCHEMA_VERSION + ); + } + + #[test] + fn database_from_a_newer_app_is_refused_without_mutation() { + let dir = tempfile::tempdir().unwrap(); + let db_dir = dir.path().join(".decocms"); + fs::create_dir_all(&db_dir).unwrap(); + let path = local_db_path(dir.path()); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch( + "CREATE TABLE sentinel (value TEXT NOT NULL); INSERT INTO sentinel VALUES ('kept');", + ) + .unwrap(); + conn.pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION + 1) + .unwrap(); + drop(conn); + + let error = match ThreadsDb::open(dir.path()) { + Ok(_) => panic!("a newer database must not be opened"), + Err(error) => error, + }; + assert!(matches!( + error, + DbError::NewerSchemaVersion { + found, + supported + } if found == CURRENT_SCHEMA_VERSION + 1 && supported == CURRENT_SCHEMA_VERSION + )); + let conn = Connection::open(&path).unwrap(); + assert_eq!( + conn.pragma_query_value::(None, "user_version", |row| row.get(0)) + .unwrap(), + CURRENT_SCHEMA_VERSION + 1 + ); + assert_eq!( + conn.query_row("SELECT value FROM sentinel", [], |row| row + .get::<_, String>(0)) + .unwrap(), + "kept" + ); + } + + #[test] + fn failed_migration_rolls_back_schema_rows_and_version() { + let dir = tempfile::tempdir().unwrap(); + let db_dir = dir.path().join(".decocms"); + fs::create_dir_all(&db_dir).unwrap(); + let path = local_db_path(dir.path()); + let conn = Connection::open(&path).unwrap(); + // A malformed legacy table makes migration 2 fail after migration 1 + // has already created its other tables. The outer transaction must + // undo every one of those changes. + conn.execute_batch( + "CREATE TABLE rt_messages (id TEXT PRIMARY KEY); \ + INSERT INTO rt_messages VALUES ('sentinel');", + ) + .unwrap(); + drop(conn); + + assert!(ThreadsDb::open(dir.path()).is_err()); + let conn = Connection::open(&path).unwrap(); + assert_eq!( + conn.pragma_query_value::(None, "user_version", |row| row.get(0)) + .unwrap(), + 0 + ); + assert_eq!( + conn.query_row("SELECT id FROM rt_messages", [], |row| row + .get::<_, String>(0)) + .unwrap(), + "sentinel" + ); + let created_by_failed_upgrade: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_schema WHERE type = 'table' AND name = 'threads'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(created_by_failed_upgrade, 0); + } + + #[cfg(unix)] + #[test] + fn file_store_is_owner_only_including_wal_sidecars() { + use std::os::unix::fs::PermissionsExt; + + let parent = tempfile::tempdir().unwrap(); + let app_root = parent.path().join("existing-project"); + let store_dir = app_root.join(".decocms"); + fs::create_dir_all(&store_dir).unwrap(); + fs::set_permissions(&store_dir, fs::Permissions::from_mode(0o755)).unwrap(); + let path = local_db_path(&app_root); + drop(Connection::open(&path).unwrap()); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + + let db = ThreadsDb::open(&app_root).unwrap(); + db.create_thread("permission check".to_string()).unwrap(); + assert_eq!( + fs::metadata(&store_dir).unwrap().permissions().mode() & 0o777, + 0o700 + ); + for path in [ + path, + sqlite_sidecar_path(&local_db_path(&app_root), "-wal"), + sqlite_sidecar_path(&local_db_path(&app_root), "-shm"), + ] { + assert!( + path.exists(), + "SQLite should have materialized {}", + path.display() + ); + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } + + #[test] + fn rt_org_scoped_apis_cannot_observe_or_mutate_another_org() { + let db = ThreadsDb::open_in_memory().unwrap(); + db.rt_create_thread( + Some("thread-a"), + "org-a", + "original", + None, + "vmcp", + None, + "user-a", + ) + .unwrap(); + let session_part = serde_json::json!([{ + "type": "data-harness-session", + "harnessId": "claude-code", + "sessionId": "session-a" + }]); + db.rt_append_message_in_org( + "org-a", + "assistant-a", + "thread-a", + "assistant", + &session_part, + None, + ) + .unwrap(); + db.rt_enqueue_turn_in_org( + "org-a", + "thread-a", + &turn_input("queued-a", "tenant scoped", 1), + ) + .unwrap(); + + assert!(db + .rt_get_thread_in_org("org-b", "thread-a") + .unwrap() + .is_none()); + assert!(db + .rt_get_message_in_org("org-b", "assistant-a") + .unwrap() + .is_none()); + assert_eq!( + db.rt_list_messages_in_org("org-b", "thread-a", 100, 0, false) + .unwrap() + .1, + 0 + ); + assert_eq!( + db.rt_last_assistant_session_in_org("org-b", "thread-a") + .unwrap(), + None + ); + assert!(db + .rt_list_turn_queue_in_org("org-b", "thread-a") + .unwrap() + .is_empty()); + assert!(matches!( + db.rt_cancel_turn_in_org("org-b", "thread-a", "workflow-queued-a") + .unwrap(), + RtTurnCancelOutcome::NotFound + )); + assert!(db + .rt_enqueue_turn_in_org( + "org-b", + "thread-a", + &turn_input("queued-b", "cross tenant", 2), + ) + .is_err()); + + let patch = RtThreadPatch { + title: Some("cross-tenant overwrite".to_string()), + ..RtThreadPatch::default() + }; + assert!(db + .rt_update_thread_in_org("org-b", "thread-a", "user-b", &patch) + .unwrap() + .is_none()); + db.rt_pin_harness_if_unset_in_org( + "org-b", + "thread-a", + "codex", + Some("user-desktop"), + Some("other-branch"), + ) + .unwrap(); + db.rt_set_thread_status_in_org("org-b", "thread-a", "failed") + .unwrap(); + assert!(db + .rt_append_message_in_org( + "org-b", + "cross-org-message", + "thread-a", + "user", + &serde_json::json!([]), + None, + ) + .is_err()); + assert!(!db.rt_delete_thread_in_org("org-b", "thread-a").unwrap()); + + let unchanged = db + .rt_get_thread_in_org("org-a", "thread-a") + .unwrap() + .unwrap(); + assert_eq!(unchanged.title, "original"); + assert_eq!(unchanged.status, "completed"); + assert_eq!(unchanged.harness_id, None); + assert_eq!(unchanged.branch, None); + assert!(db.rt_get_message("cross-org-message").unwrap().is_none()); + assert_eq!( + db.rt_last_assistant_session_in_org("org-a", "thread-a") + .unwrap(), + Some(("claude-code".to_string(), "session-a".to_string())) + ); + } + + #[test] + fn rt_session_lookup_skips_failed_rows_invalid_tokens_and_other_harnesses() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + + db.rt_append_message_in_org( + "org", + "assistant-claude-1", + "thread", + "assistant", + &serde_json::json!([{ + "type": "data-harness-session", + "harnessId": "claude-code", + "sessionId": "claude-session-1", + }]), + None, + ) + .unwrap(); + // A pre-spawn failure persists an assistant row with no token. It must + // not erase the last known-good on-disk CLI conversation. + db.rt_append_message_in_org( + "org", + "assistant-failed", + "thread", + "assistant", + &serde_json::json!([]), + Some(&serde_json::json!({"finishReason": "error"})), + ) + .unwrap(); + // Wrong-provider and whitespace-only tokens are not valid anchors for + // the thread's pinned harness. + db.rt_append_message_in_org( + "org", + "assistant-codex", + "thread", + "assistant", + &serde_json::json!([{ + "type": "data-harness-session", + "harnessId": "codex", + "sessionId": "codex-session-1", + }]), + None, + ) + .unwrap(); + db.rt_append_message_in_org( + "org", + "assistant-invalid", + "thread", + "assistant", + &serde_json::json!([ + { + "type": "data-harness-session", + "harnessId": "claude-code", + "sessionId": " \t ", + }, + { + "type": "data-harness-session", + "harnessId": " ", + "sessionId": "not-valid", + }, + ]), + None, + ) + .unwrap(); + + assert_eq!( + db.rt_last_assistant_session_for_harness_in_org("org", "thread", "claude-code") + .unwrap(), + Some(("claude-code".to_string(), "claude-session-1".to_string())) + ); + assert_eq!( + db.rt_last_assistant_session_for_harness_in_org("org", "thread", "codex") + .unwrap(), + Some(("codex".to_string(), "codex-session-1".to_string())) + ); + + db.rt_append_message_in_org( + "org", + "assistant-claude-2", + "thread", + "assistant", + &serde_json::json!([{ + "type": "data-harness-session", + "harnessId": "claude-code", + "sessionId": " claude-session-2 ", + }]), + None, + ) + .unwrap(); + assert_eq!( + db.rt_last_assistant_session_for_harness_in_org("org", "thread", "claude-code") + .unwrap(), + Some(("claude-code".to_string(), "claude-session-2".to_string())) + ); + + db.lock() + .execute( + "UPDATE native_scoped_messages SET parts = '{broken-json' \ + WHERE id = 'assistant-claude-2'", + [], + ) + .unwrap(); + assert!( + db.rt_last_assistant_session_for_harness_in_org("org", "thread", "claude-code") + .is_err(), + "corrupt durable session history must fail closed instead of starting a fresh CLI session" + ); + } + + #[test] + fn rt_create_thread_rejects_explicit_id_collision_across_orgs() { + let db = ThreadsDb::open_in_memory().unwrap(); + db.rt_create_thread( + Some("shared-id"), + "org-a", + "a", + None, + "vmcp-a", + None, + "user-a", + ) + .unwrap(); + let conflict = db.rt_create_thread( + Some("shared-id"), + "org-b", + "b", + None, + "vmcp-b", + None, + "user-b", + ); + assert!(matches!( + conflict, + Err(DbError::IdempotencyConflict { + entity: "rt_thread", + ref id + }) if id == "shared-id" + )); + assert_eq!( + db.rt_get_thread("shared-id") + .unwrap() + .unwrap() + .organization_id, + "org-a" + ); + } + + #[test] + fn durable_turn_enqueue_is_exact_idempotent_and_fifo() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + let first_input = turn_input("m1", "first", 100); + let first = match db + .rt_enqueue_turn_in_org("org", "thread", &first_input) + .unwrap() + { + RtTurnEnqueueOutcome::Inserted(item) => item, + other => panic!("expected insertion, got {other:?}"), + }; + assert_eq!(first.fifo_ordinal, 1); + assert_eq!(first.state, RtTurnQueueState::Queued); + assert_eq!(first.claim_token, None); + + let mut retry = first_input.clone(); + retry.enqueued_at = 999; + let repeated = match db.rt_enqueue_turn_in_org("org", "thread", &retry).unwrap() { + RtTurnEnqueueOutcome::Existing(item) => item, + other => panic!("expected existing row, got {other:?}"), + }; + assert_eq!( + repeated, first, + "server enqueue time stays first-write-wins" + ); + + let second = match db + .rt_enqueue_turn_in_org("org", "thread", &turn_input("m2", "second", 101)) + .unwrap() + { + RtTurnEnqueueOutcome::Inserted(item) => item, + other => panic!("expected insertion, got {other:?}"), + }; + assert_eq!(second.fifo_ordinal, 2); + assert_eq!( + db.rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .iter() + .map(|item| item.message_id.as_str()) + .collect::>(), + ["m1", "m2"] + ); + + let mut conflicting_payload = first_input.clone(); + conflicting_payload.user_message["parts"][0]["text"] = serde_json::json!("changed"); + assert!(matches!( + db.rt_enqueue_turn_in_org("org", "thread", &conflicting_payload), + Err(DbError::IdempotencyConflict { + entity: "rt_turn_queue", + ref id, + }) if id == "m1" + )); + let mut conflicting_workflow = turn_input("m3", "third", 102); + conflicting_workflow.workflow_id = first_input.workflow_id.clone(); + assert!(matches!( + db.rt_enqueue_turn_in_org("org", "thread", &conflicting_workflow), + Err(DbError::IdempotencyConflict { + entity: "rt_turn_queue", + ref id, + }) if id == "m3" + )); + assert_eq!( + db.rt_list_turn_queue_in_org("org", "thread").unwrap().len(), + 2 + ); + + let mut malformed = turn_input("m4", "bad identity", 103); + malformed.user_message["id"] = serde_json::json!("different"); + assert!(matches!( + db.rt_enqueue_turn_in_org("org", "thread", &malformed), + Err(DbError::InvalidQueueData(_)) + )); + } + + #[test] + fn native_assistant_ids_are_reserved_deterministic_and_scope_generation_aware() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread-a"); + create_rt_thread(&db, "org", "thread-b"); + let first = db + .rt_thread_fence_in_org("org", "thread-a") + .unwrap() + .unwrap(); + let second = db + .rt_thread_fence_in_org("org", "thread-b") + .unwrap() + .unwrap(); + let id = native_assistant_message_id(&first, "user-id"); + assert_eq!(id, native_assistant_message_id(&first, "user-id")); + assert_ne!(id, native_assistant_message_id(&first, "other-user")); + assert_ne!(id, native_assistant_message_id(&second, "user-id")); + let hash = id + .strip_prefix(NATIVE_ASSISTANT_MESSAGE_ID_V1_PREFIX) + .expect("v1 namespace"); + assert_eq!(hash.len(), 64); + assert!(hash + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())); + + let reserved = turn_input("native-assistant:v2:caller", "blocked", 1); + assert!(matches!( + db.rt_enqueue_turn_in_org("org", "thread-a", &reserved), + Err(DbError::InvalidQueueData(message)) if message.contains("reserved namespace") + )); + } + + #[test] + fn enqueue_reserves_global_user_and_assistant_ids_before_acceptance() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread-a"); + create_rt_thread(&db, "org", "thread-b"); + db.rt_enqueue_turn_in_org("org", "thread-a", &turn_input("shared", "first", 1)) + .unwrap(); + assert!(matches!( + db.rt_enqueue_turn_in_org("org", "thread-b", &turn_input("shared", "second", 2)), + Err(DbError::IdempotencyConflict { + entity: "rt_turn_queue", + ref id, + }) if id == "shared" + )); + + let fence_b = db + .rt_thread_fence_in_org("org", "thread-b") + .unwrap() + .unwrap(); + let predicted = native_assistant_message_id(&fence_b, "unique-user"); + db.rt_append_message_in_org( + "org", + &predicted, + "thread-a", + "assistant", + &serde_json::json!([]), + None, + ) + .unwrap(); + assert!(matches!( + db.rt_enqueue_turn_in_org( + "org", + "thread-b", + &turn_input("unique-user", "second", 3), + ), + Err(DbError::IdempotencyConflict { + entity: "rt_message", + ref id, + }) if id == &predicted + )); + assert!(db + .rt_list_turn_queue_in_org("org", "thread-b") + .unwrap() + .is_empty()); + } + + #[test] + fn enqueue_retry_rejects_reversed_v1_completed_pair() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + let input = turn_input("m1", "reversed", 1); + let fence = db.rt_thread_fence_in_org("org", "thread").unwrap().unwrap(); + let assistant_id = native_assistant_message_id(&fence, &input.message_id); + + db.rt_append_message_in_org( + "org", + &assistant_id, + "thread", + "assistant", + &serde_json::json!([{"type": "text", "text": "too early"}]), + None, + ) + .unwrap(); + db.rt_append_message_in_org( + "org", + &input.message_id, + "thread", + "user", + input.user_message.get("parts").unwrap(), + input.user_message.get("metadata"), + ) + .unwrap(); + + assert!(matches!( + db.rt_enqueue_turn_in_org("org", "thread", &input), + Err(DbError::IdempotencyConflict { + entity: "rt_message", + ref id, + }) if id == &assistant_id + )); + assert!(db + .rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .is_empty()); + } + + #[test] + fn enqueue_retry_rejects_reversed_legacy_completed_pair() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + let input = turn_input("m1", "legacy reversed", 1); + let assistant_id = legacy_native_assistant_message_id(&input.message_id); + + db.rt_append_message_in_org( + "org", + &assistant_id, + "thread", + "assistant", + &serde_json::json!([{"type": "text", "text": "too early"}]), + None, + ) + .unwrap(); + db.rt_append_message_in_org( + "org", + &input.message_id, + "thread", + "user", + input.user_message.get("parts").unwrap(), + input.user_message.get("metadata"), + ) + .unwrap(); + + assert!(matches!( + db.rt_enqueue_turn_in_org("org", "thread", &input), + Err(DbError::IdempotencyConflict { + entity: "rt_message", + ref id, + }) if id == &assistant_id + )); + assert!(db + .rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .is_empty()); + } + + #[test] + fn claim_adopts_exact_persisted_pair_without_rerun_and_preserves_failed_status() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + let input = turn_input("m1", "already complete", 1); + let queued = match db.rt_enqueue_turn_in_org("org", "thread", &input).unwrap() { + RtTurnEnqueueOutcome::Inserted(item) => item, + other => panic!("expected insertion, got {other:?}"), + }; + let assistant_id = claimed_assistant_id(&queued); + db.rt_append_message_in_org( + "org", + "m1", + "thread", + "user", + input.user_message.get("parts").unwrap(), + input.user_message.get("metadata"), + ) + .unwrap(); + let assistant_parts = serde_json::json!([{"type": "text", "text": "kept"}]); + db.rt_append_message_in_org( + "org", + &assistant_id, + "thread", + "assistant", + &assistant_parts, + None, + ) + .unwrap(); + db.rt_set_thread_status_in_org("org", "thread", "failed") + .unwrap(); + + assert_eq!( + db.rt_claim_turn_queue_head_fenced(&queued.fence) + .unwrap() + .unwrap(), + RtTurnClaimOutcome::Completed { + workflow_id: queued.workflow_id.clone(), + } + ); + assert!(db + .rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .is_empty()); + assert_eq!( + db.rt_get_thread("thread").unwrap().unwrap().status, + "failed" + ); + assert_eq!( + db.rt_get_message_in_org("org", &assistant_id) + .unwrap() + .unwrap() + .parts, + assistant_parts + ); + } + + #[test] + fn active_recovery_adopts_exact_pair_without_downgrading_completed_status() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + let input = turn_input("m1", "already durable", 1); + db.rt_enqueue_turn_in_org("org", "thread", &input).unwrap(); + let claimed = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + db.rt_begin_claimed_turn(&claimed).unwrap(); + let assistant_id = claimed_assistant_id(&claimed); + db.rt_append_message_in_org( + "org", + &assistant_id, + "thread", + "assistant", + &serde_json::json!([{"type": "text", "text": "done"}]), + Some(&serde_json::json!({"finishReason": "stop"})), + ) + .unwrap(); + db.rt_set_thread_status_in_org("org", "thread", "completed") + .unwrap(); + + assert!( + db.rt_list_orphaned_active_turns_isolated() + .unwrap() + .is_empty(), + "exact durable pair is adopted during scan without executable recovery work" + ); + assert!(db + .rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .is_empty()); + assert_eq!( + db.rt_get_thread("thread").unwrap().unwrap().status, + "completed" + ); + } + + #[test] + fn active_recovery_adopts_exact_pair_without_downgrading_requires_action_status() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + let input = turn_input("m1", "already durable", 1); + db.rt_enqueue_turn_in_org("org", "thread", &input).unwrap(); + let claimed = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + db.rt_begin_claimed_turn(&claimed).unwrap(); + let assistant_id = claimed_assistant_id(&claimed); + db.rt_append_message_in_org( + "org", + &assistant_id, + "thread", + "assistant", + &serde_json::json!([{"type": "text", "text": "Should I continue?"}]), + Some(&serde_json::json!({"finishReason": "stop"})), + ) + .unwrap(); + db.rt_set_thread_status_in_org("org", "thread", "requires_action") + .unwrap(); + + assert!( + db.rt_list_orphaned_active_turns_isolated() + .unwrap() + .is_empty(), + "exact durable pair is adopted during scan without executable recovery work" + ); + assert!(db + .rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .is_empty()); + assert_eq!( + db.rt_get_thread("thread").unwrap().unwrap().status, + "requires_action" + ); + } + + #[test] + fn claimed_session_checkpoint_survives_reopen_and_orphan_finalization() { + let dir = tempfile::tempdir().unwrap(); + let db = ThreadsDb::open(dir.path()).unwrap(); + create_rt_thread(&db, "org", "thread"); + let fence = db.rt_thread_fence_in_org("org", "thread").unwrap().unwrap(); + assert!(db + .rt_pin_harness_if_unset_fenced(&fence, "claude-code", None, None) + .unwrap()); + db.rt_enqueue_turn_in_org("org", "thread", &turn_input("m1", "first prompt", 1)) + .unwrap(); + let claimed = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + assert!(matches!( + db.rt_begin_claimed_turn(&claimed).unwrap(), + RtTurnBeginOutcome::Begun(_) + )); + assert!(db + .rt_checkpoint_claimed_turn_session(&claimed, "claude-code", "session-1") + .unwrap()); + assert!(db + .rt_checkpoint_claimed_turn_session(&claimed, " claude-code ", " session-1 ") + .unwrap()); + assert!(db + .rt_checkpoint_claimed_turn_session(&claimed, "claude-code", "session-2") + .is_err()); + assert!(db + .rt_checkpoint_claimed_turn_session(&claimed, "codex", "session-1") + .is_err()); + assert_eq!( + db.rt_list_turn_queue_in_org("org", "thread").unwrap()[0].checkpoint_session, + Some(("claude-code".to_string(), "session-1".to_string())), + "wrong-harness checkpoint must not mutate the authoritative pair" + ); + + let mut stale_claim = claimed.clone(); + stale_claim.claim_token = Some("not-the-active-claim".to_string()); + assert!(!db + .rt_checkpoint_claimed_turn_session(&stale_claim, "claude-code", "session-1") + .unwrap()); + let mut stale_generation = claimed.clone(); + stale_generation.fence.generation = "not-the-live-generation".to_string(); + assert!(!db + .rt_checkpoint_claimed_turn_session(&stale_generation, "claude-code", "session-1") + .unwrap()); + drop(db); + + let db = ThreadsDb::open(dir.path()).unwrap(); + let orphan = match db + .rt_list_orphaned_active_turns_isolated() + .unwrap() + .as_slice() + { + [RtOrphanedTurn::Ready(turn)] => turn.clone(), + other => panic!("expected one recoverable checkpointed turn, got {other:?}"), + }; + assert_eq!( + orphan.checkpoint_session, + Some(("claude-code".to_string(), "session-1".to_string())) + ); + assert!(matches!( + db.rt_begin_claimed_turn(&orphan).unwrap(), + RtTurnBeginOutcome::Begun(_) + )); + let interrupted = serde_json::json!([{ + "type": "text", + "text": "interrupted" + }]); + assert!(matches!( + db.rt_finalize_claimed_turn( + &orphan, + &interrupted, + Some(&serde_json::json!({"interrupted": true})), + RtTurnTerminalStatus::Failed, + ) + .unwrap(), + RtTurnTerminalOutcome::Completed(_) + )); + assert!(db + .rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .is_empty()); + assert_eq!( + db.rt_last_assistant_session_for_harness_in_org("org", "thread", "claude-code") + .unwrap(), + Some(("claude-code".to_string(), "session-1".to_string())) + ); + let messages = db + .rt_list_messages_in_org("org", "thread", 10, 0, false) + .unwrap() + .0; + assert_eq!(messages.len(), 2); + assert!(messages[1].parts.as_array().unwrap().iter().any(|part| { + part == &serde_json::json!({ + "type": "data-harness-session", + "harnessId": "claude-code", + "sessionId": "session-1", + }) + })); + } + + #[test] + fn assistant_checkpoint_merge_is_idempotent_and_rejects_malformed_parts() { + let checkpoint = ("claude-code".to_string(), "session-1".to_string()); + let existing = serde_json::json!([ + {"type": "text", "text": "done"}, + { + "type": "data-harness-session", + "harnessId": "claude-code", + "sessionId": "session-1", + } + ]); + assert_eq!( + assistant_parts_with_checkpoint(&existing, Some(&checkpoint)).unwrap(), + existing, + "terminal finalization must not duplicate the accumulator's token" + ); + for malformed in [ + serde_json::json!([{"type": "data-harness-session"}]), + serde_json::json!([{ + "type": "data-harness-session", + "harnessId": "codex", + "sessionId": "session-1", + }]), + serde_json::json!([{ + "type": "data-harness-session", + "harnessId": "claude-code", + "sessionId": " ", + }]), + ] { + assert!( + assistant_parts_with_checkpoint(&malformed, Some(&checkpoint)).is_err(), + "malformed/conflicting session parts must fail closed: {malformed}" + ); + } + } + + #[test] + fn claim_quarantines_wrong_or_reversed_assistant_reservations() { + for corruption in ["wrong-id", "reversed-sequence"] { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + let input = turn_input("m1", corruption, 1); + let queued = match db.rt_enqueue_turn_in_org("org", "thread", &input).unwrap() { + RtTurnEnqueueOutcome::Inserted(item) => item, + other => panic!("expected insertion, got {other:?}"), + }; + let reserved_id = claimed_assistant_id(&queued); + let (assistant_id, assistant_first) = match corruption { + "wrong-id" => ("foreign-assistant".to_string(), false), + "reversed-sequence" => (reserved_id, true), + _ => unreachable!(), + }; + if assistant_first { + db.rt_append_message_in_org( + "org", + &assistant_id, + "thread", + "assistant", + &serde_json::json!([{"type": "text", "text": "foreign"}]), + None, + ) + .unwrap(); + } + db.rt_append_message_in_org( + "org", + "m1", + "thread", + "user", + input.user_message.get("parts").unwrap(), + input.user_message.get("metadata"), + ) + .unwrap(); + if !assistant_first { + db.rt_append_message_in_org( + "org", + &assistant_id, + "thread", + "assistant", + &serde_json::json!([{"type": "text", "text": "foreign"}]), + None, + ) + .unwrap(); + db.lock() + .execute( + "UPDATE native_scoped_turn_queue SET assistant_message_id = ?1 \ + WHERE workflow_id = ?2", + params![assistant_id, queued.workflow_id], + ) + .unwrap(); + } + + let malformed = match db + .rt_claim_turn_queue_head_fenced(&queued.fence) + .unwrap() + .unwrap() + { + RtTurnClaimOutcome::Malformed(item) => item, + other => panic!("{corruption} must not be adopted or executed: {other:?}"), + }; + assert!(malformed.canonical_user.is_none()); + assert_eq!( + db.rt_finalize_malformed_orphan( + &malformed, + &serde_json::json!([{"type": "text", "text": "must not persist"}]), + None, + ) + .unwrap(), + RtTurnTerminalOutcome::Quarantined + ); + assert_eq!( + db.rt_get_message_in_org("org", &assistant_id) + .unwrap() + .unwrap() + .parts, + serde_json::json!([{"type": "text", "text": "foreign"}]) + ); + } + } + + #[test] + fn malformed_queued_head_is_quarantined_and_healthy_tail_remains_claimable() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + for (id, at) in [("broken", 1), ("healthy", 2)] { + db.rt_enqueue_turn_in_org("org", "thread", &turn_input(id, id, at)) + .unwrap(); + } + db.lock() + .execute( + "UPDATE native_scoped_turn_queue SET normalized_input_json = '{' \ + WHERE workflow_id = 'workflow-broken'", + [], + ) + .unwrap(); + let fence = db.rt_thread_fence_in_org("org", "thread").unwrap().unwrap(); + let malformed = match db.rt_claim_turn_queue_head_fenced(&fence).unwrap().unwrap() { + RtTurnClaimOutcome::Malformed(item) => item, + other => panic!("expected malformed head, got {other:?}"), + }; + let interrupted_parts = serde_json::json!([{"type": "text", "text": "quarantined"}]); + assert!(matches!( + db.rt_finalize_malformed_orphan( + &malformed, + &interrupted_parts, + Some(&serde_json::json!({"interrupted": true})), + ) + .unwrap(), + RtTurnTerminalOutcome::Completed(_) + )); + let messages = db + .rt_list_messages_in_org("org", "thread", 100, 0, false) + .unwrap() + .0; + assert_eq!( + messages + .iter() + .map(|message| message.role.as_str()) + .collect::>(), + ["user", "assistant"], + "valid accepted user JSON survives unrelated normalized-input corruption" + ); + assert!(messages[0].seq < messages[1].seq); + assert_eq!(messages[1].parts, interrupted_parts); + let healthy = ready_claim(db.rt_claim_turn_queue_head_fenced(&fence).unwrap().unwrap()); + assert_eq!(healthy.message_id, "healthy"); + } + + #[test] + fn pre_v8_duplicate_queue_loser_is_quarantined_before_harness_claim() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "winner"); + create_rt_thread(&db, "org", "loser"); + let winner = match db + .rt_enqueue_turn_in_org("org", "winner", &turn_input("shared", "winner", 1)) + .unwrap() + { + RtTurnEnqueueOutcome::Inserted(item) => item, + other => panic!("expected insertion, got {other:?}"), + }; + let loser_fence = db.rt_thread_fence_in_org("org", "loser").unwrap().unwrap(); + create_rt_thread(&db, "org", "reservation-owner"); + let reservation_owner_fence = db + .rt_thread_fence_in_org("org", "reservation-owner") + .unwrap() + .unwrap(); + let loser_message = serde_json::json!({ + "id": "shared", + "role": "user", + "parts": [{"type": "text", "text": "legacy loser"}], + }); + db.lock() + .execute( + "INSERT INTO native_scoped_turn_queue (\ + account_scope, organization_id, thread_id, thread_generation, message_id, \ + assistant_message_id, workflow_id, task_id, normalized_input_json, \ + user_message_json, enqueued_at, fifo_ordinal, state, claim_token\ + ) VALUES (?1, 'org', 'loser', ?2, 'shared', 'msg-shared-assistant', \ + 'legacy-loser', 'loser', '{}', ?3, 2, 1, 'queued', NULL)", + params![ + loser_fence.account_scope, + loser_fence.generation, + loser_message.to_string(), + ], + ) + .unwrap(); + let occupied_isolation_id = native_assistant_message_id(&loser_fence, "shared"); + let reservation_owner_message = serde_json::json!({ + "id": "reservation-owner-user", + "role": "user", + "parts": [{"type": "text", "text": "owns the v1 reservation"}], + }); + db.lock() + .execute( + "INSERT INTO native_scoped_turn_queue (\ + account_scope, organization_id, thread_id, thread_generation, message_id, \ + assistant_message_id, workflow_id, task_id, normalized_input_json, \ + user_message_json, enqueued_at, fifo_ordinal, state, claim_token\ + ) VALUES (?1, 'org', 'reservation-owner', ?2, 'reservation-owner-user', ?3, \ + 'reservation-owner-workflow', 'reservation-owner-task', '{}', ?4, \ + 3, 1, 'queued', NULL)", + params![ + reservation_owner_fence.account_scope, + reservation_owner_fence.generation, + occupied_isolation_id, + reservation_owner_message.to_string(), + ], + ) + .unwrap(); + let occupied_parts = serde_json::json!([{"type": "text", "text": "foreign owner"}]); + db.rt_append_message_in_org( + "org", + &occupied_isolation_id, + "winner", + "assistant", + &occupied_parts, + None, + ) + .unwrap(); + + let loser = match db + .rt_claim_turn_queue_head_fenced(&loser_fence) + .unwrap() + .unwrap() + { + RtTurnClaimOutcome::Malformed(item) => item, + other => panic!("legacy collision must not become executable: {other:?}"), + }; + assert!(loser.error.contains("reservation collision")); + assert_eq!( + loser.assistant_message_id.as_deref(), + Some("msg-shared-assistant"), + "quarantine must not rewrite a legacy reservation into an occupied v1 id" + ); + assert_ne!(loser.assistant_message_id, winner.assistant_message_id); + let recovered = db.rt_list_orphaned_active_turns_isolated().unwrap(); + let recovered_loser = match recovered.as_slice() { + [RtOrphanedTurn::Malformed(item)] => item, + other => panic!("persisted quarantine must survive recovery: {other:?}"), + }; + assert!(recovered_loser.error.contains("quarantined at claim")); + assert_eq!( + db.rt_finalize_malformed_orphan( + recovered_loser, + &serde_json::json!([{"type": "text", "text": "must not persist"}]), + Some(&serde_json::json!({"interrupted": true})), + ) + .unwrap(), + RtTurnTerminalOutcome::Quarantined, + "occupied message and queue reservations must close without stealing either" + ); + assert_eq!( + db.rt_get_message_in_org("org", &occupied_isolation_id) + .unwrap() + .unwrap() + .parts, + occupied_parts + ); + assert_eq!( + db.rt_list_turn_queue_in_org("org", "reservation-owner") + .unwrap() + .len(), + 1, + "quarantine must not mutate the queue row that owns the v1 reservation" + ); + assert!( + db.rt_list_messages_in_org("org", "loser", 100, 0, false) + .unwrap() + .0 + .is_empty(), + "a pre-v8 collision loser must not gain an assistant-only transcript" + ); + assert!(db + .rt_list_turn_queue_in_org("org", "loser") + .unwrap() + .is_empty()); + assert!(matches!( + db.rt_claim_turn_queue_head_fenced(&winner.fence) + .unwrap() + .unwrap(), + RtTurnClaimOutcome::Ready(_) + )); + } + + #[test] + fn reopened_pre_v8_running_collision_recovers_only_the_oldest_owner() { + let dir = tempfile::tempdir().unwrap(); + let db = ThreadsDb::open(dir.path()).unwrap(); + create_rt_thread(&db, "org", "winner"); + create_rt_thread(&db, "org", "loser"); + for (thread_id, workflow_id, claim_token, at) in [ + ("winner", "legacy-running-winner", "winner-claim", 1), + ("loser", "legacy-running-loser", "loser-claim", 2), + ] { + let fence = db + .rt_thread_fence_in_org("org", thread_id) + .unwrap() + .unwrap(); + let user = serde_json::json!({ + "id": "shared-running-user", + "role": "user", + "parts": [{"type": "text", "text": thread_id}], + }); + db.lock() + .execute( + "INSERT INTO native_scoped_turn_queue (\ + account_scope, organization_id, thread_id, thread_generation, message_id, \ + assistant_message_id, workflow_id, task_id, normalized_input_json, \ + user_message_json, enqueued_at, fifo_ordinal, state, claim_token\ + ) VALUES (?1, 'org', ?2, ?3, 'shared-running-user', \ + 'msg-shared-running-user-assistant', ?4, ?2, '{}', ?5, ?6, 1, \ + 'running', ?7)", + params![ + fence.account_scope, + thread_id, + fence.generation, + workflow_id, + user.to_string(), + at, + claim_token, + ], + ) + .unwrap(); + } + drop(db); + + let db = ThreadsDb::open(dir.path()).unwrap(); + let recovered = db.rt_list_orphaned_active_turns_isolated().unwrap(); + assert_eq!(recovered.len(), 2); + let winner = recovered + .iter() + .find_map(|turn| match turn { + RtOrphanedTurn::Ready(turn) if turn.fence.thread_id == "winner" => { + Some(turn.clone()) + } + _ => None, + }) + .expect("oldest reservation remains recoverable"); + let loser = recovered + .iter() + .find_map(|turn| match turn { + RtOrphanedTurn::Malformed(turn) if turn.fence.thread_id == "loser" => { + Some(turn.clone()) + } + _ => None, + }) + .expect("newer duplicate is quarantined before harness recovery"); + assert!(loser.error.contains("reservation collision")); + assert!(loser.canonical_user.is_none()); + assert_eq!( + db.rt_finalize_malformed_orphan( + &loser, + &serde_json::json!([{"type": "text", "text": "must not persist"}]), + None, + ) + .unwrap(), + RtTurnTerminalOutcome::Quarantined + ); + assert!(db + .rt_list_messages_in_org("org", "loser", 100, 0, false) + .unwrap() + .0 + .is_empty()); + + db.rt_begin_claimed_turn(&winner).unwrap(); + assert!(matches!( + db.rt_finalize_claimed_turn( + &winner, + &serde_json::json!([{"type": "text", "text": "interrupted"}]), + Some(&serde_json::json!({"finishReason": "error"})), + RtTurnTerminalStatus::Failed, + ) + .unwrap(), + RtTurnTerminalOutcome::Completed(_) + )); + assert_eq!( + db.rt_list_messages_in_org("org", "winner", 100, 0, false) + .unwrap() + .0 + .iter() + .map(|message| message.role.as_str()) + .collect::>(), + ["user", "assistant"] + ); + } + + #[test] + fn durable_turn_claim_is_fifo_and_atomic_completion_is_claim_fenced() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + for (id, at) in [("m1", 1), ("m2", 2)] { + db.rt_enqueue_turn_in_org("org", "thread", &turn_input(id, id, at)) + .unwrap(); + } + + let first = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + assert_eq!(first.message_id, "m1"); + assert_eq!(first.state, RtTurnQueueState::Running); + assert!(first.claim_token.is_some()); + assert!(db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .is_none()); + + let mut forged = first.clone(); + forged.claim_token = Some("stale-owner".to_string()); + assert_eq!( + db.rt_finalize_claimed_turn( + &forged, + &serde_json::json!([]), + None, + RtTurnTerminalStatus::Completed, + ) + .unwrap(), + RtTurnTerminalOutcome::Stale + ); + assert!(matches!( + db.rt_begin_claimed_turn(&first).unwrap(), + RtTurnBeginOutcome::Begun(_) + )); + assert!(matches!( + db.rt_finalize_claimed_turn( + &first, + &serde_json::json!([]), + None, + RtTurnTerminalStatus::Completed, + ) + .unwrap(), + RtTurnTerminalOutcome::Completed(_) + )); + assert_eq!( + db.rt_finalize_claimed_turn( + &first, + &serde_json::json!([]), + None, + RtTurnTerminalStatus::Completed, + ) + .unwrap(), + RtTurnTerminalOutcome::Stale + ); + + let second = ready_claim( + db.rt_claim_turn_queue_head_fenced(&first.fence) + .unwrap() + .unwrap(), + ); + assert_eq!(second.message_id, "m2"); + assert!(matches!( + db.rt_begin_claimed_turn(&second).unwrap(), + RtTurnBeginOutcome::Begun(_) + )); + assert!(matches!( + db.rt_finalize_claimed_turn( + &second, + &serde_json::json!([]), + None, + RtTurnTerminalStatus::Completed, + ) + .unwrap(), + RtTurnTerminalOutcome::Completed(_) + )); + assert!(db + .rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .is_empty()); + } + + #[test] + fn terminal_commit_atomically_persists_assistant_status_and_queue_removal() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + db.rt_enqueue_turn_in_org("org", "thread", &turn_input("m1", "first", 1)) + .unwrap(); + let claimed = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + assert!(matches!( + db.rt_begin_claimed_turn(&claimed).unwrap(), + RtTurnBeginOutcome::Begun(_) + )); + let parts = serde_json::json!([{"type": "text", "text": "done"}]); + let metadata = serde_json::json!({"finishReason": "stop"}); + let assistant_id = claimed_assistant_id(&claimed); + + let assistant = match db + .rt_finalize_claimed_turn( + &claimed, + &parts, + Some(&metadata), + RtTurnTerminalStatus::Completed, + ) + .unwrap() + { + RtTurnTerminalOutcome::Completed(message) => message, + RtTurnTerminalOutcome::Quarantined => panic!("healthy claim must not quarantine"), + RtTurnTerminalOutcome::Stale => panic!("current claim must complete"), + }; + assert_eq!(assistant.id, assistant_id); + assert_eq!(assistant.role, "assistant"); + assert_eq!(assistant.parts, parts); + assert_eq!(assistant.metadata, Some(metadata)); + assert_eq!( + db.rt_get_thread("thread").unwrap().unwrap().status, + "completed" + ); + assert!(db + .rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .is_empty()); + + assert_eq!( + db.rt_finalize_claimed_turn( + &claimed, + &assistant.parts, + assistant.metadata.as_ref(), + RtTurnTerminalStatus::Completed, + ) + .unwrap(), + RtTurnTerminalOutcome::Stale, + "once the claim row is gone an old worker cannot mutate status again" + ); + } + + #[test] + fn begin_claim_atomically_persists_queued_user_and_in_progress_status() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + let mut input = turn_input("m1", "first", 1); + input.user_message["metadata"] = serde_json::json!({"source": "queue"}); + db.rt_enqueue_turn_in_org("org", "thread", &input).unwrap(); + let claimed = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + + let first = match db.rt_begin_claimed_turn(&claimed).unwrap() { + RtTurnBeginOutcome::Begun(message) => message, + other => panic!("expected begun user message, got {other:?}"), + }; + assert_eq!(first.id, "m1"); + assert_eq!(first.role, "user"); + assert_eq!(first.metadata, Some(serde_json::json!({"source": "queue"}))); + assert_eq!( + db.rt_get_thread("thread").unwrap().unwrap().status, + "in_progress" + ); + assert_eq!( + db.rt_begin_claimed_turn(&claimed).unwrap(), + RtTurnBeginOutcome::Begun(first.clone()), + "an identical retry validates the existing user without duplicating it" + ); + assert_eq!( + db.rt_list_messages_in_org("org", "thread", 100, 0, false) + .unwrap() + .0, + [first] + ); + assert_eq!( + db.rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .as_slice(), + std::slice::from_ref(&claimed), + "begin never removes the active claim" + ); + } + + #[test] + fn begin_claim_cancellation_or_stale_owner_writes_nothing() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + db.rt_enqueue_turn_in_org("org", "thread", &turn_input("m1", "first", 1)) + .unwrap(); + let claimed = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + let initial_status = db.rt_get_thread("thread").unwrap().unwrap().status; + let mut stale = claimed.clone(); + stale.claim_token = Some("other-owner".to_string()); + assert_eq!( + db.rt_begin_claimed_turn(&stale).unwrap(), + RtTurnBeginOutcome::Stale + ); + assert!(db.rt_get_message_in_org("org", "m1").unwrap().is_none()); + assert_eq!( + db.rt_get_thread("thread").unwrap().unwrap().status, + initial_status + ); + + db.rt_cancel_turn_in_org("org", "thread", &claimed.workflow_id) + .unwrap(); + assert_eq!( + db.rt_begin_claimed_turn(&claimed).unwrap(), + RtTurnBeginOutcome::CancelRequested + ); + assert!(db.rt_get_message_in_org("org", "m1").unwrap().is_none()); + assert_eq!( + db.rt_get_thread("thread").unwrap().unwrap().status, + initial_status + ); + } + + #[test] + fn begin_claim_rolls_back_user_when_status_update_fails() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + db.rt_enqueue_turn_in_org("org", "thread", &turn_input("m1", "first", 1)) + .unwrap(); + let claimed = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + let initial_status = db.rt_get_thread("thread").unwrap().unwrap().status; + db.lock() + .execute_batch( + "CREATE TEMP TRIGGER begin_status_failure \ + BEFORE UPDATE OF status ON native_scoped_threads \ + BEGIN SELECT RAISE(ABORT, 'forced begin status failure'); END", + ) + .unwrap(); + + assert!(matches!( + db.rt_begin_claimed_turn(&claimed), + Err(DbError::Sqlite(_)) + )); + db.lock() + .execute_batch("DROP TRIGGER begin_status_failure") + .unwrap(); + assert!(db.rt_get_message_in_org("org", "m1").unwrap().is_none()); + assert_eq!( + db.rt_get_thread("thread").unwrap().unwrap().status, + initial_status + ); + assert_eq!( + db.rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .as_slice(), + std::slice::from_ref(&claimed) + ); + } + + #[test] + fn terminal_commit_cancel_before_begin_persists_user_then_assistant() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + db.rt_enqueue_turn_in_org("org", "thread", &turn_input("m1", "first", 1)) + .unwrap(); + let claimed = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + let assistant_id = claimed_assistant_id(&claimed); + assert!(matches!( + db.rt_cancel_turn_in_org("org", "thread", &claimed.workflow_id) + .unwrap(), + RtTurnCancelOutcome::ActiveCancelRequested(_) + )); + + assert!(matches!( + db.rt_finalize_claimed_turn( + &claimed, + &serde_json::json!([]), + Some(&serde_json::json!({"cancelled": true})), + RtTurnTerminalStatus::Failed, + ) + .unwrap(), + RtTurnTerminalOutcome::Completed(_) + )); + assert_eq!( + db.rt_get_thread("thread").unwrap().unwrap().status, + "failed" + ); + let messages = db + .rt_list_messages_in_org("org", "thread", 100, 0, false) + .unwrap() + .0; + assert_eq!(messages.len(), 2); + assert_eq!( + (messages[0].id.as_str(), messages[0].role.as_str()), + ("m1", "user") + ); + assert_eq!( + ( + messages[1].id.as_str(), + messages[1].role.as_str(), + messages[1].metadata.as_ref(), + ), + ( + assistant_id.as_str(), + "assistant", + Some(&serde_json::json!({"cancelled": true})), + ) + ); + assert!( + messages[0].seq < messages[1].seq, + "the accepted user must precede its cancellation assistant" + ); + assert!(db + .rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .is_empty()); + } + + #[test] + fn terminal_commit_recovers_an_exact_existing_assistant_without_duplication() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + db.rt_enqueue_turn_in_org("org", "thread", &turn_input("m1", "first", 1)) + .unwrap(); + let claimed = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + let parts = serde_json::json!([{"type": "text", "text": "Should I keep going?"}]); + let metadata = serde_json::json!({"finishReason": "stop"}); + let assistant_id = claimed_assistant_id(&claimed); + assert!(matches!( + db.rt_begin_claimed_turn(&claimed).unwrap(), + RtTurnBeginOutcome::Begun(_) + )); + let existing = db + .rt_append_message( + &assistant_id, + &claimed.fence.thread_id, + "assistant", + &parts, + Some(&metadata), + ) + .unwrap(); + + let recovered = match db + .rt_finalize_claimed_turn( + &claimed, + &parts, + Some(&metadata), + RtTurnTerminalStatus::RequiresAction, + ) + .unwrap() + { + RtTurnTerminalOutcome::Completed(message) => message, + RtTurnTerminalOutcome::Quarantined => panic!("healthy claim must not quarantine"), + RtTurnTerminalOutcome::Stale => panic!("orphan claim is still owned"), + }; + assert_eq!(recovered, existing, "existing row remains byte-identical"); + assert_eq!( + db.rt_list_messages_in_org("org", "thread", 100, 0, false) + .unwrap() + .0 + .iter() + .filter(|message| message.id == assistant_id) + .count(), + 1 + ); + assert_eq!( + db.rt_get_thread("thread").unwrap().unwrap().status, + "requires_action", + "exact-pair crash adoption must preserve the resolved pause status" + ); + assert!(db + .rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .is_empty()); + } + + #[test] + fn terminal_commit_stale_claim_changes_nothing() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + db.rt_enqueue_turn_in_org("org", "thread", &turn_input("m1", "first", 1)) + .unwrap(); + let claimed = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + assert!(matches!( + db.rt_begin_claimed_turn(&claimed).unwrap(), + RtTurnBeginOutcome::Begun(_) + )); + let mut stale = claimed.clone(); + let assistant_id = claimed_assistant_id(&claimed); + stale.claim_token = Some("not-the-owner".to_string()); + + assert_eq!( + db.rt_finalize_claimed_turn( + &stale, + &serde_json::json!([]), + None, + RtTurnTerminalStatus::Completed, + ) + .unwrap(), + RtTurnTerminalOutcome::Stale + ); + assert!(db + .rt_get_message_in_org("org", &assistant_id) + .unwrap() + .is_none()); + assert_eq!( + db.rt_get_thread("thread").unwrap().unwrap().status, + "in_progress" + ); + assert_eq!( + db.rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .as_slice(), + std::slice::from_ref(&claimed) + ); + + let mut malformed_claim = claimed; + malformed_claim.claim_token = None; + assert!(matches!( + db.rt_finalize_claimed_turn( + &malformed_claim, + &serde_json::json!([]), + None, + RtTurnTerminalStatus::Completed, + ), + Err(DbError::InvalidQueueData(_)) + )); + } + + #[test] + fn terminal_commit_conflict_rolls_back_status_and_queue_delete() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + db.rt_enqueue_turn_in_org("org", "thread", &turn_input("m1", "first", 1)) + .unwrap(); + let claimed = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + let assistant_id = claimed_assistant_id(&claimed); + assert!(matches!( + db.rt_begin_claimed_turn(&claimed).unwrap(), + RtTurnBeginOutcome::Begun(_) + )); + db.rt_append_message( + &assistant_id, + &claimed.fence.thread_id, + "assistant", + &serde_json::json!([{"type": "text", "text": "original"}]), + None, + ) + .unwrap(); + + assert!(matches!( + db.rt_finalize_claimed_turn( + &claimed, + &serde_json::json!([{"type": "text", "text": "conflict"}]), + None, + RtTurnTerminalStatus::Completed, + ), + Err(DbError::IdempotencyConflict { + entity: "rt_message", + ref id, + }) if id == &assistant_id + )); + assert_eq!( + db.rt_get_thread("thread").unwrap().unwrap().status, + "in_progress" + ); + assert_eq!( + db.rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .as_slice(), + std::slice::from_ref(&claimed) + ); + assert_eq!( + db.rt_get_message_in_org("org", &assistant_id) + .unwrap() + .unwrap() + .parts, + serde_json::json!([{"type": "text", "text": "original"}]) + ); + } + + #[test] + fn terminal_commit_rolls_back_assistant_when_status_or_delete_fails() { + for failure_point in ["status", "delete"] { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + db.rt_enqueue_turn_in_org("org", "thread", &turn_input("m1", "first", 1)) + .unwrap(); + let claimed = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + let assistant_id = claimed_assistant_id(&claimed); + assert!(matches!( + db.rt_begin_claimed_turn(&claimed).unwrap(), + RtTurnBeginOutcome::Begun(_) + )); + let trigger = match failure_point { + "status" => { + "CREATE TEMP TRIGGER terminal_failure \ + BEFORE UPDATE OF status ON native_scoped_threads \ + BEGIN SELECT RAISE(ABORT, 'forced status failure'); END" + } + "delete" => { + "CREATE TEMP TRIGGER terminal_failure \ + BEFORE DELETE ON native_scoped_turn_queue \ + BEGIN SELECT RAISE(ABORT, 'forced delete failure'); END" + } + _ => unreachable!(), + }; + db.lock().execute_batch(trigger).unwrap(); + + assert!(matches!( + db.rt_finalize_claimed_turn( + &claimed, + &serde_json::json!([{"type": "text", "text": "must roll back"}]), + None, + RtTurnTerminalStatus::Completed, + ), + Err(DbError::Sqlite(_)) + )); + db.lock() + .execute_batch("DROP TRIGGER terminal_failure") + .unwrap(); + assert!(db + .rt_get_message_in_org("org", &assistant_id) + .unwrap() + .is_none()); + assert_eq!( + db.rt_get_thread("thread").unwrap().unwrap().status, + "in_progress", + "{failure_point} failure must roll back the status write" + ); + assert_eq!( + db.rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .as_slice(), + std::slice::from_ref(&claimed), + "{failure_point} failure must retain the owned claim" + ); + } + } + + #[test] + fn durable_turn_cancel_removes_queued_and_marks_active() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + for (id, at) in [("m1", 1), ("m2", 2)] { + db.rt_enqueue_turn_in_org("org", "thread", &turn_input(id, id, at)) + .unwrap(); + } + let active = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + assert!(matches!( + db.rt_cancel_turn_in_org("org", "thread", "workflow-m2") + .unwrap(), + RtTurnCancelOutcome::QueuedDeleted(item) if item.message_id == "m2" + )); + let interrupted = match db + .rt_cancel_turn_in_org("org", "thread", "workflow-m1") + .unwrap() + { + RtTurnCancelOutcome::ActiveCancelRequested(item) => item, + other => panic!("expected active cancellation, got {other:?}"), + }; + assert_eq!(interrupted.state, RtTurnQueueState::CancelRequested); + assert_eq!(interrupted.claim_token, active.claim_token); + assert!(matches!( + db.rt_finalize_claimed_turn( + &interrupted, + &serde_json::json!([]), + Some(&serde_json::json!({"cancelled": true})), + RtTurnTerminalStatus::Failed, + ) + .unwrap(), + RtTurnTerminalOutcome::Completed(_) + )); + assert!(matches!( + db.rt_cancel_turn_in_org("org", "thread", "missing") + .unwrap(), + RtTurnCancelOutcome::NotFound + )); + } + + #[test] + fn cancel_all_prevents_tail_promotion_and_preserves_active_for_signal() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + for (id, at) in [("m1", 1), ("m2", 2), ("m3", 3)] { + db.rt_enqueue_turn_in_org("org", "thread", &turn_input(id, id, at)) + .unwrap(); + } + let active = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + let outcome = db.rt_cancel_all_turns_in_org(&active.fence).unwrap(); + assert_eq!( + outcome.queued_retained_workflow_ids, + ["workflow-m2", "workflow-m3"] + ); + assert_eq!(outcome.active_workflow_ids, ["workflow-m1"]); + let remaining = db.rt_list_turn_queue_in_org("org", "thread").unwrap(); + assert_eq!(remaining.len(), 3); + assert_eq!(remaining[0].state, RtTurnQueueState::CancelRequested); + assert!(remaining[1..] + .iter() + .all(|item| item.state == RtTurnQueueState::Queued)); + assert!(db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .is_none()); + } + + #[test] + fn failed_thread_delete_retains_accepted_tails_and_stays_durably_closed() { + let dir = tempfile::tempdir().unwrap(); + let db = ThreadsDb::open(dir.path()).unwrap(); + create_rt_thread(&db, "org", "thread"); + for (id, at) in [("m1", 1), ("m2", 2)] { + db.rt_enqueue_turn_in_org("org", "thread", &turn_input(id, id, at)) + .unwrap(); + } + let fence = db.rt_thread_fence_in_org("org", "thread").unwrap().unwrap(); + assert!(db.rt_mark_thread_delete_pending(&fence).unwrap()); + let cancelled = db.rt_cancel_all_turns_in_org(&fence).unwrap(); + assert_eq!( + cancelled.queued_retained_workflow_ids, + ["workflow-m1", "workflow-m2"] + ); + assert!(db + .rt_claim_turn_queue_head_fenced(&fence) + .unwrap() + .is_none()); + assert!(matches!( + db.rt_enqueue_turn_in_org("org", "thread", &turn_input("m3", "blocked", 3)), + Err(DbError::ThreadDeletePending { .. }) + )); + + db.lock() + .execute_batch( + "CREATE TEMP TRIGGER injected_thread_delete_failure \ + BEFORE DELETE ON native_scoped_threads \ + BEGIN SELECT RAISE(ABORT, 'injected delete failure'); END", + ) + .unwrap(); + assert!(matches!( + db.rt_delete_thread_in_org_if_generation(&fence), + Err(DbError::Sqlite(_)) + )); + assert!(db.rt_thread_delete_pending(&fence).unwrap()); + assert_eq!( + db.rt_list_turn_queue_in_org("org", "thread").unwrap().len(), + 2, + "a failed final cascade must retain every accepted tail" + ); + + drop(db); + let db = ThreadsDb::open(dir.path()).unwrap(); + assert!(matches!( + db.rt_create_thread( + Some("thread"), + "org", + "must stay closed", + None, + "vmcp", + None, + "user", + ), + Err(DbError::ThreadDeletePending { .. }) + )); + assert!(matches!( + db.rt_update_thread_in_org( + "org", + "thread", + "user", + &RtThreadPatch { + title: Some("must not update".to_string()), + ..RtThreadPatch::default() + }, + ), + Err(DbError::ThreadDeletePending { .. }) + )); + assert_eq!( + db.rt_get_thread("thread").unwrap().unwrap().title, + "", + "GET stays available so the caller can retry DELETE" + ); + assert_eq!( + db.rt_list_turn_queue_in_org("org", "thread").unwrap().len(), + 2, + "reopen must not consume accepted tails" + ); + assert!(db.rt_delete_thread_in_org_if_generation(&fence).unwrap()); + assert_eq!( + db.lock() + .query_row("SELECT COUNT(*) FROM native_scoped_turn_queue", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 0 + ); + } + + #[test] + fn recovery_never_reclaims_a_possibly_side_effecting_orphan() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread-a"); + create_rt_thread(&db, "org", "thread-b"); + for (id, at) in [("a1", 1), ("a2", 2)] { + db.rt_enqueue_turn_in_org("org", "thread-a", &turn_input(id, id, at)) + .unwrap(); + } + db.rt_enqueue_turn_in_org("org", "thread-b", &turn_input("b1", "b1", 3)) + .unwrap(); + let orphan = db + .rt_claim_turn_queue_head_in_org("org", "thread-a") + .unwrap() + .unwrap(); + + assert_eq!( + db.rt_list_orphaned_active_turns_isolated().unwrap(), + [RtOrphanedTurn::Ready(orphan.clone())] + ); + assert!(db + .rt_claim_turn_queue_head_in_org("org", "thread-a") + .unwrap() + .is_none()); + let recoverable = db.rt_list_recoverable_turn_queues().unwrap(); + assert_eq!( + recoverable + .iter() + .map(|fence| fence.thread_id.as_str()) + .collect::>(), + ["thread-a", "thread-b"] + ); + + assert!(matches!( + db.rt_begin_claimed_turn(&orphan).unwrap(), + RtTurnBeginOutcome::Begun(_) + )); + assert!(matches!( + db.rt_finalize_claimed_turn( + &orphan, + &serde_json::json!([{"type": "text", "text": "interrupted"}]), + Some(&serde_json::json!({"interrupted": true})), + RtTurnTerminalStatus::Failed, + ) + .unwrap(), + RtTurnTerminalOutcome::Completed(_) + )); + assert_eq!( + db.rt_claim_turn_queue_head_in_org("org", "thread-a") + .unwrap() + .unwrap() + .message_id, + "a2" + ); + } + + #[test] + fn malformed_orphan_is_isolated_and_can_be_finalized_without_bricking_others() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "broken-thread"); + create_rt_thread(&db, "org", "healthy-thread"); + for (id, at) in [("broken-1", 1), ("broken-2", 2)] { + db.rt_enqueue_turn_in_org("org", "broken-thread", &turn_input(id, id, at)) + .unwrap(); + } + db.rt_enqueue_turn_in_org( + "org", + "healthy-thread", + &turn_input("healthy-1", "healthy", 3), + ) + .unwrap(); + let broken_claim = db + .rt_claim_turn_queue_head_in_org("org", "broken-thread") + .unwrap() + .unwrap(); + let healthy_claim = db + .rt_claim_turn_queue_head_in_org("org", "healthy-thread") + .unwrap() + .unwrap(); + db.lock() + .execute( + "UPDATE native_scoped_turn_queue SET user_message_json = '{broken-json' \ + WHERE workflow_id = ?1", + [&broken_claim.workflow_id], + ) + .unwrap(); + + let isolated = db.rt_list_orphaned_active_turns_isolated().unwrap(); + assert_eq!(isolated.len(), 2); + let malformed = isolated + .iter() + .find_map(|item| match item { + RtOrphanedTurn::Malformed(item) => Some(item.clone()), + RtOrphanedTurn::Ready(_) => None, + }) + .expect("broken row must be returned with its claim identity"); + assert_eq!(malformed.workflow_id, broken_claim.workflow_id); + assert!(malformed.error.contains("json error")); + assert!(isolated + .iter() + .any(|item| matches!(item, RtOrphanedTurn::Ready(item) if item == &healthy_claim))); + + let mut stale = malformed.clone(); + stale.claim_token = Some("other-owner".to_string()); + assert_eq!( + db.rt_finalize_malformed_orphan( + &stale, + &serde_json::json!([{"type": "text", "text": "must not persist"}]), + None, + ) + .unwrap(), + RtTurnTerminalOutcome::Stale + ); + + assert_eq!( + db.rt_finalize_malformed_orphan( + &malformed, + &serde_json::json!([{"type": "text", "text": "must not persist"}]), + None, + ) + .unwrap(), + RtTurnTerminalOutcome::Quarantined + ); + assert!(db + .rt_list_messages_in_org("org", "broken-thread", 100, 0, false) + .unwrap() + .0 + .is_empty()); + assert_eq!( + db.rt_get_thread("broken-thread").unwrap().unwrap().status, + "failed" + ); + assert_eq!( + db.rt_list_orphaned_active_turns_isolated().unwrap(), + [RtOrphanedTurn::Ready(healthy_claim)], + "the unrelated healthy orphan remains recoverable" + ); + assert_eq!( + db.rt_claim_turn_queue_head_in_org("org", "broken-thread") + .unwrap() + .unwrap() + .message_id, + "broken-2", + "quarantining the corrupt head unblocks only its own safe tail" + ); + } + + #[test] + fn durable_turn_queue_survives_database_reopen() { + let dir = tempfile::tempdir().unwrap(); + let expected_claim = { + let db = ThreadsDb::open(dir.path()).unwrap(); + create_rt_thread(&db, "org", "thread"); + for (id, at) in [("m1", 1), ("m2", 2)] { + db.rt_enqueue_turn_in_org("org", "thread", &turn_input(id, id, at)) + .unwrap(); + } + db.rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap() + }; + + let reopened = ThreadsDb::open(dir.path()).unwrap(); + assert_eq!( + reopened.rt_list_orphaned_active_turns_isolated().unwrap(), + [RtOrphanedTurn::Ready(expected_claim)] + ); + let queue = reopened.rt_list_turn_queue_in_org("org", "thread").unwrap(); + assert_eq!( + queue + .iter() + .map(|item| (item.message_id.as_str(), item.state)) + .collect::>(), + [ + ("m1", RtTurnQueueState::Running), + ("m2", RtTurnQueueState::Queued), + ] + ); + assert!(reopened + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .is_none()); + } + + #[test] + fn complete_thread_reads_are_generation_fenced() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + let fence = db.rt_thread_fence_in_org("org", "thread").unwrap().unwrap(); + + let current = db.rt_thread_fenced(&fence).unwrap().unwrap(); + assert_eq!(current.id, "thread"); + assert_eq!(current.organization_id, "org"); + assert_eq!(current.virtual_mcp_id, "vmcp"); + assert_eq!(current.created_by, "user"); + + let mut stale = fence; + stale.generation = "stale-generation".to_string(); + assert!(db.rt_thread_fenced(&stale).unwrap().is_none()); + } + + #[test] + fn harness_pin_reads_are_generation_fenced() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + let fence = db.rt_thread_fence_in_org("org", "thread").unwrap().unwrap(); + + assert_eq!(db.rt_harness_id_fenced(&fence).unwrap(), Some(None)); + assert!(db + .rt_pin_harness_if_unset_fenced(&fence, "codex", None, None) + .unwrap()); + assert_eq!( + db.rt_harness_id_fenced(&fence).unwrap(), + Some(Some("codex".to_string())) + ); + + let mut stale = fence; + stale.generation = "stale-generation".to_string(); + assert_eq!(db.rt_harness_id_fenced(&stale).unwrap(), None); + } + + #[test] + fn thread_generation_fences_old_worker_writes_and_delete_retires_id() { + let db = ThreadsDb::open_in_memory().unwrap(); + create_rt_thread(&db, "org", "thread"); + let old_fence = db.rt_thread_fence_in_org("org", "thread").unwrap().unwrap(); + assert!(db + .lock() + .execute( + "UPDATE native_scoped_threads SET generation = 'forged' WHERE id = 'thread'", + [], + ) + .is_err()); + db.rt_enqueue_turn_in_org("org", "thread", &turn_input("m1", "old", 1)) + .unwrap(); + let old_claim = db + .rt_claim_turn_queue_head_in_org("org", "thread") + .unwrap() + .unwrap(); + let stale_assistant_id = claimed_assistant_id(&old_claim); + assert!(db + .rt_delete_thread_in_org_if_generation(&old_fence) + .unwrap()); + assert!(db + .rt_list_turn_queue_in_org("org", "thread") + .unwrap() + .is_empty()); + assert_eq!( + db.lock() + .query_row("SELECT COUNT(*) FROM native_scoped_turn_queue", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 0, + "thread deletion must physically cascade its durable queue rows" + ); + assert!(!db + .rt_pin_harness_if_unset_fenced(&old_fence, "claude-code", Some("user-desktop"), None,) + .unwrap()); + assert_eq!( + db.rt_last_assistant_session_fenced(&old_fence, "claude-code") + .unwrap(), + None + ); + assert!(!db + .rt_delete_thread_in_org_if_generation(&old_fence) + .unwrap()); + assert_eq!( + db.rt_finalize_claimed_turn( + &old_claim, + &serde_json::json!([]), + None, + RtTurnTerminalStatus::Failed, + ) + .unwrap(), + RtTurnTerminalOutcome::Stale + ); + assert!(db + .rt_get_message_in_org("org", &stale_assistant_id) + .unwrap() + .is_none()); + assert!(db.rt_get_thread("thread").unwrap().is_none()); + assert!(db + .rt_claim_turn_queue_head_fenced(&old_fence) + .unwrap() + .is_none()); + + assert!(matches!( + db.rt_create_thread( + Some("thread"), + "org", + "must stay deleted", + None, + "vmcp", + None, + "user", + ), + Err(DbError::RetiredThreadId { ref thread_id, .. }) if thread_id == "thread" + )); + } + + #[test] + fn stale_generation_delete_does_not_tombstone_and_live_create_is_idempotent() { + let db = ThreadsDb::open_in_memory().unwrap(); + let scope = RtAccountScope::new("studio.decocms.com", "alice").unwrap(); + let first = db + .rt_create_thread_scoped( + &scope, + Some("live-idempotent"), + "org", + "original", + None, + "vmcp", + None, + "alice", + ) + .unwrap(); + let fence = db + .rt_thread_fence_in_scope(&scope, "org", &first.id) + .unwrap() + .unwrap(); + + let mut stale = fence.clone(); + stale.generation = "not-the-live-generation".to_string(); + assert!(!db.rt_delete_thread_in_org_if_generation(&stale).unwrap()); + assert!( + !rt_thread_is_tombstoned(&db.lock(), &scope.storage_key(), "org", &first.id,).unwrap() + ); + + let duplicate = db + .rt_create_thread_scoped( + &scope, + Some(&first.id), + "org", + "ignored retry title", + None, + "different-vmcp", + Some("ignored-branch"), + "alice", + ) + .unwrap(); + assert_eq!(duplicate.id, first.id); + assert_eq!(duplicate.title, "original"); + assert_eq!( + db.rt_thread_fence_in_scope(&scope, "org", &first.id) + .unwrap() + .unwrap(), + fence, + "a live idempotent retry must retain the original generation" + ); + } + + #[test] + fn retired_thread_ids_are_scoped_by_account_and_organization() { + let db = ThreadsDb::open_in_memory().unwrap(); + let alice = RtAccountScope::new("studio.decocms.com", "alice").unwrap(); + let bob = RtAccountScope::new("studio.decocms.com", "bob").unwrap(); + let id = "scope-local-retired-id"; + + db.rt_create_thread_scoped( + &alice, + Some(id), + "org-a", + "alice org a", + None, + "vmcp", + None, + "alice", + ) + .unwrap(); + let alice_org_a = db + .rt_thread_fence_in_scope(&alice, "org-a", id) + .unwrap() + .unwrap(); + assert!(db + .rt_delete_thread_in_org_if_generation(&alice_org_a) + .unwrap()); + + // The same account may use the same opaque public id in another org. + db.rt_create_thread_scoped( + &alice, + Some(id), + "org-b", + "alice org b", + None, + "vmcp", + None, + "alice", + ) + .unwrap(); + let alice_org_b = db + .rt_thread_fence_in_scope(&alice, "org-b", id) + .unwrap() + .unwrap(); + assert!(db + .rt_delete_thread_in_org_if_generation(&alice_org_b) + .unwrap()); + + // A different signed-in account is an independent local tenant too. + db.rt_create_thread_scoped( + &bob, + Some(id), + "org-a", + "bob org a", + None, + "vmcp", + None, + "bob", + ) + .unwrap(); + let bob_org_a = db + .rt_thread_fence_in_scope(&bob, "org-a", id) + .unwrap() + .unwrap(); + assert!(db + .rt_delete_thread_in_org_if_generation(&bob_org_a) + .unwrap()); + + assert!(matches!( + db.rt_create_thread_scoped( + &alice, + Some(id), + "org-a", + "must remain retired", + None, + "vmcp", + None, + "alice", + ), + Err(DbError::RetiredThreadId { + ref organization_id, + ref thread_id, + .. + }) if organization_id == "org-a" && thread_id == id + )); + } + + #[test] + fn rt_append_message_is_idempotent_by_id_and_does_not_consume_seq() { + let db = ThreadsDb::open_in_memory().unwrap(); + db.rt_create_thread(Some("idem"), "org", "", None, "vmcp", None, "user") + .unwrap(); + let original_parts = serde_json::json!([{"type": "text", "text": "original"}]); + let original_metadata = serde_json::json!({"attempt": 1}); + assert!(db.rt_get_message("stable-id").unwrap().is_none()); + let first = db + .rt_append_message( + "stable-id", + "idem", + "user", + &original_parts, + Some(&original_metadata), + ) + .unwrap(); + assert_eq!( + db.rt_get_message("stable-id").unwrap().unwrap().seq, + first.seq + ); + let updated_after_first = db.rt_get_thread("idem").unwrap().unwrap().updated_at; + std::thread::sleep(Duration::from_millis(2)); + let repeated = db + .rt_append_message( + "stable-id", + "idem", + "user", + &original_parts, + Some(&original_metadata), + ) + .unwrap(); + assert_eq!(repeated.role, "user"); + assert_eq!(repeated.parts, original_parts); + assert_eq!(repeated.metadata, Some(original_metadata)); + assert_eq!(repeated.seq, first.seq); + assert_eq!(repeated.created_at, first.created_at); + assert_eq!( + db.rt_get_thread("idem").unwrap().unwrap().updated_at, + updated_after_first, + "an ignored retry must not bump its thread" + ); + + let next = db + .rt_append_message("next-id", "idem", "assistant", &serde_json::json!([]), None) + .unwrap(); + assert_eq!(next.seq, first.seq + 1); + assert_eq!(db.rt_list_messages("idem", 100, 0, false).unwrap().1, 2); + } + + #[test] + fn rt_append_message_rejects_same_id_with_different_semantics() { + let db = ThreadsDb::open_in_memory().unwrap(); + for id in ["original-thread", "different-thread"] { + db.rt_create_thread(Some(id), "org", "", None, "vmcp", None, "user") + .unwrap(); + } + let parts = serde_json::json!([{"type": "text", "text": "original"}]); + let metadata = serde_json::json!({"attempt": 1}); + db.rt_append_message( + "stable-id", + "original-thread", + "user", + &parts, + Some(&metadata), + ) + .unwrap(); + + let conflicts = [ + db.rt_append_message( + "stable-id", + "different-thread", + "user", + &parts, + Some(&metadata), + ), + db.rt_append_message( + "stable-id", + "original-thread", + "assistant", + &parts, + Some(&metadata), + ), + db.rt_append_message( + "stable-id", + "original-thread", + "user", + &serde_json::json!([{"type": "text", "text": "different"}]), + Some(&metadata), + ), + db.rt_append_message( + "stable-id", + "original-thread", + "user", + &parts, + Some(&serde_json::json!({"attempt": 2})), + ), + ]; + for conflict in conflicts { + assert!(matches!( + conflict, + Err(DbError::IdempotencyConflict { + entity: "rt_message", + ref id + }) if id == "stable-id" + )); + } + let (messages, total) = db + .rt_list_messages("original-thread", 100, 0, false) + .unwrap(); + assert_eq!(total, 1); + assert_eq!(messages[0].parts, parts); + assert_eq!( + db.rt_list_messages("different-thread", 100, 0, false) + .unwrap() + .1, + 0 + ); + } + + #[test] + fn rt_message_sequence_is_scoped_per_thread() { + let db = ThreadsDb::open_in_memory().unwrap(); + for id in ["one", "two"] { + db.rt_create_thread(Some(id), "org", "", None, "vmcp", None, "user") + .unwrap(); + } + let one_first = db + .rt_append_message("one-1", "one", "user", &serde_json::json!([]), None) + .unwrap(); + let two_first = db + .rt_append_message("two-1", "two", "user", &serde_json::json!([]), None) + .unwrap(); + let one_second = db + .rt_append_message("one-2", "one", "assistant", &serde_json::json!([]), None) + .unwrap(); + assert_eq!((one_first.seq, two_first.seq, one_second.seq), (1, 1, 2)); + } + + #[test] + fn rt_list_messages_desc_returns_the_newest_page_not_the_oldest() { + let db = ThreadsDb::open_in_memory().unwrap(); + db.rt_create_thread(Some("t1"), "org", "", None, "vmcp", None, "user") + .unwrap(); + // 6 messages (3 turns), appended oldest -> newest. + for i in 0..6 { + db.rt_append_message( + &format!("m{i}"), + "t1", + if i % 2 == 0 { "user" } else { "assistant" }, + &serde_json::json!([]), + None, + ) + .unwrap(); + std::thread::sleep(Duration::from_millis(2)); + } + // desc, limit 2 -> the NEWEST 2 (this was the bug: it used to return + // the oldest 2 regardless, dropping the recent turn out of the window). + let (page, total) = db.rt_list_messages("t1", 2, 0, true).unwrap(); + assert_eq!(total, 6); + assert_eq!( + page.iter().map(|m| m.id.as_str()).collect::>(), + vec!["m5", "m4"], + ); + // asc still returns the oldest page (prior default behavior). + let (page_asc, _) = db.rt_list_messages("t1", 2, 0, false).unwrap(); + assert_eq!( + page_asc.iter().map(|m| m.id.as_str()).collect::>(), + vec!["m0", "m1"], + ); + } + + #[test] + fn rt_list_messages_exposes_monotonic_ascending_seq() { + // Durable `seq` is insert-order and clock-independent — no + // sleeps needed (unlike the `created_at`-based ordering test above). + let db = ThreadsDb::open_in_memory().unwrap(); + db.rt_create_thread(Some("seqt"), "org", "", None, "vmcp", None, "user") + .unwrap(); + let user = db + .rt_append_message("mu", "seqt", "user", &serde_json::json!([]), None) + .unwrap(); + let assistant = db + .rt_append_message("ma", "seqt", "assistant", &serde_json::json!([]), None) + .unwrap(); + // `rt_append_message` returns the row's monotonic ordinal: the user + // turn (inserted first) precedes its assistant reply. + assert!( + user.seq < assistant.seq, + "user row's seq must precede the assistant's" + ); + + // The asc list page carries the same strictly-increasing seq. + let (page, total) = db.rt_list_messages("seqt", 100, 0, false).unwrap(); + assert_eq!(total, 2); + assert_eq!( + page.iter().map(|m| m.id.as_str()).collect::>(), + ["mu", "ma"] + ); + assert!( + page[0].seq < page[1].seq, + "asc page seqs must be strictly increasing" + ); + + // The desc page carries the SAME ordinals, just newest-first — so the + // frontend's seq sort reconstructs identical order regardless of the + // page direction it fetched. + let (page_desc, _) = db.rt_list_messages("seqt", 100, 0, true).unwrap(); + assert_eq!( + page_desc.iter().map(|m| m.id.as_str()).collect::>(), + ["ma", "mu"] + ); + assert!(page_desc[0].seq > page_desc[1].seq); + // Same underlying ordinals in both directions. + assert_eq!(page[0].seq, page_desc[1].seq); + assert_eq!(page[1].seq, page_desc[0].seq); + } + + #[test] + fn format_rfc3339_epoch_zero() { + assert_eq!(format_rfc3339(Duration::ZERO), "1970-01-01T00:00:00.000Z"); + } + + #[test] + fn format_rfc3339_known_date() { + // 1704067200 == 2024-01-01T00:00:00Z + assert_eq!( + format_rfc3339(Duration::from_millis(1_704_067_200_123)), + "2024-01-01T00:00:00.123Z" + ); + } + + #[test] + fn create_and_get_thread_roundtrip() { + let db = ThreadsDb::open_in_memory().unwrap(); + let t = db.create_thread("hello".to_string()).unwrap(); + assert_eq!(t.title, "hello"); + assert_eq!(t.created_at, t.updated_at); + let fetched = db.get_thread(&t.id).unwrap().unwrap(); + assert_eq!(fetched.id, t.id); + assert_eq!(fetched.title, "hello"); + } + + #[test] + fn get_unknown_thread_is_none() { + let db = ThreadsDb::open_in_memory().unwrap(); + assert!(db.get_thread("nope").unwrap().is_none()); + } + + #[test] + fn list_threads_orders_newest_updated_first() { + // Millisecond-resolution timestamps: sleep between steps so this + // assertion can't tie-break on `rowid` instead of `updated_at` on a + // fast machine that completes two ops within the same millisecond. + let db = ThreadsDb::open_in_memory().unwrap(); + let a = db.create_thread("a".to_string()).unwrap(); + std::thread::sleep(Duration::from_millis(5)); + let b = db.create_thread("b".to_string()).unwrap(); + std::thread::sleep(Duration::from_millis(5)); + // Bump `a`'s updated_at past `b`'s by appending a message to it. + db.create_message(&a.id, "user", &Value::Array(vec![])) + .unwrap(); + let listed = db.list_threads().unwrap(); + assert_eq!(listed[0].id, a.id); + assert_eq!(listed[1].id, b.id); + } + + #[test] + fn update_title_bumps_updated_at() { + let db = ThreadsDb::open_in_memory().unwrap(); + let t = db.create_thread("a".to_string()).unwrap(); + let updated = db.update_thread_title(&t.id, "renamed").unwrap().unwrap(); + assert_eq!(updated.title, "renamed"); + assert!(updated.updated_at >= t.updated_at); + } + + #[test] + fn update_unknown_thread_is_none() { + let db = ThreadsDb::open_in_memory().unwrap(); + assert!(db.update_thread_title("nope", "x").unwrap().is_none()); + } + + #[test] + fn delete_thread_is_idempotent_and_cascades() { + let db = ThreadsDb::open_in_memory().unwrap(); + let t = db.create_thread("a".to_string()).unwrap(); + db.create_message(&t.id, "user", &Value::Array(vec![])) + .unwrap(); + db.delete_thread(&t.id).unwrap(); + assert!(db.get_thread(&t.id).unwrap().is_none()); + assert!(db.list_messages(&t.id).unwrap().is_empty()); + // Deleting again must not error. + db.delete_thread(&t.id).unwrap(); + } + + #[test] + fn create_message_bumps_thread_updated_at_and_round_trips_parts() { + let db = ThreadsDb::open_in_memory().unwrap(); + let t = db.create_thread("a".to_string()).unwrap(); + let parts = serde_json::json!([{"type": "text", "text": "hi"}]); + let m = db.create_message(&t.id, "user", &parts).unwrap(); + assert_eq!(m.parts, parts); + let refreshed = db.get_thread(&t.id).unwrap().unwrap(); + assert!(refreshed.updated_at >= t.updated_at); + let messages = db.list_messages(&t.id).unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].parts, parts); + } + + #[test] + fn list_runs_empty_for_fresh_thread() { + let db = ThreadsDb::open_in_memory().unwrap(); + let t = db.create_thread("a".to_string()).unwrap(); + assert!(db.list_runs(&t.id).unwrap().is_empty()); + } + + #[test] + fn thread_exists_true_and_false() { + let db = ThreadsDb::open_in_memory().unwrap(); + let t = db.create_thread("a".to_string()).unwrap(); + assert!(db.thread_exists(&t.id).unwrap()); + assert!(!db.thread_exists("nope").unwrap()); + } + + #[test] + fn create_thread_with_id_is_idempotent_and_uses_the_given_id() { + let db = ThreadsDb::open_in_memory().unwrap(); + let first = db.create_thread_with_id("thread-abc", "").unwrap(); + assert_eq!(first.id, "thread-abc"); + let second = db + .create_thread_with_id("thread-abc", "ignored title") + .unwrap(); + assert_eq!(second.id, "thread-abc"); + assert_eq!( + second.title, "", + "a repeated create must not overwrite the original title" + ); + assert_eq!(db.list_threads().unwrap().len(), 1); + } + + #[test] + fn create_message_with_id_is_idempotent_on_repeated_calls() { + let db = ThreadsDb::open_in_memory().unwrap(); + let t = db.create_thread("a".to_string()).unwrap(); + let parts = serde_json::json!([{"type": "text", "text": "hi"}]); + let first = db + .create_message_with_id("run-1-user", &t.id, "user", &parts) + .unwrap(); + let second = db + .create_message_with_id("run-1-user", &t.id, "user", &parts) + .unwrap(); + assert_eq!(first.id, second.id); + assert_eq!(first.created_at, second.created_at); + // Only ONE row — a re-poll must not duplicate the message. + let messages = db.list_messages(&t.id).unwrap(); + assert_eq!(messages.len(), 1); + } + + #[test] + fn create_message_with_id_only_bumps_updated_at_on_the_actual_insert() { + let db = ThreadsDb::open_in_memory().unwrap(); + let t = db.create_thread("a".to_string()).unwrap(); + let parts = serde_json::json!([]); + db.create_message_with_id("run-1-user", &t.id, "user", &parts) + .unwrap(); + let after_first = db.get_thread(&t.id).unwrap().unwrap().updated_at; + std::thread::sleep(Duration::from_millis(5)); + db.create_message_with_id("run-1-user", &t.id, "user", &parts) + .unwrap(); + let after_second = db.get_thread(&t.id).unwrap().unwrap().updated_at; + assert_eq!( + after_first, after_second, + "a re-poll's ignored insert must not re-bump updated_at" + ); + } + + #[test] + fn create_run_is_idempotent_and_starts_running() { + let db = ThreadsDb::open_in_memory().unwrap(); + let t = db.create_thread("a".to_string()).unwrap(); + let first = db.create_run("run-1", &t.id, "claude-code").unwrap(); + assert_eq!(first.status, "running"); + assert_eq!(first.harness_id, "claude-code"); + let second = db.create_run("run-1", &t.id, "claude-code").unwrap(); + assert_eq!(first.created_at, second.created_at); + let runs = db.list_runs(&t.id).unwrap(); + assert_eq!(runs.len(), 1, "a re-poll must not duplicate the run row"); + } + + #[test] + fn set_run_terminal_status_transitions_from_running() { + let db = ThreadsDb::open_in_memory().unwrap(); + let t = db.create_thread("a".to_string()).unwrap(); + db.create_run("run-1", &t.id, "codex").unwrap(); + db.set_run_terminal_status("run-1", "completed", None) + .unwrap(); + let runs = db.list_runs(&t.id).unwrap(); + assert_eq!(runs[0].status, "completed"); + assert!(runs[0].ended_at.is_some()); + assert_eq!(runs[0].error, None); + } + + #[test] + fn set_run_terminal_status_records_the_error_on_failure() { + let db = ThreadsDb::open_in_memory().unwrap(); + let t = db.create_thread("a".to_string()).unwrap(); + db.create_run("run-1", &t.id, "codex").unwrap(); + db.set_run_terminal_status("run-1", "failed", Some("boom")) + .unwrap(); + let runs = db.list_runs(&t.id).unwrap(); + assert_eq!(runs[0].status, "failed"); + assert_eq!(runs[0].error.as_deref(), Some("boom")); + } + + #[test] + fn set_run_terminal_status_is_one_way_once_terminal() { + let db = ThreadsDb::open_in_memory().unwrap(); + let t = db.create_thread("a".to_string()).unwrap(); + db.create_run("run-1", &t.id, "codex").unwrap(); + db.set_run_terminal_status("run-1", "completed", None) + .unwrap(); + let completed_ended_at = db.list_runs(&t.id).unwrap()[0].ended_at.clone(); + // A second terminal write (e.g. a racing cancel) must NOT + // overwrite the already-terminal row. + db.set_run_terminal_status("run-1", "cancelled", Some("late cancel")) + .unwrap(); + let runs = db.list_runs(&t.id).unwrap(); + assert_eq!(runs[0].status, "completed"); + assert_eq!(runs[0].error, None); + assert_eq!(runs[0].ended_at, completed_ended_at); + } + + #[test] + fn set_run_terminal_status_on_an_unknown_run_id_is_a_harmless_no_op() { + let db = ThreadsDb::open_in_memory().unwrap(); + // No panic, no error — just nothing to update. + db.set_run_terminal_status("nope", "completed", None) + .unwrap(); + } + + #[test] + fn native_threads_are_isolated_by_upstream_user_and_org() { + let db = ThreadsDb::open_in_memory().unwrap(); + let prod_alice = RtAccountScope::new("studio.decocms.com", "alice").unwrap(); + let dev_alice = RtAccountScope::new("localhost:4000", "alice").unwrap(); + let prod_bob = RtAccountScope::new("studio.decocms.com", "bob").unwrap(); + let thread = db + .rt_create_thread_scoped( + &prod_alice, + Some("account-scoped-thread"), + "shared-org", + "Alice chat", + None, + "vmcp", + None, + "alice", + ) + .unwrap(); + assert_eq!(thread.organization_id, "shared-org"); + assert_eq!(thread.created_by, "alice"); + + for foreign in [&dev_alice, &prod_bob] { + assert!(db + .rt_get_thread_in_scope(foreign, "shared-org", &thread.id) + .unwrap() + .is_none()); + assert!(db + .rt_update_thread_in_scope( + foreign, + "shared-org", + &thread.id, + &foreign.user_id, + &RtThreadPatch { + title: Some("hijacked".to_string()), + ..RtThreadPatch::default() + }, + ) + .unwrap() + .is_none()); + assert_eq!( + db.rt_list_messages_in_scope(foreign, "shared-org", &thread.id, 100, 0, false,) + .unwrap() + .1, + 0 + ); + assert!(db + .rt_list_turn_queue_scoped(foreign, "shared-org", &thread.id) + .unwrap() + .is_empty()); + } + + assert_eq!( + db.rt_get_thread_in_scope(&prod_alice, "shared-org", &thread.id) + .unwrap() + .unwrap() + .title, + "Alice chat" + ); + } + + #[test] + fn creator_filter_resolves_me_to_the_authenticated_user() { + let db = ThreadsDb::open_in_memory().unwrap(); + let scope = RtAccountScope::new("studio.decocms.com", "alice").unwrap(); + for (id, creator) in [("mine", "alice"), ("teammate", "bob")] { + db.rt_create_thread_scoped(&scope, Some(id), "org", id, None, "vmcp", None, creator) + .unwrap(); + } + let (mine, total) = db + .rt_list_threads_scoped( + &scope, + "org", + RtThreadListOptions { + created_by: Some("alice"), + hidden: None, + search: None, + trigger_ids: None, + virtual_mcp_id: None, + has_trigger: None, + start_date: None, + end_date: None, + status: None, + agent_id: None, + limit: 50, + offset: 0, + }, + ) + .unwrap(); + assert_eq!(total, 1); + assert_eq!(mine[0].id, "mine"); + } + + #[test] + fn native_thread_list_filters_items_and_count_with_production_trigger_semantics() { + let db = ThreadsDb::open_in_memory().unwrap(); + let scope = RtAccountScope::new("studio.decocms.com", "alice").unwrap(); + for (id, title, virtual_mcp_id, creator) in [ + ("normal-match", "Needle target", "vm-primary", "alice"), + ("normal-other", "Needle other", "vm-agent", "bob"), + ("no-trigger", "Needle no trigger", "vm-agent", "alice"), + ("hidden-trigger", "Needle hidden", "vm-primary", "alice"), + ("outside-date", "Needle old", "vm-primary", "alice"), + ] { + db.rt_create_thread_scoped( + &scope, + Some(id), + "org", + title, + None, + virtual_mcp_id, + None, + creator, + ) + .unwrap(); + } + { + let conn = db.lock(); + for (id, trigger_id, hidden, status, updated_at) in [ + ( + "normal-match", + Some("trigger-a"), + 0, + "completed", + "2026-01-15T00:00:00.000Z", + ), + ( + "normal-other", + Some("trigger-b"), + 0, + "failed", + "2026-01-20T00:00:00.000Z", + ), + ( + "no-trigger", + None, + 0, + "completed", + "2026-01-10T00:00:00.000Z", + ), + ( + "hidden-trigger", + Some("trigger-a"), + 1, + "completed", + "2026-01-16T00:00:00.000Z", + ), + ( + "outside-date", + Some("trigger-c"), + 0, + "completed", + "2025-12-31T23:59:59.999Z", + ), + ] { + conn.execute( + "UPDATE native_scoped_threads \ + SET trigger_id = ?1, hidden = ?2, status = ?3, updated_at = ?4 \ + WHERE id = ?5", + params![trigger_id, hidden, status, updated_at, id], + ) + .unwrap(); + } + } + + let (items, total) = db + .rt_list_threads_scoped( + &scope, + "org", + RtThreadListOptions { + created_by: Some("alice"), + hidden: Some(false), + search: Some("needle"), + trigger_ids: None, + virtual_mcp_id: Some("vm-primary"), + has_trigger: Some(true), + start_date: Some("2026-01-01T00:00:00.000Z"), + end_date: Some("2026-01-31T23:59:59.999Z"), + status: Some("completed"), + // Production uses virtual_mcp_id first and only falls + // back to agentId when it is absent. + agent_id: Some("vm-agent"), + limit: 100, + offset: 0, + }, + ) + .unwrap(); + assert_eq!(total, 1); + assert_eq!(items.len(), 1); + assert_eq!(items[0].id, "normal-match"); + + let (fallback_items, fallback_total) = db + .rt_list_threads_scoped( + &scope, + "org", + RtThreadListOptions { + created_by: None, + hidden: Some(false), + search: None, + trigger_ids: None, + virtual_mcp_id: None, + has_trigger: Some(false), + start_date: None, + end_date: None, + status: Some("completed"), + agent_id: Some("vm-agent"), + limit: 100, + offset: 0, + }, + ) + .unwrap(); + assert_eq!(fallback_total, 1); + assert_eq!(fallback_items[0].id, "no-trigger"); + + let trigger_ids = vec!["trigger-a".to_string(), "trigger-b".to_string()]; + let (trigger_items, trigger_total) = db + .rt_list_threads_scoped( + &scope, + "org", + RtThreadListOptions { + // Every normal filter below deliberately conflicts. The + // production listByTriggerIds branch ignores all of them, + // but always forces hidden=false. + created_by: Some("nobody"), + hidden: Some(true), + search: Some("does-not-match"), + trigger_ids: Some(&trigger_ids), + virtual_mcp_id: Some("missing-vm"), + has_trigger: Some(false), + start_date: Some("2999-01-01T00:00:00.000Z"), + end_date: Some("2999-01-02T00:00:00.000Z"), + status: Some("in_progress"), + agent_id: Some("missing-agent"), + limit: 1, + offset: 0, + }, + ) + .unwrap(); + assert_eq!(trigger_total, 2); + assert_eq!(trigger_items.len(), 1); + assert_eq!(trigger_items[0].id, "normal-other"); + assert!(trigger_items.iter().all(|item| !item.hidden)); + } + + #[test] + fn corrupt_thread_json_is_reported_instead_of_silently_becoming_null() { + let db = ThreadsDb::open_in_memory().unwrap(); + let scope = RtAccountScope::new("studio.decocms.com", "alice").unwrap(); + db.rt_create_thread_scoped( + &scope, + Some("corrupt-json"), + "org", + "corrupt", + None, + "vmcp", + None, + "alice", + ) + .unwrap(); + db.lock() + .execute( + "UPDATE native_scoped_threads SET metadata = '{' WHERE id = 'corrupt-json'", + [], + ) + .unwrap(); + + let error = db + .rt_get_thread_in_scope(&scope, "org", "corrupt-json") + .unwrap_err(); + assert!(matches!( + error, + DbError::Sqlite(rusqlite::Error::FromSqlConversionFailure( + 13, + rusqlite::types::Type::Text, + _ + )) + )); + } + + #[test] + fn v4_lazily_adopts_only_attributable_legacy_rows_once() { + let dir = tempfile::tempdir().unwrap(); + create_pre_account_scope_fixture(dir.path()); + let db = ThreadsDb::open(dir.path()).unwrap(); + let prod_user = RtAccountScope::new("studio.decocms.com", "user-a").unwrap(); + let dev_same_sub = RtAccountScope::new("localhost:4000", "user-a").unwrap(); + let other_user = RtAccountScope::new("studio.decocms.com", "user-b").unwrap(); + + let adopted = db + .rt_get_thread_in_scope(&prod_user, "shared-org", "owned-legacy") + .unwrap() + .expect("matching creator should be adopted"); + assert_eq!(adopted.organization_id, "shared-org"); + assert_eq!(adopted.created_by, "user-a"); + assert_eq!( + db.rt_list_turn_queue_scoped(&prod_user, "shared-org", "owned-legacy") + .unwrap() + .len(), + 1, + "the durable queue must move in the same account adoption transaction" + ); + + for scope in [&dev_same_sub, &other_user] { + assert!(db + .rt_get_thread_in_scope(scope, "shared-org", "owned-legacy") + .unwrap() + .is_none()); + } + for scope in [&prod_user, &dev_same_sub, &other_user] { + assert!(db + .rt_get_thread_in_scope(scope, "shared-org", "placeholder-legacy") + .unwrap() + .is_none()); + } + assert_eq!( + schema_version(&local_db_path(dir.path())), + CURRENT_SCHEMA_VERSION + ); + } +} diff --git a/apps/native/crates/local-api/src/routes/upstream.rs b/apps/native/crates/local-api/src/routes/upstream.rs new file mode 100644 index 0000000000..31715a8c67 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/upstream.rs @@ -0,0 +1,2508 @@ +//! The app-API intercept-or-proxy catchall — new, no daemon precedent. Full +//! behavior: the native local-API contract. +//! +//! Owned by the Phase 3 upstream-auth family (`crates/upstream`). This +//! handler forwards `method`/relative-path/query/body to +//! `/` with the Keychain-backed access token +//! attached SERVER-SIDE (via `upstream::global()` — +//! `crates/upstream/src/session.rs`'s process-wide singleton, shared with +//! the shell's `auth_status`/`auth_login`/`auth_logout`/ +//! `auth_complete_session` IPC commands so both surfaces always agree on +//! sign-in state). The upstream bearer token is never constructed from, or +//! exposed to, anything this handler receives from the caller — it never +//! appears in a response, a log line, or an error body. +//! +//! Wired at `router.rs`'s top level, as the MAIN listener's fallback (merged +//! in from a small `app_api` sub-router that carries its own `guard` +//! middleware — Origin allowlist + LOCAL bearer, see `router.rs`), so by the +//! time this handler runs the CALLER is already authenticated against the +//! LOCAL token. This handler's own job is the SEPARATE, orthogonal question +//! of whether there's a valid UPSTREAM (mesh) session to forward with. +//! +//! Formerly nested under a `/upstream` path prefix (which `nest()` stripped +//! before this handler ever saw `req.uri()`); now mounted with NO prefix at +//! all — the caller's own path (e.g. `/api/acme/threads?x=1`) is exactly +//! what this handler receives, unchanged. The prefix was dropped once the +//! reverse-proxy family this fallback used to share an origin with (the +//! dev-server preview) moved to its own dedicated loopback listener (see +//! `lib.rs`/`router.rs`'s `build_preview`) — with the two families on +//! genuinely separate origins, there is no longer any ambiguity to +//! disambiguate with a path prefix. +//! +//! ## Thin reverse-proxy catchall — no path allowlist +//! +//! This handler is a THIN tokio/reqwest reverse proxy to the upstream mesh: +//! only chat (decopilot dispatch) and the sandbox/thread/model interception +//! table (`routes/intercept/`) are handled locally, checked FIRST — anything +//! that isn't intercepted is forwarded upstream, for ANY path, not just a +//! curated `/api/*` allowlist. The web client's own transport +//! (`apps/web/src/lib/desktop/transport-rules.ts`) rewrites every +//! same-origin app request (`/api`, `/mcp`, `/oauth-proxy`, `/.well-known`, +//! `/org`, ...) straight to this same main-listener origin, bearer-attached, +//! with no path-prefix rewrite at all — all of them stream through the SAME +//! bearer-forwarding branch below (see [`send_with_retry`]/[`build_response`]), +//! not just `/api/*`. A prior revision 404'd anything outside `/api/*` here +//! (`ALLOWED_PREFIX`) — removed as a needless restriction that contradicted +//! this catchall model and broke every one of those other prefixes; a later +//! revision still gated the rewrite on a `PROXIED_PATH_PREFIXES` allowlist +//! client-side — removed too, once the preview moved off this origin, +//! since this handler (not the client) is the one place that needs to know +//! which paths it recognizes. +//! +//! ## Two forwarding branches: bearer (org data) vs. cookie (auth) +//! +//! Post-v1 owner feedback locked a HYBRID sign-in design: the desktop +//! shell's in-app login screen is the SAME shared component as the web +//! `/login` route, and email/password + email-OTP submit through THIS +//! proxy to Better Auth's cookie-based session endpoints +//! (`/api/auth/sign-in/email`, `/api/auth/email-otp/verify-otp`, ...) — +//! see `apps/web/src/routes/login.tsx`. Those calls have no +//! Keychain-stored OAuth bearer to attach (there may not even BE one yet — +//! this is how a session gets established in the first place), and a +//! successful call needs its `Set-Cookie` captured somewhere so +//! `auth_complete_session` (`crates/upstream/src/bridge.rs`) can present +//! it to the MCP OAuth authorize endpoint moments later. +//! +//! So every request whose path starts with [`AUTH_PATH_PREFIX`] takes +//! [`proxy_auth_path`] instead of the bearer-token branch below: +//! - attaches `upstream::UpstreamSession::cookie_jar()`'s captured cookie +//! (if any) as a plain `Cookie:` header — never the OAuth bearer; +//! - captures any `Set-Cookie` the upstream response carries back into +//! that SAME jar; +//! - STRIPS `Set-Cookie` from the response before it reaches the webview +//! — the cookie is a full Better Auth session credential (password +//! change, org management, ...), far broader than the OAuth-scoped +//! access this app needs, and must never land in a WKWebView/JS context. +//! - treats a successful `POST /api/auth/sign-out` as the native logout +//! signal too: Better Auth revokes its upstream cookie session first, +//! then [`upstream::UpstreamSession::logout`] revokes the OAuth token and +//! clears the Keychain/cache/jar. The shared production UI therefore +//! keeps its existing `authClient.signOut()` path with no desktop fork. +//! +//! Every other `/api/*` path keeps using the existing bearer-token branch +//! unchanged — org-data calls never see or need the cookie jar. +//! +//! ## Public, no-auth passthrough: `GET /api/config` +//! +//! The desktop sign-in screen (`apps/web/src/desktop/sign-in-screen.tsx`) +//! needs to know which auth methods are enabled BEFORE any upstream session +//! exists — the exact moment the bearer-token branch above always 401s +//! (`session.access_token()` returns `NoSession`). `apps/api/src/api/ +//! routes/public-config.ts`'s `GET /api/config` is explicitly public/no-auth +//! on mesh (fetched by the web SPA before login too), so [`PUBLIC_NO_AUTH_PATH`] +//! gives it the same bearer-free treatment `AUTH_PATH_PREFIX` gives +//! `/api/auth/*` — checked first, before the bearer branch ever runs. +//! [`proxy_public_config`] does NOT touch the cookie jar (this route never +//! sets or needs a session cookie); it strips any inbound `Cookie` and any +//! outbound `Set-Cookie` purely as defense-in-depth, keeping the "a cookie +//! never reaches the webview" invariant uniform across every branch of this +//! proxy even though this particular route would never trigger it. + +use std::time::Duration; + +use axum::{ + body::Body, + extract::{Request, State}, + http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; + +use crate::error::ApiError; +use crate::routes::intercept; +use crate::state::AppState; + +/// The cookie-relay branch's scope — see this module's doc comment ("Two +/// forwarding branches"). Checked first, before the general catchall +/// bearer-forwarding branch every other path takes. +const AUTH_PATH_PREFIX: &str = "/api/auth/"; + +/// The production shell's existing Better Auth sign-out endpoint. A +/// successful response from this path is also the native app's logout +/// signal: after Better Auth has revoked the cookie session upstream, the +/// proxy revokes the native OAuth credential and clears its local store/jar. +/// Keeping this coordination here means the shared web UI does not need a +/// second, desktop-only sign-out action. +const AUTH_SIGN_OUT_PATH: &str = "/api/auth/sign-out"; + +/// Exact-match public, no-auth mesh route — see this module's doc comment +/// ("Public, no-auth passthrough"). Checked before the bearer-token branch, +/// same as [`AUTH_PATH_PREFIX`]. Exact match (not a prefix) because +/// `public-config.ts` mounts exactly one route (`GET /`) at this path. +const PUBLIC_NO_AUTH_PATH: &str = "/api/config"; + +/// Guards just the headers phase of the upstream fetch (mirrors +/// `routes/proxy.rs`'s `HEADERS_TIMEOUT` reasoning) — org-data API calls +/// should answer fast; once headers arrive the body streams with no +/// further deadline, so a long-lived SSE response is never cut off. +const UPSTREAM_HEADERS_TIMEOUT: Duration = Duration::from_secs(30); + +/// Bounded request-body buffering — org-data JSON payloads are small; this +/// just stops one misbehaving caller from parking an unbounded allocation +/// in this process (same reasoning as `routes/proxy.rs`'s +/// `MAX_PROXY_BODY_BYTES`, scaled down since this namespace never proxies +/// dev-server-sized responses). +const MAX_UPSTREAM_BODY_BYTES: usize = 25 * 1024 * 1024; + +pub async fn proxy(State(state): State, req: Request) -> Response { + let (parts, body) = req.into_parts(); + let path = parts.uri.path().to_string(); + let path_and_query = parts + .uri + .path_and_query() + .map(|pq| pq.as_str().to_string()) + .unwrap_or_else(|| path.clone()); + + tracing::debug!(method = %parts.method, path = %path, "app-api proxy: incoming request"); + + let body_bytes = match axum::body::to_bytes(body, MAX_UPSTREAM_BODY_BYTES).await { + Ok(b) => b, + Err(err) => { + return ApiError::payload_too_large(format!("failed to read request body: {err}")) + .into_response() + } + }; + + // The interception table (`routes/intercept/`) is checked FIRST, before + // either the cookie-relay or bearer-forwarding branches below — an + // intercepted route never talks to upstream at all (not even to decide + // whether there's a valid session), so it must never wait on, or be + // gated by, this proxy's auth machinery. See that module's doc comment + // for the full route table and the map citations behind each entry. + if let Some(response) = + intercept::try_intercept(&state, &parts.method, &path, parts.uri.query(), &body_bytes).await + { + return response; + } + + let session = upstream::global(); + + if path.starts_with(AUTH_PATH_PREFIX) { + return proxy_auth_path( + &session, + &parts.method, + &path_and_query, + &parts.headers, + &body_bytes, + ) + .await; + } + + if path == PUBLIC_NO_AUTH_PATH { + return proxy_public_config( + &session, + &parts.method, + &path_and_query, + &parts.headers, + &body_bytes, + ) + .await; + } + + let access_token = match session.access_token().await { + Ok(token) => token, + Err(err) if err.kind == upstream::refresh::RefreshErrorKind::Transient => { + return ApiError::bad_gateway(format!("upstream unreachable: {}", err.message)) + .into_response(); + } + // `NoSession` (never logged in / already signed out) and + // `InvalidGrant` (refresh token itself rejected) both mean the + // SAME thing to this handler's caller: there is no usable upstream + // session right now. `InvalidGrant` additionally tears down the + // stale local credentials so the NEXT call doesn't repeat a + // doomed refresh attempt. + Err(err) => { + if err.kind == upstream::refresh::RefreshErrorKind::InvalidGrant { + session + .hard_sign_out("refresh token rejected while resolving an app-API request") + .await; + } + return unauthorized_upstream(); + } + }; + + let mut out_headers = build_forward_headers(&parts.headers); + attach_persisted_cookie(&mut out_headers, &session).await; + // A payload we intend to rewrite must arrive uncompressed — reqwest is + // built without `gzip`, so a compressed body would fail to parse and the + // rewrite would silently fail open. + if is_protected_resource_metadata(&path) { + out_headers.insert( + header::ACCEPT_ENCODING, + HeaderValue::from_static("identity"), + ); + } + + match send_with_retry( + &session, + &parts.method, + &path_and_query, + &out_headers, + &body_bytes, + access_token, + ) + .await + { + Ok(upstream_resp) if is_protected_resource_metadata(&path) => { + localized_resource_metadata(upstream_resp, session.target(), &parts.headers).await + } + Ok(upstream_resp) => build_response(upstream_resp).await, + Err(ProxyError::Network(msg)) => { + ApiError::bad_gateway(format!("upstream unreachable: {msg}")).into_response() + } + Err(ProxyError::HardUnauthorized) => unauthorized_upstream(), + } +} + +/// RFC 9728 protected-resource metadata, in either of the two anchorings the +/// MCP SDK probes. +fn is_protected_resource_metadata(path: &str) -> bool { + path.starts_with("/.well-known/oauth-protected-resource") + || path.contains("/.well-known/oauth-protected-resource") +} + +/// Rewrite the metadata's `resource` to the origin the CALLER reached us at. +/// +/// Upstream builds it from its own origin, so a desktop client is told the +/// protected resource is `https://studio.decocms.com/api//mcp/` +/// while it is talking to `http://localhost:/…`. The MCP SDK validates +/// that the two agree and refuses the mismatch: +/// +/// ```text +/// Protected resource https://studio.decocms.com/api/o/mcp/c +/// does not match expected http://localhost:4420/api/o/mcp/c (or origin) +/// ``` +/// +/// `authorization_servers` is deliberately left pointing upstream: that IS +/// where the authorization endpoints live, and this proxy has no OAuth +/// endpoints of its own to offer instead. +async fn localized_resource_metadata( + upstream: reqwest::Response, + target: &str, + request_headers: &HeaderMap, +) -> Response { + if !upstream.status().is_success() { + return build_response(upstream).await; + } + let Some(local_origin) = caller_origin(request_headers) else { + return build_response(upstream).await; + }; + + let status = upstream.status(); + let mut headers = upstream.headers().clone(); + strip_hop_by_hop_headers(&mut headers); + headers.remove(header::CONTENT_ENCODING); + + let Ok(bytes) = upstream.bytes().await else { + return ApiError::bad_gateway("upstream metadata body was unreadable").into_response(); + }; + // Fail OPEN: an unparseable or unchanged body is forwarded verbatim, so a + // shape this does not recognize can never break OAuth outright. + let body = rewrite_resource_field(&bytes, target.trim_end_matches('/'), &local_origin) + .map(axum::body::Bytes::from) + .unwrap_or(bytes); + headers.remove(header::CONTENT_LENGTH); + let mut res = Response::new(Body::from(body)); + *res.status_mut() = status; + *res.headers_mut() = headers; + res +} + +/// The origin the webview used to reach us — `Origin` when it sent one, else +/// derived from the exact `Host` it addressed (which the embedded guard has +/// already validated). +fn caller_origin(headers: &HeaderMap) -> Option { + if let Some(origin) = headers + .get(header::ORIGIN) + .and_then(|v| v.to_str().ok()) + .filter(|o| o.starts_with("http://") || o.starts_with("https://")) + { + return Some(origin.trim_end_matches('/').to_string()); + } + let host = headers.get(header::HOST)?.to_str().ok()?; + (!host.is_empty()).then(|| format!("http://{host}")) +} + +/// Swap the upstream origin for the local one in `resource`. `None` when the +/// body is not JSON, has no `resource` string, or it does not name upstream. +fn rewrite_resource_field( + bytes: &[u8], + upstream_origin: &str, + local_origin: &str, +) -> Option> { + let mut value: serde_json::Value = serde_json::from_slice(bytes).ok()?; + let resource = value.get("resource")?.as_str()?; + let path = resource.strip_prefix(upstream_origin)?; + // The remainder MUST begin the path, or this is a lookalike host that + // merely starts with the origin string — `studio.decocms.com.evil.example` + // prefix-matches `studio.decocms.com` and would otherwise be rewritten as + // if it were ours. + if !path.is_empty() && !path.starts_with('/') { + return None; + } + let localized = format!("{local_origin}{path}"); + *value.get_mut("resource")? = serde_json::Value::String(localized); + serde_json::to_vec(&value).ok() +} + +/// The cookie-relay branch — see this module's doc comment ("Two +/// forwarding branches"). Never attaches the Keychain OAuth bearer (this +/// is how a session is established in the first place; there may be none +/// yet). Attaches `session.cookie_jar()`'s captured cookie for +/// `session.host()` if present, captures the upstream response's +/// `Set-Cookie` back into that same jar, and strips `Set-Cookie` from what +/// goes back to the caller. +async fn proxy_auth_path( + session: &upstream::UpstreamSession, + method: &Method, + path_and_query: &str, + headers: &HeaderMap, + body: &axum::body::Bytes, +) -> Response { + let mut out_headers = build_forward_headers(headers); + // A payload we intend to REWRITE must arrive uncompressed. reqwest is + // built without its `gzip` feature here, so forwarding the webview's + // `Accept-Encoding: gzip` would hand us compressed bytes, the JSON parse + // would fail, and the rewrite would silently fail open — the exact way + // the `/api/config` version rewrite failed before it asked for identity + // too. + if carries_org_assets(path_and_query) { + out_headers.insert( + header::ACCEPT_ENCODING, + HeaderValue::from_static("identity"), + ); + } + // `build_forward_headers` already strips any inbound `Cookie` the + // caller sent (the webview holds no cookies of ours to begin with, but + // a stray/forged one must never ride through) — this proxy decides the + // Cookie header for this branch exclusively, from the ephemeral jar OR + // the durable persisted cookie below. + // + // Prefer the ephemeral jar's value (the one currently in-flight through + // an active login handshake — e.g. the sign-in POST that's ABOUT to + // establish a session, before anything is persisted yet), falling back + // to the durable Keychain-persisted cookie ([`upstream::UpstreamSession::cookie_header`]) + // for every `/api/auth/*` call AFTER sign-in completes — this is what + // makes `GET /api/auth/get-session` / `GET /api/auth/organization/list` + // (both under this same `AUTH_PATH_PREFIX`) keep working on every + // subsequent request for the rest of the session's life, not just the + // one bridge round trip. See this module's + `crates/upstream/src/ + // bridge.rs`'s doc comments for why. + // MERGE the durable persisted session cookie with whatever the ephemeral + // jar currently holds — do NOT pick one. The upstream sits behind + // Cloudflare, which sets `__cf_bm` on responses; this proxy captures every + // `Set-Cookie` into the jar (below), so after the first upstream response + // the jar holds `__cf_bm`. A plain jar-or-persisted CHOICE then let that + // infra cookie SHADOW the persisted `__Secure-better-auth.session_token`, + // so every org-scoped `/api/auth/*` call forwarded `__cf_bm` alone and got + // 401 (get-session only slipped through because it fired before the jar was + // polluted). A real browser sends BOTH cookies; so do we. The jar wins on a + // name clash — it carries the freshest rotated session token (see the + // capture/`remember_cookie` below), while the persisted cookie guarantees + // the session token is present even when the jar holds only `__cf_bm`. + let persisted_cookie = session.cookie_header().await; + let jar_cookie = session.cookie_jar().header_value(session.host()); + let cookie = merge_cookie_headers(persisted_cookie.as_deref(), jar_cookie.as_deref()); + // Names only, never the values — lets a debug trace confirm WHICH cookies + // we forward (e.g. that `__Secure-better-auth.session_token` an https + // upstream's `get-session` actually reads rides alongside `__cf_bm`) + // without logging any secret. + let forwarded_cookie_names: Vec = cookie + .as_deref() + .map(|c| { + c.split(';') + .filter_map(|kv| kv.trim().split('=').next()) + .filter(|n| !n.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + if let Some(cookie) = cookie { + match HeaderValue::from_str(&cookie) { + Ok(value) => { + out_headers.insert(header::COOKIE, value); + } + Err(err) => { + // Should not happen — captured values come from an + // upstream `Set-Cookie` that was itself a valid header + // value when received. Defensive only: proceed without a + // cookie rather than fail the whole request. + tracing::warn!(error = %err, "captured cookie jar value was not a valid header value; forwarding without it"); + } + } + } + + // Present as a first-party caller: Better Auth's CSRF check rejects + // unknown Origins on auth POSTs (and the webview's own Origin is + // `tauri://localhost`, stripped in `build_forward_headers`). The + // upstream's own origin is by definition on its trusted list. + if let Ok(value) = HeaderValue::from_str(session.target().trim_end_matches('/')) { + out_headers.insert(header::ORIGIN, value); + } + + let url = format!("{}{path_and_query}", session.target()); + let upstream_resp = match send_once_no_bearer(method, &url, &out_headers, body.clone()).await { + Ok(resp) => resp, + Err(ProxyError::Network(msg)) => { + return ApiError::bad_gateway(format!("upstream unreachable: {msg}")).into_response() + } + // `send_once_no_bearer` never produces `HardUnauthorized` — that + // variant only exists for the bearer-retry branch above. + Err(ProxyError::HardUnauthorized) => { + unreachable!("send_once_no_bearer never returns ProxyError::HardUnauthorized") + } + }; + + tracing::debug!( + method = %method, + path = %path_and_query, + status = upstream_resp.status().as_u16(), + forwarded_cookie_names = ?forwarded_cookie_names, + "auth-path proxy: upstream responded" + ); + + // The real Studio shell already signs out through Better Auth. Once that + // upstream operation succeeds, complete the native half of the same + // transition: `logout()` attempts OAuth revocation and only then clears + // the Keychain-backed session and ephemeral cookie jar. Do this before + // capturing response cookies: Better Auth commonly sends an expired + // session `Set-Cookie` on sign-out, and retaining that tombstone in our + // deliberately attribute-free jar would leave the app locally half-signed + // out. A failed upstream sign-out deliberately leaves the native session + // intact so the UI can report/retry the failure without lying about the + // server-side state. + let auth_path = path_and_query.split('?').next().unwrap_or(path_and_query); + if *method == Method::POST + && auth_path == AUTH_SIGN_OUT_PATH + && upstream_resp.status().is_success() + { + session.logout().await; + return build_auth_response(upstream_resp).await; + } + + let set_cookie_values: Vec = upstream_resp + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + // Only ever track OUR OWN Better Auth cookies in the jar / persisted + // store — NEVER infrastructure cookies the upstream's CDN sets + // (Cloudflare's `__cf_bm`, `cf_clearance`, `_cfuvid`, …). Without this + // filter, capturing `__cf_bm` and then `remember_cookie`-ing the jar's + // full contents OVERWRITES the real session cookie (the jar's + // `header_value` returns everything it holds, and the Google/bridge + // path never Set-Cookie's the session token into the jar to begin + // with), which silently 401'd every org-scoped call after the first + // upstream response. Everything here is keyed by `session.host()`, so + // staging (`studio-stg.decocms.com`) and prod (`studio.decocms.com`) + // cookies live in separate jars and never mix. + .filter(|sc| is_better_auth_set_cookie(sc)) + .map(str::to_string) + .collect(); + if !set_cookie_values.is_empty() { + session + .cookie_jar() + .capture(session.host(), set_cookie_values.iter().map(String::as_str)); + // Keep the DURABLE persisted cookie fresh too: a call after the + // initial sign-in (e.g. Better Auth rotating the session token on + // some later `/api/auth/*` round trip) must not silently go stale + // just because `complete_session()`'s one-time bridge already ran. + // A no-op if there's no OAuth session yet (mid-handshake, before + // `complete_session()` — the ephemeral jar carries it forward to + // that call instead, same as before this change). + if let Some(full_header) = session.cookie_jar().header_value(session.host()) { + session.remember_cookie(&full_header).await; + } + } + + // Better Auth's org payloads carry `logo` as an ABSOLUTE upstream URL + // under `/api//files/…`. The webview renders it straight into an + // ``, which goes cross-origin with no credentials — desktop auth is a + // Keychain token held here, not a browser cookie for that host — so every + // org icon fails to load. Rewriting the payload to a same-origin path + // sends the request back through this proxy, which attaches the token. + if carries_org_assets(path_and_query) { + return localized_auth_response(upstream_resp, session.target()).await; + } + build_auth_response(upstream_resp).await +} + +/// Auth endpoints whose payloads can carry an organization `logo`. +fn carries_org_assets(path_and_query: &str) -> bool { + let path = path_and_query + .split(['?', '#']) + .next() + .unwrap_or(path_and_query); + path.starts_with("/api/auth/organization") || path == "/api/auth/get-session" +} + +/// [`build_auth_response`], but buffering the body so protected-asset URLs can +/// be localized. Only used for the small JSON payloads above — everything else +/// keeps streaming. +async fn localized_auth_response(upstream: reqwest::Response, target: &str) -> Response { + if !upstream.status().is_success() { + return build_auth_response(upstream).await; + } + let status = upstream.status(); + let mut headers = upstream.headers().clone(); + strip_hop_by_hop_headers(&mut headers); + headers.remove(header::SET_COOKIE); + + let Ok(bytes) = upstream.bytes().await else { + return ApiError::bad_gateway("upstream auth body was unreadable").into_response(); + }; + // Asked for `identity`, but never trust it: a body we hand back verbatim + // must not claim an encoding it no longer has. + headers.remove(header::CONTENT_ENCODING); + // Fail OPEN: an unparseable or unchanged body is forwarded verbatim, so a + // payload shape this does not recognize can never break sign-in. + let body = localize_first_party_asset_urls(&bytes, target.trim_end_matches('/')) + .map(axum::body::Bytes::from) + .unwrap_or(bytes); + headers.remove(header::CONTENT_LENGTH); + let mut res = Response::new(Body::from(body)); + *res.status_mut() = status; + *res.headers_mut() = headers; + res +} + +/// Rewrite every absolute upstream protected-file URL in a JSON payload into +/// the same-origin path form. `None` when nothing changed. +/// +/// Deliberately narrow, because widening it would turn this proxy into a +/// general authenticated asset relay: the origin must match EXACTLY (a +/// lookalike host, or one carrying credentials, will not prefix-match the +/// bare origin string), and the path must be `/api//files/…`. Ports the +/// same rule the web app applies at render time +/// (`lib/desktop/transport-rules.ts`). +fn localize_first_party_asset_urls(bytes: &[u8], upstream_origin: &str) -> Option> { + if upstream_origin.is_empty() { + return None; + } + let mut value: serde_json::Value = serde_json::from_slice(bytes).ok()?; + if !localize_in_place(&mut value, upstream_origin) { + return None; + } + serde_json::to_vec(&value).ok() +} + +fn localize_in_place(value: &mut serde_json::Value, origin: &str) -> bool { + match value { + serde_json::Value::String(text) => match localized_asset_path(text, origin) { + Some(path) => { + *text = path; + true + } + None => false, + }, + // Explicit loops, NOT `any()`: that short-circuits on the first + // rewrite and would leave every later logo in the payload untouched. + serde_json::Value::Array(items) => { + let mut changed = false; + for item in items.iter_mut() { + changed |= localize_in_place(item, origin); + } + changed + } + serde_json::Value::Object(map) => { + let mut changed = false; + for item in map.values_mut() { + changed |= localize_in_place(item, origin); + } + changed + } + _ => false, + } +} + +/// The same-origin path for an upstream protected-file URL, or `None`. +fn localized_asset_path(url: &str, origin: &str) -> Option { + let rest = url.strip_prefix(origin)?; + if !rest.starts_with('/') { + return None; + } + let path = rest.split(['?', '#']).next().unwrap_or(rest); + let mut segments = path.trim_start_matches('/').split('/'); + let is_protected = segments.next() == Some("api") + && segments.next().is_some_and(|org| !org.is_empty()) + && segments.next() == Some("files"); + is_protected.then(|| rest.to_string()) +} + +/// Merge two `Cookie:` header values into one, deduping by cookie name; the +/// SECOND argument (`overlay`) wins on a name clash. Returns `None` when both +/// are absent/empty. This is what lets the durable session cookie (`base`) +/// always ride ALONGSIDE whatever the ephemeral jar (`overlay`) captured from +/// upstream — e.g. Cloudflare's `__cf_bm` — so a captured infra cookie can +/// never SHADOW the session cookie (the silent cause of a 401 on every +/// org-scoped `/api/auth/*` call). Mirrors what a browser sends: all of its +/// cookies for the host, in one header. First-seen name order is preserved. +fn merge_cookie_headers(base: Option<&str>, overlay: Option<&str>) -> Option { + let mut order: Vec = Vec::new(); + let mut values: std::collections::HashMap = std::collections::HashMap::new(); + for src in [base, overlay] { + let Some(header) = src else { continue }; + for part in header.split(';') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let (name, value) = match part.split_once('=') { + Some((n, v)) => (n.trim().to_string(), v.trim().to_string()), + None => (part.to_string(), String::new()), + }; + if name.is_empty() { + continue; + } + // Overlay overwrites the value on a clash but keeps the original + // first-seen position (so `insert`ing a duplicate name must not + // re-append to `order`). + if values.insert(name.clone(), value).is_none() { + order.push(name); + } + } + } + if order.is_empty() { + return None; + } + Some( + order + .iter() + .map(|name| { + let value = &values[name]; + if value.is_empty() { + name.clone() + } else { + format!("{name}={value}") + } + }) + .collect::>() + .join("; "), + ) +} + +/// True if a `Set-Cookie` header value names one of OUR OWN Better Auth cookies +/// (the name contains `better-auth`, regardless of any `__Secure-`/`__Host-` +/// prefix) — as opposed to infrastructure cookies the upstream's CDN sets +/// (Cloudflare's `__cf_bm`, `cf_clearance`, `_cfuvid`). Only the former belong +/// in this proxy's cookie jar / persisted session store; capturing the latter +/// clobbers the real session cookie (see the capture site in `proxy_auth_path`). +fn is_better_auth_set_cookie(set_cookie: &str) -> bool { + set_cookie + .split('=') + .next() + .map(|name| name.trim().contains("better-auth")) + .unwrap_or(false) +} + +/// Attaches the durable, Keychain-persisted Better Auth session cookie (see +/// `upstream::UpstreamSession::cookie_header`'s doc comment) to an +/// org-data/bearer-branch request, in ADDITION to the `Authorization: +/// Bearer` token already attached by [`send_once`]/[`send_with_retry`] — a +/// no-op when this session has no persisted cookie (the system-browser +/// login path, or before any embedded sign-in has ever completed). This is +/// what makes the real production web shell's own sign-in gate and org +/// switcher (both native Better Auth `/api/auth/*` endpoints, reached via +/// [`proxy_auth_path`] above) AND every ordinary org-scoped data call +/// (reached via THIS branch) agree on the same signed-in identity, for +/// every request this proxy forwards — not just `/api/auth/*`. +async fn attach_persisted_cookie(headers: &mut HeaderMap, session: &upstream::UpstreamSession) { + let Some(cookie) = session.cookie_header().await else { + return; + }; + match HeaderValue::from_str(&cookie) { + Ok(value) => { + headers.insert(header::COOKIE, value); + } + Err(err) => { + tracing::warn!(error = %err, "persisted cookie was not a valid header value; forwarding without it"); + } + } +} + +/// The public, no-auth passthrough — see this module's doc comment ("Public, +/// no-auth passthrough"). No Keychain bearer, no cookie-jar read/write: +/// `GET /api/config` needs neither to answer. Still strips any inbound +/// `Cookie` / outbound `Set-Cookie` defensively (this route never sets one, +/// but every branch of this proxy keeps the same invariant uniformly). +async fn proxy_public_config( + session: &upstream::UpstreamSession, + method: &Method, + path_and_query: &str, + headers: &HeaderMap, + body: &axum::body::Bytes, +) -> Response { + let mut out_headers = build_forward_headers(headers); + out_headers.remove(header::COOKIE); + // Ask upstream for an UNCOMPRESSED body. `reqwest` is built without its + // `gzip` feature (see the workspace manifest), so it hands back whatever + // encoding the origin chose — and a CDN in front of upstream answers the + // browser's `Accept-Encoding: gzip` with a gzipped body. Those bytes are + // not JSON, so `rewrite_config_version` below would silently fail to parse + // them and fall through to passthrough, leaving the version un-rewritten. + // Overriding the header here (rather than enabling `gzip` crate-wide) + // keeps every other proxied route — including the streaming ones — byte + // -identical; `/api/config` is a few hundred bytes, so the lost + // compression costs nothing. + out_headers.insert( + header::ACCEPT_ENCODING, + HeaderValue::from_static("identity"), + ); + + let url = format!("{}{path_and_query}", session.target()); + match send_once_no_bearer(method, &url, &out_headers, body.clone()).await { + Ok(resp) => rewrite_config_version(resp).await, + Err(ProxyError::Network(msg)) => { + ApiError::bad_gateway(format!("upstream unreachable: {msg}")).into_response() + } + Err(ProxyError::HardUnauthorized) => { + unreachable!("send_once_no_bearer never returns ProxyError::HardUnauthorized") + } + } +} + +/// The web bundle this app ships, baked in by `build.rs` from the same +/// `apps/api/package.json` Vite reads for `__STUDIO_VERSION__`. Empty when +/// that file could not be read at build time (see `build.rs`). +const STUDIO_WEB_VERSION: &str = env!("STUDIO_WEB_VERSION"); + +/// Answers `GET /api/config` with the version of the bundle actually running +/// inside this app, not the upstream deployment's. +/// +/// `version-check-dialog.tsx` polls this route every 5 minutes and nags "A new +/// version is ready" once the reported version differs from the bundle's own +/// build-time `__STUDIO_VERSION__` on two consecutive polls. Upstream redeploys +/// many times a day, so proxied unchanged that banner is guaranteed to fire in +/// the desktop app — and its "Refresh" action cannot possibly help, because the +/// webview loads a bundle packaged into the app, not whatever upstream now +/// serves. Rewriting the field keeps the two in agreement, so the banner only +/// ever appears in the browser, where reloading genuinely fetches new assets. +/// +/// Every other field is passed through untouched: this rewrites one string, it +/// does not reimplement the payload. Non-2xx, non-JSON, unparseable, or +/// unexpectedly-shaped bodies stream through unchanged, as does an empty +/// `STUDIO_WEB_VERSION`. +async fn rewrite_config_version(upstream: reqwest::Response) -> Response { + if STUDIO_WEB_VERSION.is_empty() || !upstream.status().is_success() { + return build_auth_response(upstream).await; + } + + let status = upstream.status(); + let mut headers = upstream.headers().clone(); + strip_hop_by_hop_headers(&mut headers); + headers.remove(header::SET_COOKIE); + + let Ok(bytes) = upstream.bytes().await else { + return ApiError::bad_gateway("upstream config body was unreadable").into_response(); + }; + + let patched = patch_config_version(&bytes, STUDIO_WEB_VERSION); + + let body = match patched { + Some(json) => { + // Content-Length no longer matches the re-serialized body; drop it + // and let axum frame the response itself. Content-Encoding goes too: + // reaching here means the body parsed as JSON, so what we emit is + // plain — advertising an encoding would make the browser try to + // decompress it. + headers.remove(header::CONTENT_LENGTH); + headers.remove(header::CONTENT_ENCODING); + json + } + None => bytes.to_vec(), + }; + + let mut res = Response::new(Body::from(body)); + *res.status_mut() = status; + *res.headers_mut() = headers; + res +} + +/// Replaces `config.version` in a `GET /api/config` body, or `None` when the +/// payload isn't the shape we expect — the caller then passes the original +/// bytes through untouched rather than inventing a response. +/// +/// Pure (no I/O) so the contract is unit-testable without standing up a proxy, +/// per TESTING.md. +fn patch_config_version(bytes: &[u8], version: &str) -> Option> { + let mut value = serde_json::from_slice::(bytes).ok()?; + // `{ "config": { "version": "…", … } }` — see + // `apps/api/src/api/routes/public-config.ts`. + let slot = value.get_mut("config")?.get_mut("version")?; + if !slot.is_string() { + return None; + } + *slot = serde_json::Value::String(version.to_string()); + serde_json::to_vec(&value).ok() +} + +/// Strips hop-by-hop headers (RFC 7230 §6.1) plus `Host` (reqwest derives +/// it from the target URL) and the caller's OWN `Authorization` — that's +/// the LOCAL bearer `router.rs`'s `guard` middleware already validated; +/// forwarding it upstream would leak an unrelated credential. The preview +/// listener is intentionally different: it carries the sandboxed +/// application's own Authorization end-to-end and never uses local-api auth. +fn build_forward_headers(incoming: &HeaderMap) -> HeaderMap { + let mut out = incoming.clone(); + strip_hop_by_hop_headers(&mut out); + out.remove(header::HOST); + out.remove(header::AUTHORIZATION); + out.remove(header::CONTENT_LENGTH); + // Never forward whatever `Cookie` the caller sent. In embedded mode this + // is local-api's HttpOnly session credential (already removed by the + // router guard); in standalone mode it may be stray/forged. Every branch + // of this proxy decides the outbound `Cookie` header itself, exclusively + // (the persisted + // session cookie here in the bearer branch, the jar/persisted cookie in + // `proxy_auth_path`, none at all in `proxy_public_config`) — uniform + // defense-in-depth across all three, even though today only the first + // two ever attach one. + out.remove(header::COOKIE); + // Browser-context headers must not leak upstream: this proxy is a + // server-side client, not the webview. Concretely, the webview's + // `Origin: tauri://localhost` trips Better Auth's CSRF origin check on + // every `/api/auth/*` POST ("Invalid origin"), which the Node-driven + // e2e suites never caught because bare `fetch` sends no Origin at all. + // `proxy_auth_path` re-inserts an upstream-origin `Origin` below so + // Better Auth sees a first-party request. + out.remove(header::ORIGIN); + out.remove(header::REFERER); + for name in ["sec-fetch-site", "sec-fetch-mode", "sec-fetch-dest"] { + out.remove(name); + } + out +} + +/// Removes headers that describe one HTTP connection rather than the proxied +/// message. RFC 7230 §6.1 also allows `Connection` to name arbitrary additional +/// hop-by-hop fields, so collect those tokens before removing `Connection`. +fn strip_hop_by_hop_headers(headers: &mut HeaderMap) { + let connection_tokens: Vec = headers + .get_all(header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .filter_map(|name| HeaderName::from_bytes(name.trim().as_bytes()).ok()) + .collect(); + + for name in connection_tokens { + headers.remove(name); + } + for name in [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", + ] { + headers.remove(name); + } +} + +#[derive(Debug)] +enum ProxyError { + Network(String), + /// The independent OAuth probe rejected the credential even after its + /// normal refresh path. A resource-specific `401` never produces this. + HardUnauthorized, +} + +fn proxy_client() -> &'static reqwest::Client { + static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + // This is a reverse proxy, so redirects belong to the webview. + // Following them here would hide the upstream status/Location and + // break browser-driven OAuth flows. + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("static upstream proxy client configuration must build") + }) +} + +/// Sends the request with `token`. A resource `401` is not proof that the +/// OAuth session is invalid: MCP connections can return their own `401` +/// while the Studio credential remains healthy. Revalidate against the +/// dedicated auth probe and retry only when that probe actually rotated the +/// access token. Resource-specific `401`s pass through without signing out. +/// Call an org-scoped builtin tool upstream and return its parsed JSON body. +/// +/// The interception table is otherwise a strictly-local surface (see +/// `routes/intercept`'s doc comment: an intercepted route "never talks to +/// upstream at all"). This is the one deliberate exception, for the single +/// fact local-api cannot know on its own: a virtual MCP's git repo and runtime +/// live in the cluster-side registry, and `SANDBOX_START` must resolve them +/// before it can provision a local sandbox. It fails closed — a signed-out or +/// unreachable upstream surfaces an error rather than provisioning a sandbox +/// against a guessed repo. +pub(crate) async fn call_org_tool( + org: &str, + tool_name: &str, + input: &serde_json::Value, +) -> Result { + let session = upstream::global(); + let token = session + .access_token() + .await + .map_err(|_| "not signed in to the upstream deployment".to_string())?; + + let path = format!( + "/api/{}/tools/{}", + urlencoding::encode(org), + urlencoding::encode(tool_name) + ); + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + headers.insert(header::ACCEPT, HeaderValue::from_static("application/json")); + + let body = axum::body::Bytes::from(serde_json::to_vec(input).map_err(|e| e.to_string())?); + let response = send_with_retry(&session, &Method::POST, &path, &headers, &body, token) + .await + .map_err(|error| match error { + ProxyError::Network(msg) => format!("upstream unreachable: {msg}"), + ProxyError::HardUnauthorized => "not signed in to the upstream deployment".to_string(), + })?; + + let status = response.status(); + let bytes = response + .bytes() + .await + .map_err(|e| format!("could not read {tool_name} response: {e}"))?; + if !status.is_success() { + return Err(format!( + "{tool_name} failed upstream ({status}): {}", + String::from_utf8_lossy(&bytes) + .chars() + .take(300) + .collect::() + )); + } + serde_json::from_slice(&bytes).map_err(|e| format!("{tool_name} returned invalid JSON: {e}")) +} + +/// Why an authenticated upstream call never produced a response at all. A +/// non-2xx response is NOT one of these — it comes back as `Ok`, so the +/// caller decides what its status means. +#[derive(Debug)] +pub(crate) enum UpstreamCallError { + /// No usable session, or the OAuth probe rejected the credential. + NotSignedIn, + /// Network/timeout reaching the deployment. + Unreachable(String), +} + +/// Send one authenticated request to an arbitrary upstream path and hand the +/// raw response back — status, headers and an unconsumed body, so a caller +/// serving a large object can stream it rather than buffering. +/// +/// [`call_org_tool`] above is the JSON-tool-shaped convenience over the same +/// machinery; this is the general form, used by `routes/webdav.rs` for the +/// org filesystem's REST contract (`/api/:org/fs/:volume/*`), which is not a +/// tool call. Both share [`send_with_retry`], so both get the same +/// "resource 401 is not a session 401" revalidate-and-retry semantics and the +/// same server-side token attachment — a caller never sees or supplies the +/// upstream bearer. +pub(crate) async fn send_org_request( + method: Method, + path_and_query: &str, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result { + let session = upstream::global(); + let token = session + .access_token() + .await + .map_err(|_| UpstreamCallError::NotSignedIn)?; + send_with_retry(&session, &method, path_and_query, &headers, &body, token) + .await + .map_err(|error| match error { + ProxyError::Network(msg) => UpstreamCallError::Unreachable(msg), + ProxyError::HardUnauthorized => UpstreamCallError::NotSignedIn, + }) +} + +async fn send_with_retry( + session: &upstream::UpstreamSession, + method: &Method, + path_and_query: &str, + headers: &HeaderMap, + body: &axum::body::Bytes, + token: String, +) -> Result { + let url = format!("{}{path_and_query}", session.target()); + let first = send_once(method, &url, headers, body.clone(), &token).await?; + if first.status() != StatusCode::UNAUTHORIZED { + return Ok(first); + } + + let status = session.force_revalidate().await; + if !status.signed_in { + return Err(ProxyError::HardUnauthorized); + } + + let current = match session.access_token().await { + Ok(current) => current, + Err(_) => return Err(ProxyError::HardUnauthorized), + }; + if current == token { + return Ok(first); + } + + send_once(method, &url, headers, body.clone(), ¤t).await +} + +async fn send_once( + method: &Method, + url: &str, + headers: &HeaderMap, + body: axum::body::Bytes, + token: &str, +) -> Result { + let builder = proxy_client() + .request(method.clone(), url) + .headers(headers.clone()) + .bearer_auth(token) + .body(body); + match tokio::time::timeout(UPSTREAM_HEADERS_TIMEOUT, builder.send()).await { + Ok(Ok(resp)) => Ok(resp), + Ok(Err(err)) => Err(ProxyError::Network(err.to_string())), + Err(_elapsed) => Err(ProxyError::Network("upstream headers timeout".to_string())), + } +} + +/// [`send_once`]'s sibling for [`proxy_auth_path`] — same headers-timeout +/// handling, but no `Authorization: Bearer` attached at all (the auth-path +/// branch authenticates via the `Cookie` header already present in +/// `headers`, not a bearer token). +async fn send_once_no_bearer( + method: &Method, + url: &str, + headers: &HeaderMap, + body: axum::body::Bytes, +) -> Result { + let builder = proxy_client() + .request(method.clone(), url) + .headers(headers.clone()) + .body(body); + match tokio::time::timeout(UPSTREAM_HEADERS_TIMEOUT, builder.send()).await { + Ok(Ok(resp)) => Ok(resp), + Ok(Err(err)) => Err(ProxyError::Network(err.to_string())), + Err(_elapsed) => Err(ProxyError::Network("upstream headers timeout".to_string())), + } +} + +/// Streams the upstream response back verbatim — status, headers (minus +/// hop-by-hop, PLUS `Set-Cookie` — see below), and body via `bytes_stream()` +/// rather than buffering, so SSE/chunked responses pass through +/// incrementally instead of waiting for the whole thing. +async fn build_response(upstream: reqwest::Response) -> Response { + let status = upstream.status(); + let mut headers = upstream.headers().clone(); + strip_hop_by_hop_headers(&mut headers); + // Defense-in-depth, uniform with `build_auth_response`: this branch now + // also attaches the durable session cookie (`attach_persisted_cookie`), so + // an org-scoped route that unexpectedly echoed a `Set-Cookie` must never + // let it reach the webview. + headers.remove(header::SET_COOKIE); + let body = Body::from_stream(upstream.bytes_stream()); + let mut res = Response::new(body); + *res.status_mut() = status; + *res.headers_mut() = headers; + res +} + +/// [`build_response`]'s sibling for [`proxy_auth_path`]: same hop-by-hop +/// stripping and streaming behavior, PLUS `Set-Cookie` is ALWAYS stripped +/// (already captured into the jar by the caller before this runs — see +/// `proxy_auth_path`) — the webview must never see the real upstream +/// session cookie. `HeaderMap::remove` removes every entry for a given +/// name, so a response carrying multiple `Set-Cookie` headers (Better Auth +/// sometimes sets more than one, e.g. a session token plus a companion +/// flag cookie) is fully stripped, not just the first. +async fn build_auth_response(upstream: reqwest::Response) -> Response { + let status = upstream.status(); + let mut headers = upstream.headers().clone(); + strip_hop_by_hop_headers(&mut headers); + headers.remove(header::SET_COOKIE); + let body = Body::from_stream(upstream.bytes_stream()); + let mut res = Response::new(body); + *res.status_mut() = status; + *res.headers_mut() = headers; + res +} + +/// `401 {"error":"unauthorized","upstream":true}` — same envelope as the +/// LOCAL bearer's own 401 (the `error` field), plus an `upstream: true` +/// hint so a caller can disambiguate the two (the contract doc flagged +/// this exact shape as "TBD" pending a Phase 3 implementer; this is that +/// implementation). Used both when there's no upstream session to even +/// attempt a request with, and when the dedicated OAuth probe rejects the +/// credential after its refresh path. A resource-specific `401` is passed +/// through unchanged and never reaches this helper. +fn unauthorized_upstream() -> Response { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "unauthorized", "upstream": true })), + ) + .into_response() +} + +/// `POST /_auth/complete-session` — an HTTP mirror of the +/// `auth_complete_session` Tauri command (`src-tauri/src/commands.rs`), +/// wired directly on the `upstream` sub-router in `router.rs` ALONGSIDE +/// (not through) [`proxy`]'s `/api/*` scope allowlist, so the black-box +/// contract suite (`apps/native/e2e/`, which has no Tauri context to +/// invoke IPC commands through) can drive the SAME bridge +/// (`upstream::UpstreamSession::complete_session`) the shell calls into. +/// Both call sites converge on the one `upstream::global()` singleton, so +/// they can never disagree about the outcome — this route exists purely +/// because the e2e harness only speaks HTTP, not as a second +/// implementation. Not part of the `/api/*` proxy scope allowlist (this is +/// a LOCAL action, never forwarded anywhere) and not documented as a +/// stable public surface in the contract doc beyond this note. +pub async fn complete_session() -> Response { + match upstream::global().complete_session().await { + Ok(status) => Json(json!({ + "signedIn": status.signed_in, + "userLabel": status.user_label, + })) + .into_response(), + Err(err) => { + let is_no_cookie = matches!( + err, + upstream::session::SessionError::Bridge( + upstream::bridge::BridgeError::NoSessionCookie + ) + ); + let status = if is_no_cookie { + StatusCode::UNAUTHORIZED + } else { + StatusCode::BAD_GATEWAY + }; + (status, Json(json!({ "error": err.to_string() }))).into_response() + } + } +} + +/// `GET /_auth/status` — e2e-reachable HTTP mirror of the shell's +/// `auth_status` Tauri command. Like [`complete_session`] and [`logout`], this +/// calls the same process-wide session singleton; it exists so black-box HTTP +/// coverage can prove that a proxied Better Auth sign-out changed the native +/// auth state rather than merely making subsequent proxy calls fail. +pub async fn status() -> Response { + let status = upstream::global().status().await; + Json(json!({ + "signedIn": status.signed_in, + "userLabel": status.user_label, + "upstreamUrl": status.upstream_url, + })) + .into_response() +} + +/// `POST /_auth/logout` — the same kind of e2e-reachable HTTP mirror as +/// [`complete_session`], for the `auth_logout` Tauri command +/// (`src-tauri/src/commands.rs::auth_logout`). Both call sites converge on +/// the SAME `upstream::global().logout()` — this route exists purely +/// because `apps/native/e2e/` has no Tauri context to invoke IPC commands +/// through, not as a second implementation. Always `200` (logout has no +/// failure mode from the caller's point of view — see +/// `UpstreamSession::logout`'s own doc comment on why the upstream revoke +/// half is unconditionally best-effort). +pub async fn logout() -> Response { + let status = upstream::global().logout().await; + Json(json!({ + "signedIn": status.signed_in, + "userLabel": status.user_label, + })) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + use axum::routing::{get, post}; + use axum::Router; + use futures::StreamExt; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use tokio::net::TcpListener; + use upstream::tokens::{ + host_key, test_support::MemoryTokenStore, StoredSession, TokenStore, UserInfo, + }; + use upstream::UpstreamSession; + + /// The whole point of the rewrite: the version the webview polls for must + /// be the bundle's own, so `version-check-dialog.tsx` never nags inside an + /// app whose "Refresh" cannot fetch new assets. An empty constant silently + /// disables that, so pin it here — a renamed/moved `apps/api/package.json` + /// must fail this test, not degrade quietly at runtime. + #[test] + fn studio_web_version_is_baked_in_at_build_time() { + assert!( + !STUDIO_WEB_VERSION.is_empty(), + "build.rs failed to read apps/api/package.json's version" + ); + assert!( + STUDIO_WEB_VERSION.starts_with(|c: char| c.is_ascii_digit()), + "expected a semver-ish version, got {STUDIO_WEB_VERSION:?}" + ); + } + + #[test] + fn protected_resource_metadata_points_at_the_caller_origin() { + // The real RFC 9728 shape, built by upstream from ITS own origin. + let body = br#"{"resource":"https://studio.decocms.com/api/acme/mcp/conn_1","authorization_servers":["https://studio.decocms.com/oauth-proxy/conn_1"],"bearer_methods_supported":["header"]}"#; + let out = + rewrite_resource_field(body, "https://studio.decocms.com", "http://localhost:4420") + .expect("rewritten"); + let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); + + assert_eq!(v["resource"], "http://localhost:4420/api/acme/mcp/conn_1"); + // The authorization server is upstream's and stays upstream's — this + // proxy has no OAuth endpoints of its own to offer instead. + assert_eq!( + v["authorization_servers"][0], + "https://studio.decocms.com/oauth-proxy/conn_1" + ); + assert_eq!(v["bearer_methods_supported"][0], "header"); + } + + #[test] + fn metadata_that_does_not_name_upstream_is_left_alone() { + for untouched in [ + // Already local. + br#"{"resource":"http://localhost:4420/api/a/mcp/c"}"#.as_slice(), + // A different host that merely starts the same way. + br#"{"resource":"https://studio.decocms.com.evil.example/api/a/mcp/c"}"#.as_slice(), + // No `resource` field at all. + br#"{"authorization_servers":["https://studio.decocms.com/x"]}"#.as_slice(), + // Not JSON. + b"nope".as_slice(), + ] { + assert!(rewrite_resource_field( + untouched, + "https://studio.decocms.com", + "http://localhost:4420" + ) + .is_none()); + } + } + + #[test] + fn caller_origin_prefers_origin_then_falls_back_to_host() { + let mut h = HeaderMap::new(); + h.insert(header::HOST, HeaderValue::from_static("localhost:4420")); + assert_eq!(caller_origin(&h).as_deref(), Some("http://localhost:4420")); + + h.insert( + header::ORIGIN, + HeaderValue::from_static("http://localhost:9999"), + ); + assert_eq!(caller_origin(&h).as_deref(), Some("http://localhost:9999")); + } + + #[test] + fn both_rfc9728_anchorings_are_recognized() { + assert!(is_protected_resource_metadata( + "/.well-known/oauth-protected-resource/api/acme/mcp/conn_1" + )); + assert!(is_protected_resource_metadata( + "/mcp/conn_1/.well-known/oauth-protected-resource" + )); + assert!(!is_protected_resource_metadata("/api/acme/mcp/conn_1")); + } + + #[test] + fn org_logos_are_rewritten_to_a_same_origin_path() { + // The real shape: Better Auth's organization list with an absolute + // upstream logo URL, which an cannot fetch cross-origin. + let body = br#"[{"id":"o1","slug":"fila","logo":"https://studio.decocms.com/api/fila/files/org-logos/abc123"}]"#; + let out = + localize_first_party_asset_urls(body, "https://studio.decocms.com").expect("rewritten"); + let text = String::from_utf8(out).unwrap(); + assert!( + text.contains(r#""logo":"/api/fila/files/org-logos/abc123""#), + "{text}" + ); + } + + /// Widening this would turn the proxy into a general authenticated asset + /// relay, so what it must NOT touch is the part worth pinning. + #[test] + fn only_exact_origin_first_party_file_urls_are_rewritten() { + let origin = "https://studio.decocms.com"; + for untouched in [ + // A different host that merely starts the same way. + r#"{"logo":"https://studio.decocms.com.evil.example/api/o/files/x"}"#, + // Public CDN asset — must keep loading directly. + r#"{"logo":"https://assets.decocache.com/decocms/abc/def"}"#, + // Right origin, but not a protected-file path. + r#"{"url":"https://studio.decocms.com/api/o/threads/t1"}"#, + // Credentials in the URL never prefix-match the bare origin. + r#"{"logo":"https://user:pw@studio.decocms.com/api/o/files/x"}"#, + ] { + assert!( + localize_first_party_asset_urls(untouched.as_bytes(), origin).is_none(), + "rewrote {untouched}" + ); + } + } + + #[test] + fn only_org_bearing_auth_paths_are_buffered() { + assert!(carries_org_assets("/api/auth/organization/list")); + assert!(carries_org_assets("/api/auth/get-session?x=1")); + // Everything else keeps streaming. + assert!(!carries_org_assets("/api/auth/sign-in/email")); + assert!(!carries_org_assets("/api/auth/callback/google")); + } + + #[test] + fn patch_config_version_replaces_only_the_version_field() { + let body = br#"{"config":{"version":"9.9.9","posthog":{"key":"k"},"runtime":{"a":1}}}"#; + let patched = patch_config_version(body, "4.125.3").expect("well-formed config is patched"); + let value: serde_json::Value = serde_json::from_slice(&patched).unwrap(); + + assert_eq!(value["config"]["version"], "4.125.3"); + // Everything else passes through untouched. + assert_eq!(value["config"]["posthog"]["key"], "k"); + assert_eq!(value["config"]["runtime"]["a"], 1); + } + + #[test] + fn patch_config_version_passes_through_unexpected_shapes() { + // Not JSON at all (an HTML error page from a proxy in front of us). + assert!(patch_config_version(b"nope", "4.125.3").is_none()); + // JSON, but no `config` envelope. + assert!(patch_config_version(br#"{"version":"1.0.0"}"#, "4.125.3").is_none()); + // `config` present but no `version` key. + assert!(patch_config_version(br#"{"config":{}}"#, "4.125.3").is_none()); + // `version` present but not a string — never coerce a foreign shape. + assert!(patch_config_version(br#"{"config":{"version":42}}"#, "4.125.3").is_none()); + } + + #[test] + fn merge_cookie_headers_keeps_session_cookie_alongside_captured_cf_bm() { + // The exact regression: persisted holds the session cookie, the jar + // holds only Cloudflare's `__cf_bm` — the merge must forward BOTH, so + // `__cf_bm` can't shadow the session cookie (which 401'd org calls). + let merged = merge_cookie_headers( + Some("__Secure-better-auth.session_token=SESSION"), + Some("__cf_bm=CFVALUE"), + ) + .expect("merge of two non-empty headers is Some"); + assert!(merged.contains("__Secure-better-auth.session_token=SESSION")); + assert!(merged.contains("__cf_bm=CFVALUE")); + } + + #[test] + fn merge_cookie_headers_overlay_wins_and_handles_absent_sides() { + // Jar (overlay) carries the freshest rotated session token → it wins, + // but keeps the original first-seen position. + assert_eq!( + merge_cookie_headers(Some("a=old; b=keep"), Some("a=new")).as_deref(), + Some("a=new; b=keep"), + ); + assert_eq!( + merge_cookie_headers(Some("a=1"), None).as_deref(), + Some("a=1") + ); + assert_eq!( + merge_cookie_headers(None, Some("b=2")).as_deref(), + Some("b=2") + ); + assert_eq!(merge_cookie_headers(None, None), None); + assert_eq!(merge_cookie_headers(Some(" "), None), None); + } + + #[test] + fn is_better_auth_set_cookie_accepts_our_cookies_and_rejects_cdn_ones() { + // Ours — every prefix variant. + assert!(is_better_auth_set_cookie( + "__Secure-better-auth.session_token=abc; Path=/; HttpOnly" + )); + assert!(is_better_auth_set_cookie("better-auth.session_token=abc")); + assert!(is_better_auth_set_cookie( + "__Host-better-auth.session_data=xyz; Secure" + )); + // Cloudflare / CDN infra cookies — must be rejected so they can't + // clobber the persisted session cookie. + assert!(!is_better_auth_set_cookie("__cf_bm=abc; Path=/; Secure")); + assert!(!is_better_auth_set_cookie("cf_clearance=abc")); + assert!(!is_better_auth_set_cookie("_cfuvid=abc")); + } + + fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + } + + async fn signed_in_session_with_store( + target: &str, + access_token: &str, + ) -> (UpstreamSession, Arc) { + let store = Arc::new(MemoryTokenStore::new()); + let session = StoredSession { + target: target.to_string(), + client_id: "client_1".to_string(), + user: UserInfo { + sub: "user_1".to_string(), + email: Some("a@b.com".to_string()), + name: None, + }, + access_token: access_token.to_string(), + refresh_token: Some("refresh_1".to_string()), + expires_at: Some(now_unix() + 3600), + created_at: "2026-01-01T00:00:00Z".to_string(), + cookie: None, + }; + store.save(&host_key(target), session).await.unwrap(); + ( + UpstreamSession::new(target.to_string(), store.clone()), + store, + ) + } + + async fn signed_in_session(target: &str, access_token: &str) -> UpstreamSession { + signed_in_session_with_store(target, access_token).await.0 + } + + #[tokio::test] + async fn native_watch_is_intercepted_without_contacting_upstream() { + let dir = tempfile::tempdir().unwrap(); + let state = crate::routes::intercept::test_state(dir.path()); + let request = Request::builder() + .method(Method::GET) + .uri("/api/acme/watch?types=decopilot.thread.status") + .body(Body::empty()) + .unwrap(); + + // The process-global upstream session is deliberately not configured + // in this test. A fallthrough would return 401 (or block on credential + // resolution); the local watch must answer immediately instead. + let response = tokio::time::timeout(Duration::from_secs(1), proxy(State(state), request)) + .await + .expect("local watch attempted an upstream operation"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "text/event-stream" + ); + + let mut body = response.into_body().into_data_stream(); + let connected = tokio::time::timeout(Duration::from_secs(1), body.next()) + .await + .expect("local connected frame timed out") + .expect("local watch stream ended") + .expect("local watch frame failed"); + let connected = String::from_utf8(connected.to_vec()).unwrap(); + assert!(connected.contains("event: connected")); + assert!(connected.contains("\"organizationId\":\"acme\"")); + } + + // --- proxy_auth_path: the /api/auth/* cookie-relay branch --------------- + + #[tokio::test] + async fn proxy_auth_path_captures_set_cookie_strips_it_and_attaches_it_next_time() { + let seen_cookie_headers = Arc::new(std::sync::Mutex::new(Vec::>::new())); + let seen_for_route = seen_cookie_headers.clone(); + let app = Router::new().route( + "/api/auth/sign-in/email", + post(move |headers: HeaderMap| { + let seen = seen_for_route.clone(); + async move { + seen.lock().unwrap().push( + headers + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()), + ); + ( + [( + header::SET_COOKIE, + "better-auth.session_token=abc123; Path=/; HttpOnly", + )], + Json(json!({"ok": true})), + ) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let target = format!("http://{addr}"); + + let store = Arc::new(MemoryTokenStore::new()); + let session = UpstreamSession::new(target, store); + let headers = HeaderMap::new(); + let body = axum::body::Bytes::from_static(br#"{"email":"a@b.com"}"#); + + // First call: jar is empty, nothing forwarded yet. + let res1 = proxy_auth_path( + &session, + &Method::POST, + "/api/auth/sign-in/email", + &headers, + &body, + ) + .await; + assert_eq!(res1.status(), StatusCode::OK); + assert!( + res1.headers().get(header::SET_COOKIE).is_none(), + "Set-Cookie must never reach the caller" + ); + assert!( + !session.cookie_jar().is_empty(session.host()), + "the response's Set-Cookie must be captured into the jar" + ); + + // Second call: the jar's captured cookie must now ride along. + let res2 = proxy_auth_path( + &session, + &Method::POST, + "/api/auth/sign-in/email", + &headers, + &body, + ) + .await; + assert_eq!(res2.status(), StatusCode::OK); + + let seen = seen_cookie_headers.lock().unwrap(); + assert_eq!(seen[0], None, "first request had nothing captured yet"); + assert_eq!( + seen[1].as_deref(), + Some("better-auth.session_token=abc123"), + "second request must attach the jar's captured cookie" + ); + } + + #[tokio::test] + async fn proxy_auth_path_never_attaches_the_oauth_bearer_token() { + let seen_auth = Arc::new(std::sync::Mutex::new(None::)); + let seen_for_route = seen_auth.clone(); + let app = Router::new().route( + "/api/auth/get-session", + get(move |headers: HeaderMap| { + let seen = seen_for_route.clone(); + async move { + *seen.lock().unwrap() = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + StatusCode::OK + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let target = format!("http://{addr}"); + + // A session WITH a stored OAuth token — the auth-path branch must + // still never attach it (org-data bearer forwarding and + // /api/auth/* cookie forwarding are mutually exclusive branches). + let session = signed_in_session(&target, "some-oauth-access-token").await; + let headers = HeaderMap::new(); + let body = axum::body::Bytes::new(); + + let res = proxy_auth_path( + &session, + &Method::GET, + "/api/auth/get-session", + &headers, + &body, + ) + .await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + seen_auth.lock().unwrap().as_deref(), + None, + "the auth-path branch must never attach the Keychain OAuth bearer" + ); + } + + #[tokio::test] + async fn proxy_auth_path_ignores_the_callers_own_cookie_header() { + // A caller-forged/stray `Cookie` header must never ride through — + // only the jar's own captured value (or nothing) is ever forwarded. + let seen_cookie = Arc::new(std::sync::Mutex::new(Some("unset".to_string()))); + let seen_for_route = seen_cookie.clone(); + let app = Router::new().route( + "/api/auth/get-session", + get(move |headers: HeaderMap| { + let seen = seen_for_route.clone(); + async move { + *seen.lock().unwrap() = headers + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + StatusCode::OK + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let target = format!("http://{addr}"); + let store = Arc::new(MemoryTokenStore::new()); + let session = UpstreamSession::new(target, store); + + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_static("forged=should-never-be-forwarded"), + ); + let body = axum::body::Bytes::new(); + + proxy_auth_path( + &session, + &Method::GET, + "/api/auth/get-session", + &headers, + &body, + ) + .await; + + assert_eq!( + seen_cookie.lock().unwrap().as_ref(), + None, + "the jar is empty and the caller's own Cookie header must never be forwarded" + ); + } + + // --- proxy_public_config: the GET /api/config no-auth branch ----------- + + #[tokio::test] + async fn proxy_public_config_works_with_no_session_at_all() { + // The whole point: this must succeed even for a session with NO + // stored token and an EMPTY cookie jar — the exact pre-sign-in state + // the desktop sign-in screen is in when it needs this route most. + let app = Router::new().route( + "/api/config", + get(|| async { Json(json!({"auth": {"emailAndPassword": {"enabled": true}}})) }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let target = format!("http://{addr}"); + let store = Arc::new(MemoryTokenStore::new()); + let session = UpstreamSession::new(target, store); + let headers = HeaderMap::new(); + let body = axum::body::Bytes::new(); + + let res = proxy_public_config(&session, &Method::GET, "/api/config", &headers, &body).await; + assert_eq!(res.status(), StatusCode::OK); + } + + #[tokio::test] + async fn proxy_public_config_never_attaches_a_bearer_or_cookie_from_the_jar() { + let seen_auth = Arc::new(std::sync::Mutex::new(Some("unset".to_string()))); + let seen_cookie = Arc::new(std::sync::Mutex::new(Some("unset".to_string()))); + let seen_auth_for_route = seen_auth.clone(); + let seen_cookie_for_route = seen_cookie.clone(); + let app = Router::new().route( + "/api/config", + get(move |headers: HeaderMap| { + let seen_auth = seen_auth_for_route.clone(); + let seen_cookie = seen_cookie_for_route.clone(); + async move { + *seen_auth.lock().unwrap() = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + *seen_cookie.lock().unwrap() = headers + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + ( + [(header::SET_COOKIE, "should-be-stripped=yes; Path=/")], + Json(json!({"ok": true})), + ) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let target = format!("http://{addr}"); + + // A session WITH both a stored OAuth token AND a captured cookie — + // this branch must ignore both. + let session = signed_in_session(&target, "some-oauth-access-token").await; + session.cookie_jar().capture( + session.host(), + std::iter::once("session=should-not-be-sent"), + ); + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + HeaderValue::from_static("forged=should-never-be-forwarded"), + ); + let body = axum::body::Bytes::new(); + + let res = proxy_public_config(&session, &Method::GET, "/api/config", &headers, &body).await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + seen_auth.lock().unwrap().as_deref(), + None, + "GET /api/config must never carry the Keychain OAuth bearer" + ); + assert_eq!( + seen_cookie.lock().unwrap().as_deref(), + None, + "GET /api/config must never carry the jar's cookie or the caller's own" + ); + assert!( + res.headers().get(header::SET_COOKIE).is_none(), + "Set-Cookie must be stripped even on this public branch" + ); + } + + #[tokio::test] + async fn build_forward_headers_strips_hop_by_hop_host_and_local_authorization() { + let mut incoming = HeaderMap::new(); + incoming.insert( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer local-token"), + ); + incoming.insert(header::HOST, HeaderValue::from_static("127.0.0.1:9999")); + incoming.insert( + header::CONNECTION, + HeaderValue::from_static("keep-alive, x-private"), + ); + incoming.insert("x-private", HeaderValue::from_static("connection-only")); + incoming.insert("keep-alive", HeaderValue::from_static("timeout=5")); + incoming.insert("proxy-connection", HeaderValue::from_static("keep-alive")); + incoming.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + + let out = build_forward_headers(&incoming); + assert!(out.get(header::AUTHORIZATION).is_none()); + assert!(out.get(header::HOST).is_none()); + assert!(out.get(header::CONNECTION).is_none()); + assert!(out.get("x-private").is_none()); + assert!(out.get("keep-alive").is_none()); + assert!(out.get("proxy-connection").is_none()); + assert_eq!( + out.get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("application/json")) + ); + } + + #[tokio::test] + async fn forwards_method_path_query_body_and_attaches_upstream_bearer() { + let seen_auth = Arc::new(std::sync::Mutex::new(None)); + let seen_for_route = seen_auth.clone(); + let app = Router::new().route( + "/api/acme/threads", + post(move |headers: HeaderMap, body: axum::body::Bytes| { + let seen = seen_for_route.clone(); + async move { + *seen.lock().unwrap() = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + // Echo the raw body back verbatim (not re-wrapped in a + // JSON field, which would escape its quotes and make + // the assertion below a substring-match footgun). + (StatusCode::OK, body) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let target = format!("http://{addr}"); + let session = signed_in_session(&target, "good-token").await; + + let headers = HeaderMap::new(); + let body = axum::body::Bytes::from_static(br#"{"hello":"world"}"#); + let res = send_with_retry( + &session, + &Method::POST, + "/api/acme/threads?x=1", + &headers, + &body, + "good-token".to_string(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + seen_auth.lock().unwrap().as_deref(), + Some("Bearer good-token") + ); + + let text = res.text().await.unwrap(); + assert_eq!(text, r#"{"hello":"world"}"#); + } + + #[tokio::test] + async fn forwards_redirect_status_and_location_without_following() { + let followed = Arc::new(AtomicUsize::new(0)); + let followed_for_route = followed.clone(); + let app = Router::new() + .route( + "/oauth-proxy/start", + get(|| async { + ( + StatusCode::FOUND, + [(header::LOCATION, "/oauth-proxy/complete?code=redirect-code")], + ) + }), + ) + .route( + "/oauth-proxy/complete", + get(move || { + let followed = followed_for_route.clone(); + async move { + followed.fetch_add(1, Ordering::SeqCst); + StatusCode::OK + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let target = format!("http://{addr}"); + let session = signed_in_session(&target, "good-token").await; + let upstream = send_with_retry( + &session, + &Method::GET, + "/oauth-proxy/start", + &HeaderMap::new(), + &axum::body::Bytes::new(), + "good-token".to_string(), + ) + .await + .unwrap(); + let response = build_response(upstream).await; + + assert_eq!(response.status(), StatusCode::FOUND); + assert_eq!( + response.headers().get(header::LOCATION), + Some(&HeaderValue::from_static( + "/oauth-proxy/complete?code=redirect-code" + )) + ); + assert_eq!( + followed.load(Ordering::SeqCst), + 0, + "the Rust proxy must not consume browser-owned redirects" + ); + } + + #[tokio::test] + async fn a_resource_specific_401_preserves_the_authenticated_session() { + let calls = Arc::new(AtomicUsize::new(0)); + let calls_route = calls.clone(); + let app = Router::new() + .route( + "/api/acme/threads", + get(move || { + let calls = calls_route.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + ( + StatusCode::UNAUTHORIZED, + [(header::WWW_AUTHENTICATE, "Bearer resource-oauth")], + "connection authorization required", + ) + } + }), + ) + .route("/api/links/me", get(|| async { StatusCode::OK })); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let target = format!("http://{addr}"); + let session = signed_in_session(&target, "old-token").await; + + let headers = HeaderMap::new(); + let body = axum::body::Bytes::new(); + let res = send_with_retry( + &session, + &Method::GET, + "/api/acme/threads", + &headers, + &body, + "old-token".to_string(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + res.headers().get(header::WWW_AUTHENTICATE), + Some(&HeaderValue::from_static("Bearer resource-oauth")), + ); + assert_eq!( + res.text().await.unwrap(), + "connection authorization required", + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "must not retry when the dedicated auth probe accepts the same token" + ); + assert!(session.status().await.signed_in); + } + + #[tokio::test] + async fn a_401_force_refreshes_and_retries_once_with_the_new_token() { + let calls = Arc::new(AtomicUsize::new(0)); + let calls_route = calls.clone(); + let app = Router::new() + .route( + "/api/acme/threads", + get(move |headers: HeaderMap| { + let calls = calls_route.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + match headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + { + Some("Bearer refreshed-token") => StatusCode::OK, + _ => StatusCode::UNAUTHORIZED, + } + } + }), + ) + .route( + "/api/auth/mcp/token", + post(|| async { + Json(json!({ + "access_token": "refreshed-token", + "refresh_token": "rotated-refresh-token", + "expires_in": 3600, + })) + }), + ) + .route( + "/api/links/me", + get(|headers: HeaderMap| async move { + match headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + { + Some("Bearer refreshed-token") => StatusCode::OK, + _ => StatusCode::UNAUTHORIZED, + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let target = format!("http://{addr}"); + let (session, store) = signed_in_session_with_store(&target, "old-token").await; + let res = send_with_retry( + &session, + &Method::GET, + "/api/acme/threads", + &HeaderMap::new(), + &axum::body::Bytes::new(), + "old-token".to_string(), + ) + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(calls.load(Ordering::SeqCst), 2); + let persisted = store + .load(&host_key(&target)) + .await + .unwrap() + .expect("refreshed session remains persisted"); + assert_eq!(persisted.access_token, "refreshed-token"); + assert_eq!( + persisted.refresh_token.as_deref(), + Some("rotated-refresh-token") + ); + } + + #[tokio::test] + async fn a_transient_forced_refresh_failure_preserves_the_persisted_session() { + let resource_calls = Arc::new(AtomicUsize::new(0)); + let resource_calls_route = resource_calls.clone(); + let refresh_calls = Arc::new(AtomicUsize::new(0)); + let refresh_calls_route = refresh_calls.clone(); + let app = Router::new() + .route( + "/api/acme/threads", + get(move || { + let calls = resource_calls_route.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + StatusCode::UNAUTHORIZED + } + }), + ) + .route( + "/api/auth/mcp/token", + post(move || { + let calls = refresh_calls_route.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "temporary outage" })), + ) + } + }), + ) + .route("/api/links/me", get(|| async { StatusCode::UNAUTHORIZED })); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let target = format!("http://{addr}"); + let (session, store) = signed_in_session_with_store(&target, "old-token").await; + let res = send_with_retry( + &session, + &Method::GET, + "/api/acme/threads", + &HeaderMap::new(), + &axum::body::Bytes::new(), + "old-token".to_string(), + ) + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert_eq!(resource_calls.load(Ordering::SeqCst), 1); + assert_eq!(refresh_calls.load(Ordering::SeqCst), 1); + let persisted = store + .load(&host_key(&target)) + .await + .unwrap() + .expect("transient refresh failure must preserve the session"); + assert_eq!(persisted.access_token, "old-token"); + assert_eq!(persisted.refresh_token.as_deref(), Some("refresh_1")); + } + + #[tokio::test] + async fn an_invalid_grant_during_forced_refresh_clears_the_persisted_session() { + let resource_calls = Arc::new(AtomicUsize::new(0)); + let resource_calls_route = resource_calls.clone(); + let app = Router::new() + .route( + "/api/acme/threads", + get(move || { + let calls = resource_calls_route.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + StatusCode::UNAUTHORIZED + } + }), + ) + .route( + "/api/auth/mcp/token", + post(|| async { + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "invalid_grant" })), + ) + }), + ) + .route("/api/links/me", get(|| async { StatusCode::UNAUTHORIZED })); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let target = format!("http://{addr}"); + let (session, store) = signed_in_session_with_store(&target, "old-token").await; + let err = send_with_retry( + &session, + &Method::GET, + "/api/acme/threads", + &HeaderMap::new(), + &axum::body::Bytes::new(), + "old-token".to_string(), + ) + .await + .unwrap_err(); + + assert!(matches!(err, ProxyError::HardUnauthorized)); + assert_eq!(resource_calls.load(Ordering::SeqCst), 1); + assert!( + store.load(&host_key(&target)).await.unwrap().is_none(), + "a terminal refresh rejection must clear the stale session" + ); + } + + #[tokio::test] + async fn a_403_passes_through_untouched_without_triggering_sign_out() { + let app = Router::new().route( + "/api/acme/threads", + get(|| async { + ( + StatusCode::FORBIDDEN, + Json(json!({"error": "not a member of this org"})), + ) + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let target = format!("http://{addr}"); + let session = signed_in_session(&target, "good-token").await; + + let headers = HeaderMap::new(); + let body = axum::body::Bytes::new(); + let res = send_with_retry( + &session, + &Method::GET, + "/api/acme/threads", + &headers, + &body, + "good-token".to_string(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + assert!( + session.status().await.signed_in, + "a 403 (authorization, not authentication) must never trigger a sign-out" + ); + } + + #[tokio::test] + async fn build_response_streams_body_and_strips_hop_by_hop_response_headers() { + let app = Router::new().route( + "/x", + get(|| async { + ( + [ + (header::CONNECTION, "x-private"), + (header::CONTENT_TYPE, "text/event-stream"), + (HeaderName::from_static("x-private"), "connection-only"), + (HeaderName::from_static("keep-alive"), "timeout=5"), + (HeaderName::from_static("proxy-connection"), "keep-alive"), + ], + "data: hello\n\n", + ) + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let client = reqwest::Client::new(); + let upstream_resp = client.get(format!("http://{addr}/x")).send().await.unwrap(); + let res = build_response(upstream_resp).await; + assert_eq!(res.status(), StatusCode::OK); + assert!(res.headers().get(header::CONNECTION).is_none()); + assert!(res.headers().get("x-private").is_none()); + assert!(res.headers().get("keep-alive").is_none()); + assert!(res.headers().get("proxy-connection").is_none()); + assert_eq!( + res.headers().get(header::CONTENT_TYPE), + Some(&HeaderValue::from_static("text/event-stream")) + ); + let body = axum::body::to_bytes(res.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&body[..], b"data: hello\n\n"); + } + + #[tokio::test] + async fn build_response_strips_set_cookie_too() { + // Defense-in-depth: even the ordinary org-data branch must never let + // a stray `Set-Cookie` reach the webview, now that it attaches the + // durable session cookie on its OWN requests. + let app = Router::new().route( + "/x", + get(|| async { + ( + [(header::SET_COOKIE, "should-never-reach-the-webview=1")], + "ok", + ) + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let client = reqwest::Client::new(); + let upstream_resp = client.get(format!("http://{addr}/x")).send().await.unwrap(); + let res = build_response(upstream_resp).await; + assert!(res.headers().get(header::SET_COOKIE).is_none()); + } + + #[tokio::test] + async fn build_auth_response_strips_dynamic_hop_by_hop_headers_and_set_cookie() { + let app = Router::new().route( + "/x", + get(|| async { + ( + [ + (header::CONNECTION, "x-private"), + (HeaderName::from_static("x-private"), "connection-only"), + (HeaderName::from_static("keep-alive"), "timeout=5"), + (HeaderName::from_static("proxy-connection"), "keep-alive"), + ( + header::SET_COOKIE, + "better-auth.session_token=secret; HttpOnly", + ), + ], + "ok", + ) + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let upstream = reqwest::Client::new() + .get(format!("http://{addr}/x")) + .send() + .await + .unwrap(); + let res = build_auth_response(upstream).await; + + assert!(res.headers().get(header::CONNECTION).is_none()); + assert!(res.headers().get("x-private").is_none()); + assert!(res.headers().get("keep-alive").is_none()); + assert!(res.headers().get("proxy-connection").is_none()); + assert!(res.headers().get(header::SET_COOKIE).is_none()); + } + + // --- The real-UI course-correction: durable cookie on every proxied call --- + + #[tokio::test] + async fn attach_persisted_cookie_sets_the_header_when_a_cookie_is_stored() { + let target = "http://example.invalid".to_string(); + let store = Arc::new(MemoryTokenStore::new()); + let host = host_key(&target); + store + .save( + &host, + StoredSession { + target: target.clone(), + client_id: "client_1".to_string(), + user: UserInfo { + sub: "user_1".to_string(), + email: Some("a@b.com".to_string()), + name: None, + }, + access_token: "tok".to_string(), + refresh_token: Some("refresh_1".to_string()), + expires_at: Some(now_unix() + 3600), + created_at: "2026-01-01T00:00:00Z".to_string(), + cookie: Some("better-auth.session_token=abc".to_string()), + }, + ) + .await + .unwrap(); + let session = UpstreamSession::new(target, store); + + let mut headers = HeaderMap::new(); + attach_persisted_cookie(&mut headers, &session).await; + assert_eq!( + headers.get(header::COOKIE), + Some(&HeaderValue::from_static("better-auth.session_token=abc")) + ); + } + + #[tokio::test] + async fn attach_persisted_cookie_is_a_no_op_with_no_stored_cookie() { + let target = "http://example.invalid".to_string(); + let session = signed_in_session(&target, "tok").await; // no cookie seeded + let mut headers = HeaderMap::new(); + attach_persisted_cookie(&mut headers, &session).await; + assert!(headers.get(header::COOKIE).is_none()); + } + + #[tokio::test] + async fn proxy_auth_path_falls_back_to_the_persisted_cookie_when_the_jar_is_empty() { + // The exact scenario the real UI's sign-in gate depends on: a NEW + // request (e.g. a page reload) to `/api/auth/get-session` AFTER + // sign-in already completed and the ephemeral jar was purged — + // must still carry the durable, Keychain-persisted cookie. + let seen_cookie = Arc::new(std::sync::Mutex::new(None::)); + let seen_for_route = seen_cookie.clone(); + let app = Router::new().route( + "/api/auth/get-session", + get(move |headers: HeaderMap| { + let seen = seen_for_route.clone(); + async move { + *seen.lock().unwrap() = headers + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + StatusCode::OK + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let target = format!("http://{addr}"); + + let store = Arc::new(MemoryTokenStore::new()); + let host = host_key(&target); + store + .save( + &host, + StoredSession { + target: target.clone(), + client_id: "client_1".to_string(), + user: UserInfo { + sub: "user_1".to_string(), + email: Some("a@b.com".to_string()), + name: None, + }, + access_token: "tok".to_string(), + refresh_token: Some("refresh_1".to_string()), + expires_at: Some(now_unix() + 3600), + created_at: "2026-01-01T00:00:00Z".to_string(), + cookie: Some("better-auth.session_token=durable".to_string()), + }, + ) + .await + .unwrap(); + let session = UpstreamSession::new(target, store); + // The ephemeral jar is deliberately EMPTY — this is the whole point + // of the test. + assert!(session.cookie_jar().is_empty(session.host())); + + let headers = HeaderMap::new(); + let body = axum::body::Bytes::new(); + let res = proxy_auth_path( + &session, + &Method::GET, + "/api/auth/get-session", + &headers, + &body, + ) + .await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + seen_cookie.lock().unwrap().as_deref(), + Some("better-auth.session_token=durable"), + "proxy_auth_path must fall back to the durable persisted cookie when the ephemeral \ + jar has nothing captured" + ); + } + + #[tokio::test] + async fn org_data_calls_attach_the_durable_cookie_alongside_the_bearer() { + // The other half of the fix: ordinary `/api/:org/...` calls (the + // bearer branch, NOT `/api/auth/*`) must ALSO carry the durable + // cookie now — some org-scoped surfaces the real shell calls may + // themselves be dual-auth. + let seen_cookie = Arc::new(std::sync::Mutex::new(None::)); + let seen_for_route = seen_cookie.clone(); + let app = Router::new().route( + "/api/acme/threads", + get(move |headers: HeaderMap| { + let seen = seen_for_route.clone(); + async move { + *seen.lock().unwrap() = headers + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + StatusCode::OK + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let target = format!("http://{addr}"); + + let store = Arc::new(MemoryTokenStore::new()); + let host = host_key(&target); + store + .save( + &host, + StoredSession { + target: target.clone(), + client_id: "client_1".to_string(), + user: UserInfo { + sub: "user_1".to_string(), + email: Some("a@b.com".to_string()), + name: None, + }, + access_token: "good-token".to_string(), + refresh_token: Some("refresh_1".to_string()), + expires_at: Some(now_unix() + 3600), + created_at: "2026-01-01T00:00:00Z".to_string(), + cookie: Some("better-auth.session_token=durable".to_string()), + }, + ) + .await + .unwrap(); + let session = UpstreamSession::new(target, store); + + let mut out_headers = HeaderMap::new(); + attach_persisted_cookie(&mut out_headers, &session).await; + let res = send_with_retry( + &session, + &Method::GET, + "/api/acme/threads", + &out_headers, + &axum::body::Bytes::new(), + "good-token".to_string(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + seen_cookie.lock().unwrap().as_deref(), + Some("better-auth.session_token=durable") + ); + } +} diff --git a/apps/native/crates/local-api/src/routes/webdav.rs b/apps/native/crates/local-api/src/routes/webdav.rs new file mode 100644 index 0000000000..cded1acc18 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/webdav.rs @@ -0,0 +1,1012 @@ +//! A minimal WebDAV/1 server over the org filesystem, targeting exactly one +//! client: rclone's `webdav` backend (`--webdav-vendor other`, no locking). +//! P1 of `apps/native/docs/org-fs-plan.md`: the mount manager (P2) points a +//! supervised `rclone` child at this loopback surface and re-serves it as NFS +//! so macOS can mount `org/home`, `org/public/`, `org/uploads` and +//! `org/outputs` kext-free. +//! +//! Port of `packages/sandbox/daemon/org-fs/webdav.ts`. The PROPFIND XML +//! shape, the status codes and the `Depth` handling are what rclone actually +//! depends on, so they are reproduced verbatim; see `dav.rs` for the pure +//! translation half (and the one deliberate divergence: hrefs carry this +//! router's mount prefix, because the daemon served one volume per origin +//! root and this serves every volume under `/_sandbox/orgfs/:org/:volume`). +//! +//! Mounted as a nested catchall rather than a set of `:param` routes: the +//! request pathname after `/` IS the in-volume path, so the +//! router must not have an opinion about how many segments it has, whether +//! it ends in a slash, or which extension method addresses it (`PROPFIND`, +//! `MKCOL` and `MOVE` are not `Method` constants axum can route on). +//! +//! ## Authentication +//! +//! Nothing is minted here. The caller authenticates to local-api exactly +//! like every other `/_sandbox` route (`router.rs`'s `guard`: the per-launch +//! bearer in standalone mode, the session cookie in embedded mode), and the +//! upstream leg attaches the signed-in user's Keychain-backed access token +//! server-side via [`crate::routes::upstream::send_org_request`]. The +//! daemon's `ORGFS_CONFIG` fs-scoped API key — provisioned because a cluster +//! pod had no identity of its own — has no counterpart here and is +//! deliberately gone. +//! +//! Two consequences the P2 mount manager owns, noted here so they are not +//! rediscovered from a failing mount: +//! +//! - The shipped Tauri app boots local-api in EMBEDDED mode +//! (`src-tauri/src/setup.rs` -> `local_api::start_embedded`), where `guard` +//! wants the HttpOnly per-launch session cookie, the exact `Host`, and the +//! exact `Origin` on unsafe methods — NOT a bearer. rclone can satisfy all +//! three (`--header "Cookie: ..." --header "Origin: ..."` against the +//! expected host), but "hand rclone the bearer token" only works in +//! standalone (`ClientAuthMode::Bearer`) mode. +//! - `router.rs`'s `intercept_options` layer answers EVERY `OPTIONS` with +//! `204` before routing, so this module's `OPTIONS` branch (`DAV: 1`) is +//! currently unreachable through the main listener. rclone's `webdav` +//! backend with `vendor = other` does not probe with `OPTIONS`, so this +//! costs nothing today; a client that does would need that layer to +//! exempt this prefix. +//! +//! ## Blocking +//! +//! Every path is async end to end: upstream reads stream through +//! `Body::from_stream` rather than buffering, and the only `to_bytes` call is +//! the bounded PUT body. The TS daemon documented a real deadlock here +//! (kernel -> rclone -> WebDAV -> blocked event loop); a handler that blocks +//! the executor while the kernel waits on the mount reproduces it. + +mod dav; +mod junk; +mod org_fs; + +use axum::body::{Body, Bytes}; +use axum::extract::OriginalUri; +use axum::http::{header, HeaderValue, Method, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::any; +use axum::Router; + +use self::dav::{OrgFsNode, RequestTarget, TargetError}; +use self::org_fs::{OrgFs, OrgFsError, UpstreamOrgFs}; +use crate::state::AppState; + +type Request = axum::extract::Request; + +/// Ceiling on a single PUT body. Matches the org-fs contract's own per-file +/// limit (`MAX_UPLOAD_BYTES` in `apps/api/src/api/routes/org-fs.ts`), so a +/// body this layer accepts is one upstream can also accept. +const MAX_PUT_BYTES: usize = 500 * 1024 * 1024; + +/// The nested sub-router `router.rs` mounts at `/_sandbox/orgfs`. One +/// wildcard route (plus the bare prefix, which `/*path` does not match) and +/// a catchall fallback, all pointing at the same handler — see this module's +/// doc comment for why the router must not decompose the path itself. +pub fn router() -> Router { + Router::new() + .route("/", any(handle)) + .route("/*path", any(handle)) + .fallback(any(handle)) +} + +async fn handle(OriginalUri(original): OriginalUri, req: Request) -> Response { + let target = match dav::parse_target(original.path(), req.uri().path()) { + Ok(target) => target, + Err(TargetError::NotAVolume) => { + return text(StatusCode::NOT_FOUND, "Not Found"); + } + Err(TargetError::Traversal) => { + return text(StatusCode::BAD_REQUEST, "Bad Request"); + } + }; + let fs = UpstreamOrgFs::new(&target.org, &target.volume); + serve(&fs, &target, req).await +} + +/// The protocol core, over any [`OrgFs`] — see this module's tests. +async fn serve(fs: &dyn OrgFs, target: &RequestTarget, req: Request) -> Response { + let (parts, body) = req.into_parts(); + let method = parts.method; + let headers = parts.headers; + let path = target.path.as_str(); + + let is_write = matches!( + method, + Method::PUT | Method::DELETE | Method::POST | Method::PATCH + ) || matches!(method.as_str(), "MKCOL" | "MOVE" | "COPY"); + + // Read-only volumes are rejected HERE rather than trusting rclone's + // `--read-only` mount flag, and BEFORE the mac-junk shortcut below: a + // volume this app must never write to should not have a verb that writes + // succeed, not even vacuously. + if is_write && dav::is_read_only_volume(&target.volume) { + return text(StatusCode::FORBIDDEN, "Read-only volume"); + } + + // mac junk never reaches the backing fs — but it must still behave like a + // real file, because rclone re-reads an object right after uploading it to + // confirm the write. Answering the write `201` and the confirming read + // `404` puts rclone in an unbounded retry loop ("failed to upload try #1 + // … try #4, will retry in 16s", never converging). So writes are recorded + // in the in-memory shadow store and reads are served from it; nothing + // reaches ORG_FS, and directory listings still omit them. See `junk`. + // + // A MOVE whose *source* is junk just forgets it; a real file moved TO a + // junk name falls through (a deliberate rename must not silently lose + // data). + if dav::is_mac_junk(path) { + let (org, volume) = (target.org.as_str(), target.volume.as_str()); + match method.as_str() { + "PUT" => { + let bytes = match axum::body::to_bytes(body, MAX_PUT_BYTES).await { + Ok(bytes) => bytes, + Err(_) => return text(StatusCode::PAYLOAD_TOO_LARGE, "Payload Too Large"), + }; + junk::put(org, volume, path, bytes, false); + return empty(StatusCode::CREATED); + } + "MKCOL" => { + junk::put(org, volume, path, Bytes::new(), true); + return empty(StatusCode::CREATED); + } + "MOVE" => { + junk::remove(org, volume, path); + return empty(StatusCode::CREATED); + } + "DELETE" => { + junk::remove(org, volume, path); + return empty(StatusCode::NO_CONTENT); + } + "GET" | "HEAD" => { + let Some(node) = junk::stat(org, volume, path) else { + return text(StatusCode::NOT_FOUND, "Not Found"); + }; + let body = if method == Method::HEAD { + Body::empty() + } else { + Body::from(junk::get(org, volume, path).unwrap_or_default()) + }; + return base_headers(Response::builder(), &dav::http_date(node.updated_at_secs)) + .header(header::CONTENT_LENGTH, node.size) + .body(body) + .unwrap_or_else(|_| empty(StatusCode::INTERNAL_SERVER_ERROR)); + } + "PROPFIND" => { + let Some(node) = junk::stat(org, volume, path) else { + return text(StatusCode::NOT_FOUND, "Not Found"); + }; + return multistatus(&[dav::prop_response(&target.mount_prefix, &node)]); + } + // OPTIONS/PROPPATCH/default fall through to normal handling. + _ => {} + } + } + + match method.as_str() { + "OPTIONS" => options_response(), + + "PROPFIND" => { + let node = match stat_node(fs, path).await { + Ok(Some(node)) => node, + Ok(None) => return text(StatusCode::NOT_FOUND, "Not Found"), + Err(err) => return error_response(err), + }; + let depth = headers + .get("depth") + .and_then(|v| v.to_str().ok()) + .unwrap_or("1"); + let mut bodies = vec![dav::prop_response(&target.mount_prefix, &node)]; + if depth != "0" && node.is_dir { + let children = match fs.list_dir(path).await { + Ok(children) => children, + Err(err) => return error_response(err), + }; + for child in children { + // Hide junk that leaked into the volume before this filter. + if dav::is_mac_junk(&child.path) { + continue; + } + bodies.push(dav::prop_response(&target.mount_prefix, &child)); + } + } + multistatus(&bodies) + } + + "GET" | "HEAD" => { + let node = match stat_node(fs, path).await { + Ok(Some(node)) => node, + Ok(None) => return text(StatusCode::NOT_FOUND, "Not Found"), + Err(err) => return error_response(err), + }; + if node.is_dir { + return text(StatusCode::METHOD_NOT_ALLOWED, "Is a directory"); + } + let last_modified = dav::http_date(node.updated_at_secs); + if method == Method::HEAD { + return base_headers(Response::builder(), &last_modified) + .header(header::CONTENT_LENGTH, node.size) + .body(Body::empty()) + .unwrap_or_else(|_| empty(StatusCode::INTERNAL_SERVER_ERROR)); + } + + let range = headers + .get(header::RANGE) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + + // Push the read down to the byte store when it can be streamed: + // presigned URL + `Range` forwarded, so rclone's chunked reads of + // a big file never buffer the whole object through the studio (or + // through this process). + match fs.read_stream(path, range.as_deref()).await { + Err(err) => return error_response(err), + Ok(Some(streamed)) => { + if streamed.status == StatusCode::RANGE_NOT_SATISFIABLE { + return text(StatusCode::RANGE_NOT_SATISFIABLE, "Range Not Satisfiable"); + } + let mut builder = + base_headers(Response::builder(), &last_modified).status(streamed.status); + if let Some(value) = streamed.content_length { + builder = builder.header(header::CONTENT_LENGTH, value); + } + if let Some(value) = streamed.content_range { + builder = builder.header(header::CONTENT_RANGE, value); + } + return builder + .body(streamed.body) + .unwrap_or_else(|_| empty(StatusCode::INTERNAL_SERVER_ERROR)); + } + Ok(None) => {} + } + + let bytes = match fs.read(path).await { + Ok(bytes) => bytes, + Err(err) => return error_response(err), + }; + match dav::parse_range(range.as_deref(), bytes.len() as u64) { + Some((start, end)) => { + let slice = bytes[start as usize..=end as usize].to_vec(); + base_headers(Response::builder(), &last_modified) + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_LENGTH, slice.len()) + .header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{}", bytes.len()), + ) + .body(Body::from(slice)) + .unwrap_or_else(|_| empty(StatusCode::INTERNAL_SERVER_ERROR)) + } + None => base_headers(Response::builder(), &last_modified) + .header(header::CONTENT_LENGTH, bytes.len()) + .body(Body::from(bytes)) + .unwrap_or_else(|_| empty(StatusCode::INTERNAL_SERVER_ERROR)), + } + } + + "PUT" => { + let bytes = match axum::body::to_bytes(body, MAX_PUT_BYTES).await { + Ok(bytes) => bytes, + Err(_) => return text(StatusCode::PAYLOAD_TOO_LARGE, "Payload Too Large"), + }; + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()); + match fs.write(path, bytes, content_type).await { + Ok(()) => empty(StatusCode::CREATED), + Err(err) => error_response(err), + } + } + + "DELETE" => match fs.remove(path).await { + Ok(()) => empty(StatusCode::NO_CONTENT), + Err(err) => error_response(err), + }, + + "MKCOL" => match fs.mkdir(path).await { + Ok(()) => empty(StatusCode::CREATED), + Err(err) => error_response(err), + }, + + "MOVE" => { + let Some(destination) = headers.get("destination").and_then(|v| v.to_str().ok()) else { + return text(StatusCode::BAD_REQUEST, "Missing Destination"); + }; + let host = headers.get(header::HOST).and_then(|v| v.to_str().ok()); + let destination = match dav::parse_destination(destination, &target.mount_prefix, host) + { + Ok(destination) => destination, + Err(502) => return text(StatusCode::BAD_GATEWAY, "Bad Gateway"), + Err(_) => return text(StatusCode::BAD_REQUEST, "Bad Request"), + }; + // Identical source and destination MUST be 403 (RFC 4918 §9.9.2). + if destination == path { + return text(StatusCode::FORBIDDEN, "Forbidden"); + } + match fs.rename(path, &destination).await { + Ok(()) => empty(StatusCode::CREATED), + Err(err) => error_response(err), + } + } + + // rclone may PROPPATCH mtimes; accept as a no-op so it doesn't error. + "PROPPATCH" => multistatus(&[dav::proppatch_response(&target.mount_prefix, path)]), + + _ => Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .header("allow", dav::DAV_METHODS) + .body(Body::from("Method Not Allowed")) + .unwrap_or_else(|_| empty(StatusCode::INTERNAL_SERVER_ERROR)), + } +} + +/// The volume root (`""`) is an implicit collection with no manifest entry, +/// so synthesize it; everything else comes from the backing fs. +async fn stat_node(fs: &dyn OrgFs, path: &str) -> Result, OrgFsError> { + if path.is_empty() { + return Ok(Some(OrgFsNode { + path: String::new(), + is_dir: true, + size: 0, + updated_at_secs: dav::now_secs(), + })); + } + fs.stat(path).await +} + +fn options_response() -> Response { + Response::builder() + .status(StatusCode::OK) + .header("dav", "1") + .header("allow", dav::DAV_METHODS) + .header("ms-author-via", "DAV") + .body(Body::empty()) + .unwrap_or_else(|_| empty(StatusCode::INTERNAL_SERVER_ERROR)) +} + +fn base_headers( + builder: axum::http::response::Builder, + last_modified: &str, +) -> axum::http::response::Builder { + builder + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::LAST_MODIFIED, last_modified) + .header(header::ACCEPT_RANGES, "bytes") +} + +fn multistatus(bodies: &[String]) -> Response { + Response::builder() + .status(StatusCode::MULTI_STATUS) + .header( + header::CONTENT_TYPE, + HeaderValue::from_static("application/xml; charset=\"utf-8\""), + ) + .body(Body::from(dav::multistatus(bodies))) + .unwrap_or_else(|_| empty(StatusCode::INTERNAL_SERVER_ERROR)) +} + +fn text(status: StatusCode, message: &'static str) -> Response { + (status, message).into_response() +} + +fn empty(status: StatusCode) -> Response { + (status, Body::empty()).into_response() +} + +/// `OrgFsApiError` -> response, byte-for-byte with the TS `mapError`: the +/// upstream status is preserved so rclone sees a 404 as a 404 rather than a +/// blanket 500 it would retry. +fn error_response(err: OrgFsError) -> Response { + (err.status, err.message).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Bytes; + use std::collections::BTreeMap; + use std::sync::Mutex; + + /// An in-memory volume. Only the WebDAV translation is under test here — + /// the upstream HTTP client has its own unit tests in `org_fs.rs`. + #[derive(Default)] + struct FakeFs { + files: Mutex>>, + dirs: Mutex>, + moves: Mutex>, + fail_with: Option, + } + + impl FakeFs { + fn with_files(entries: &[(&str, &[u8])]) -> FakeFs { + let files = entries + .iter() + .map(|(path, body)| (path.to_string(), body.to_vec())) + .collect(); + FakeFs { + files: Mutex::new(files), + ..Default::default() + } + } + + fn node(&self, path: &str) -> Option { + if self.dirs.lock().unwrap().iter().any(|d| d == path) { + return Some(OrgFsNode { + path: path.to_string(), + is_dir: true, + size: 0, + updated_at_secs: 1_700_000_000, + }); + } + let files = self.files.lock().unwrap(); + if let Some(body) = files.get(path) { + return Some(OrgFsNode { + path: path.to_string(), + is_dir: false, + size: body.len() as u64, + updated_at_secs: 1_700_000_000, + }); + } + let prefix = format!("{path}/"); + files + .keys() + .any(|k| k.starts_with(&prefix)) + .then(|| OrgFsNode { + path: path.to_string(), + is_dir: true, + size: 0, + updated_at_secs: 1_700_000_000, + }) + } + + fn check(&self) -> Result<(), OrgFsError> { + match &self.fail_with { + Some(err) => Err(err.clone()), + None => Ok(()), + } + } + } + + #[async_trait::async_trait] + impl OrgFs for FakeFs { + async fn list_dir(&self, path: &str) -> Result, OrgFsError> { + self.check()?; + let prefix = if path.is_empty() { + String::new() + } else { + format!("{path}/") + }; + let mut names: Vec = Vec::new(); + for key in self.files.lock().unwrap().keys() { + let Some(rest) = key.strip_prefix(&prefix) else { + continue; + }; + if rest.is_empty() { + continue; + } + let name = rest.split('/').next().unwrap_or(rest).to_string(); + let child = format!("{prefix}{name}"); + if !names.contains(&child) { + names.push(child); + } + } + Ok(names.iter().filter_map(|p| self.node(p)).collect()) + } + + async fn stat(&self, path: &str) -> Result, OrgFsError> { + self.check()?; + Ok(self.node(path)) + } + + async fn read(&self, path: &str) -> Result, OrgFsError> { + self.check()?; + self.files + .lock() + .unwrap() + .get(path) + .cloned() + .ok_or_else(|| OrgFsError { + status: StatusCode::NOT_FOUND, + message: "Not found".to_string(), + }) + } + + /// Always falls back to the buffered path — the presign push-down has + /// no in-memory equivalent, and the fallback is what needs the range + /// arithmetic tested. + async fn read_stream( + &self, + _path: &str, + _range: Option<&str>, + ) -> Result, OrgFsError> { + self.check()?; + Ok(None) + } + + async fn write( + &self, + path: &str, + body: Bytes, + _content_type: Option<&str>, + ) -> Result<(), OrgFsError> { + self.check()?; + self.files + .lock() + .unwrap() + .insert(path.to_string(), body.to_vec()); + Ok(()) + } + + async fn mkdir(&self, path: &str) -> Result<(), OrgFsError> { + self.check()?; + self.dirs.lock().unwrap().push(path.to_string()); + Ok(()) + } + + async fn remove(&self, path: &str) -> Result<(), OrgFsError> { + self.check()?; + self.files.lock().unwrap().remove(path); + Ok(()) + } + + async fn rename(&self, from: &str, to: &str) -> Result<(), OrgFsError> { + self.check()?; + self.moves + .lock() + .unwrap() + .push((from.to_string(), to.to_string())); + Ok(()) + } + } + + const PREFIX: &str = "/_sandbox/orgfs/acme/home"; + + fn target(volume: &str, path: &str) -> RequestTarget { + RequestTarget { + org: "acme".to_string(), + volume: volume.to_string(), + path: path.to_string(), + mount_prefix: format!("/_sandbox/orgfs/acme/{volume}"), + } + } + + fn request_to(uri: &str, method: &str, headers: &[(&str, &str)], body: &[u8]) -> Request { + let mut builder = axum::http::Request::builder() + .method(Method::from_bytes(method.as_bytes()).unwrap()) + .uri(uri); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + builder.body(Body::from(body.to_vec())).unwrap() + } + + fn request(method: &str, headers: &[(&str, &str)], body: &[u8]) -> Request { + request_to("/", method, headers, body) + } + + async fn body_string(res: Response) -> String { + let bytes = axum::body::to_bytes(res.into_body(), usize::MAX) + .await + .expect("body"); + String::from_utf8_lossy(&bytes).into_owned() + } + + fn sample_fs() -> FakeFs { + FakeFs::with_files(&[ + ("MEMORY.md", b"hello org"), + ("docs/a.md", b"alpha"), + ("docs/.DS_Store", b"junk"), + ]) + } + + #[tokio::test] + async fn options_advertises_dav_1_and_the_supported_methods() { + let res = serve( + &sample_fs(), + &target("home", ""), + request("OPTIONS", &[], b""), + ) + .await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.headers().get("dav").unwrap(), "1"); + assert_eq!(res.headers().get("ms-author-via").unwrap(), "DAV"); + assert_eq!(res.headers().get("allow").unwrap(), dav::DAV_METHODS); + } + + #[tokio::test] + async fn propfind_depth_1_on_the_root_lists_children_and_hides_mac_junk() { + let res = serve( + &sample_fs(), + &target("home", ""), + request("PROPFIND", &[("depth", "1")], b""), + ) + .await; + assert_eq!(res.status(), StatusCode::MULTI_STATUS); + assert_eq!( + res.headers().get(header::CONTENT_TYPE).unwrap(), + "application/xml; charset=\"utf-8\"" + ); + let xml = body_string(res).await; + // The collection itself, then its immediate children. + assert!(xml.contains(&format!("{PREFIX}/"))); + assert!(xml.contains(&format!("{PREFIX}/MEMORY.md"))); + assert!(xml.contains(&format!("{PREFIX}/docs/"))); + assert!(xml.contains("9")); + assert_eq!(xml.matches("").count(), 3); + } + + #[tokio::test] + async fn propfind_depth_0_returns_only_the_addressed_node() { + let res = serve( + &sample_fs(), + &target("home", ""), + request("PROPFIND", &[("depth", "0")], b""), + ) + .await; + let xml = body_string(res).await; + assert_eq!(xml.matches("").count(), 1); + assert!(xml.contains(&format!("{PREFIX}/"))); + } + + #[tokio::test] + async fn propfind_without_a_depth_header_defaults_to_1() { + let res = serve( + &sample_fs(), + &target("home", "docs"), + request("PROPFIND", &[], b""), + ) + .await; + let xml = body_string(res).await; + // The `docs` collection plus `a.md` — `.DS_Store` is filtered. + assert_eq!(xml.matches("").count(), 2); + assert!(xml.contains(&format!("{PREFIX}/docs/a.md"))); + assert!(!xml.contains("DS_Store")); + } + + #[tokio::test] + async fn propfind_on_a_file_never_lists_children() { + let res = serve( + &sample_fs(), + &target("home", "docs/a.md"), + request("PROPFIND", &[("depth", "1")], b""), + ) + .await; + let xml = body_string(res).await; + assert_eq!(xml.matches("").count(), 1); + assert!(xml.contains("5")); + } + + #[tokio::test] + async fn propfind_on_a_missing_path_is_404() { + let res = serve( + &sample_fs(), + &target("home", "nope.md"), + request("PROPFIND", &[], b""), + ) + .await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn get_returns_the_bytes_with_length_and_last_modified() { + let res = serve( + &sample_fs(), + &target("home", "MEMORY.md"), + request("GET", &[], b""), + ) + .await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.headers().get(header::CONTENT_LENGTH).unwrap(), "9"); + assert_eq!(res.headers().get(header::ACCEPT_RANGES).unwrap(), "bytes"); + assert_eq!( + res.headers().get(header::LAST_MODIFIED).unwrap(), + "Tue, 14 Nov 2023 22:13:20 GMT" + ); + assert_eq!(body_string(res).await, "hello org"); + } + + #[tokio::test] + async fn get_with_a_range_serves_206_and_a_content_range() { + let res = serve( + &sample_fs(), + &target("home", "MEMORY.md"), + request("GET", &[("range", "bytes=6-8")], b""), + ) + .await; + assert_eq!(res.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!( + res.headers().get(header::CONTENT_RANGE).unwrap(), + "bytes 6-8/9" + ); + assert_eq!(body_string(res).await, "org"); + } + + #[tokio::test] + async fn head_sends_the_length_without_a_body() { + let res = serve( + &sample_fs(), + &target("home", "MEMORY.md"), + request("HEAD", &[], b""), + ) + .await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.headers().get(header::CONTENT_LENGTH).unwrap(), "9"); + assert_eq!(body_string(res).await, ""); + } + + #[tokio::test] + async fn get_on_a_directory_is_405() { + let res = serve( + &sample_fs(), + &target("home", "docs"), + request("GET", &[], b""), + ) + .await; + assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED); + } + + #[tokio::test] + async fn put_writes_through_and_returns_201() { + let fs = sample_fs(); + let res = serve( + &fs, + &target("home", "docs/new.md"), + request("PUT", &[("content-type", "text/markdown")], b"written"), + ) + .await; + assert_eq!(res.status(), StatusCode::CREATED); + assert_eq!( + fs.files.lock().unwrap().get("docs/new.md").unwrap(), + b"written" + ); + } + + #[tokio::test] + async fn delete_returns_204_and_removes_the_entry() { + let fs = sample_fs(); + let res = serve( + &fs, + &target("home", "docs/a.md"), + request("DELETE", &[], b""), + ) + .await; + assert_eq!(res.status(), StatusCode::NO_CONTENT); + assert!(!fs.files.lock().unwrap().contains_key("docs/a.md")); + } + + #[tokio::test] + async fn mkcol_creates_the_collection() { + let fs = sample_fs(); + let res = serve(&fs, &target("home", "notes"), request("MKCOL", &[], b"")).await; + assert_eq!(res.status(), StatusCode::CREATED); + assert_eq!(fs.dirs.lock().unwrap().clone(), vec!["notes".to_string()]); + } + + #[tokio::test] + async fn move_resolves_an_absolute_destination_url() { + let fs = sample_fs(); + let res = serve( + &fs, + &target("home", "docs/a.md"), + request( + "MOVE", + &[ + ("host", "127.0.0.1:4000"), + ( + "destination", + "http://127.0.0.1:4000/_sandbox/orgfs/acme/home/docs/b.md", + ), + ], + b"", + ), + ) + .await; + assert_eq!(res.status(), StatusCode::CREATED); + assert_eq!( + fs.moves.lock().unwrap().clone(), + vec![("docs/a.md".to_string(), "docs/b.md".to_string())] + ); + } + + #[tokio::test] + async fn move_without_a_destination_is_400() { + let res = serve( + &sample_fs(), + &target("home", "docs/a.md"), + request("MOVE", &[], b""), + ) + .await; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn move_onto_itself_is_403() { + let res = serve( + &sample_fs(), + &target("home", "docs/a.md"), + request( + "MOVE", + &[("destination", "/_sandbox/orgfs/acme/home/docs/a.md")], + b"", + ), + ) + .await; + assert_eq!(res.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn move_across_volumes_is_502() { + let res = serve( + &sample_fs(), + &target("home", "docs/a.md"), + request( + "MOVE", + &[("destination", "/_sandbox/orgfs/acme/outputs/a.md")], + b"", + ), + ) + .await; + assert_eq!(res.status(), StatusCode::BAD_GATEWAY); + } + + #[tokio::test] + async fn proppatch_is_an_accepted_no_op() { + let res = serve( + &sample_fs(), + &target("home", "docs/a.md"), + request("PROPPATCH", &[], b""), + ) + .await; + assert_eq!(res.status(), StatusCode::MULTI_STATUS); + let xml = body_string(res).await; + assert!(xml.contains(&format!("{PREFIX}/docs/a.md"))); + assert!(xml.contains("HTTP/1.1 200 OK")); + } + + #[tokio::test] + async fn an_unsupported_method_is_405_with_an_allow_header() { + let res = serve(&sample_fs(), &target("home", ""), request("LOCK", &[], b"")).await; + assert_eq!(res.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(res.headers().get("allow").unwrap(), dav::DAV_METHODS); + } + + #[tokio::test] + async fn public_volumes_reject_every_mutating_verb() { + let move_headers: &[(&str, &str)] = + &[("destination", "/_sandbox/orgfs/acme/public-skills/b.md")]; + let cases: [(&str, &[(&str, &str)]); 4] = [ + ("PUT", &[]), + ("DELETE", &[]), + ("MKCOL", &[]), + ("MOVE", move_headers), + ]; + for (method, headers) in cases { + let fs = sample_fs(); + let res = serve( + &fs, + &target("public-skills", "docs/a.md"), + request(method, headers, b"x"), + ) + .await; + assert_eq!( + res.status(), + StatusCode::FORBIDDEN, + "{method} must be denied" + ); + assert!(fs.moves.lock().unwrap().is_empty()); + assert!(fs.dirs.lock().unwrap().is_empty()); + assert_eq!(fs.files.lock().unwrap().len(), 3); + } + } + + #[tokio::test] + async fn public_volumes_still_serve_reads() { + let res = serve( + &sample_fs(), + &target("public-skills", "MEMORY.md"), + request("GET", &[], b""), + ) + .await; + assert_eq!(res.status(), StatusCode::OK); + } + + /// The regression this shadow store exists for: rclone re-reads an object + /// right after uploading it to confirm the write. Answering the PUT `201` + /// and the confirming read `404` made rclone retry forever ("failed to + /// upload try #1 … try #4, will retry in 16s"), pinning the file in its + /// VFS queue and burying real errors. So junk must READ BACK — while still + /// never reaching the backing filesystem. + #[tokio::test] + async fn mac_junk_round_trips_in_memory_without_touching_the_backing_fs() { + let fs = sample_fs(); + let t = target("home", "docs/._a.md"); + + assert_eq!( + serve(&fs, &t, request("PUT", &[], b"xattr")).await.status(), + StatusCode::CREATED + ); + // The confirming read now succeeds — this is the inverted assertion. + for method in ["GET", "HEAD", "PROPFIND"] { + let res = serve(&fs, &t, request(method, &[], b"")).await; + assert!( + res.status().is_success(), + "{method} must confirm the upload, got {}", + res.status() + ); + } + + // DELETE forgets it, and the read goes back to 404. + assert_eq!( + serve(&fs, &t, request("DELETE", &[], b"")).await.status(), + StatusCode::NO_CONTENT + ); + assert_eq!( + serve(&fs, &t, request("GET", &[], b"")).await.status(), + StatusCode::NOT_FOUND + ); + + // Still nothing reached the backing fs. + assert_eq!(fs.files.lock().unwrap().len(), 3); + assert!(fs.dirs.lock().unwrap().is_empty()); + assert!(fs.moves.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn an_upstream_error_status_is_preserved_verbatim() { + let fs = FakeFs { + fail_with: Some(OrgFsError { + status: StatusCode::PAYLOAD_TOO_LARGE, + message: "Volume quota exceeded".to_string(), + }), + ..Default::default() + }; + let res = serve(&fs, &target("home", "docs/a.md"), request("PUT", &[], b"x")).await; + assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(body_string(res).await, "Volume quota exceeded"); + } + + /// Wiring, through the real axum stack: extension methods reach the + /// catchall, a nested mount's `OriginalUri` survives, and an arbitrarily + /// deep (or empty) in-volume path still routes. Both cases below are + /// answered entirely locally — the read-only gate fires before any + /// upstream call — so this test needs no session. + #[tokio::test] + async fn the_nested_catchall_routes_extension_methods_at_any_path_depth() { + use tower::ServiceExt; + + let root = tempfile::tempdir().unwrap(); + let app: Router = Router::new() + .nest("/_sandbox", Router::new().nest("/orgfs", router())) + .with_state(crate::routes::intercept::test_state(root.path())); + + for (uri, method) in [ + ("/_sandbox/orgfs/acme/public-skills/deep/nested/a.md", "PUT"), + ("/_sandbox/orgfs/acme/public-skills/", "MKCOL"), + ("/_sandbox/orgfs/acme/public-skills", "DELETE"), + ] { + let res = app + .clone() + .oneshot(request_to(uri, method, &[], b"x")) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN, "{method} {uri}"); + } + } + + #[tokio::test] + async fn handle_rejects_a_path_that_names_no_volume() { + let res = handle( + OriginalUri("/_sandbox/orgfs/acme".parse().unwrap()), + request_to("/acme", "PROPFIND", &[], b""), + ) + .await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn handle_rejects_a_traversal_segment_before_reaching_upstream() { + let res = handle( + OriginalUri("/_sandbox/orgfs/acme/home/../etc".parse().unwrap()), + request_to("/acme/home/../etc", "GET", &[], b""), + ) + .await; + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/apps/native/crates/local-api/src/routes/webdav/dav.rs b/apps/native/crates/local-api/src/routes/webdav/dav.rs new file mode 100644 index 0000000000..7e9cf459f6 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/webdav/dav.rs @@ -0,0 +1,574 @@ +//! Pure WebDAV/1 protocol translation — request path parsing, `href` +//! construction, the PROPFIND multistatus XML shape, `Range` parsing and +//! HTTP-date formatting. No I/O, no upstream, no axum state, so every rule +//! rclone actually depends on is unit-testable in isolation. +//! +//! Port of the non-I/O half of `packages/sandbox/daemon/org-fs/webdav.ts` +//! (`xmlEscape`, `pathFromUrl`, `basename`, `isMacJunk`, `hrefFor`, +//! `propResponse`, `multistatus`, `parseRange`), with one deliberate +//! addition: the TS daemon ran ONE server per mounted volume at the origin +//! root, so its hrefs started at `/`. Here every volume is served under a +//! shared prefix (`/_sandbox/orgfs//`), and rclone's webdav +//! backend computes an entry's remote name by slicing its endpoint path off +//! the front of `href` (`backend/webdav/webdav.go`'s `listAll`). So hrefs +//! MUST carry that prefix or every listing resolves to the wrong name. + +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Advertised in `Allow` and `OPTIONS` — exactly what rclone's webdav +/// backend (`--webdav-vendor other`, no locking) exercises. +pub const DAV_METHODS: &str = "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, MKCOL, MOVE, PROPPATCH"; + +const WEEKDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +/// One entry in a volume. Mirrors `org-fs/api.ts::OrgFsNode`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OrgFsNode { + /// Normalized in-volume path, no leading/trailing slash. `""` is the root. + pub path: String, + pub is_dir: bool, + pub size: u64, + /// Epoch seconds; rendered as `getlastmodified`. + pub updated_at_secs: i64, +} + +/// A request resolved against the mount: which org/volume it addresses, the +/// in-volume path, and the URL prefix every `href` in the response must carry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestTarget { + pub org: String, + pub volume: String, + /// Decoded, normalized in-volume path (`""` = volume root). + pub path: String, + /// e.g. `/_sandbox/orgfs/acme/home` — no trailing slash. + pub mount_prefix: String, +} + +/// Why a request path could not be resolved to a volume entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TargetError { + /// Fewer than `/` segments — not addressable. + NotAVolume, + /// A `.` or `..` segment. `OrgFs` normalizes these upstream anyway; + /// refusing here keeps the local surface from having an opinion at all. + Traversal, +} + +pub fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + _ => out.push(ch), + } + } + out +} + +fn decode(segment: &str) -> String { + urlencoding::decode(segment) + .map(|s| s.into_owned()) + .unwrap_or_else(|_| segment.to_string()) +} + +/// Resolve `//` from the router-relative path, +/// deriving the href prefix from the original (pre-`nest()`) path so this +/// module never hardcodes where it is mounted. +pub fn parse_target( + original_path: &str, + relative_path: &str, +) -> Result { + let rel: Vec<&str> = relative_path.split('/').filter(|s| !s.is_empty()).collect(); + if rel.len() < 2 { + return Err(TargetError::NotAVolume); + } + let rest = &rel[2..]; + let mut segments = Vec::with_capacity(rest.len()); + for raw in rest { + let decoded = decode(raw); + if decoded == "." || decoded == ".." { + return Err(TargetError::Traversal); + } + segments.push(decoded); + } + + let original: Vec<&str> = original_path.split('/').filter(|s| !s.is_empty()).collect(); + let prefix_len = original.len().saturating_sub(rest.len()); + let mount_prefix = format!("/{}", original[..prefix_len].join("/")); + + Ok(RequestTarget { + org: decode(rel[0]), + volume: decode(rel[1]), + path: segments.join("/"), + mount_prefix, + }) +} + +pub fn basename(path: &str) -> &str { + match path.rfind('/') { + Some(i) => &path[i + 1..], + None => path, + } +} + +/// macOS metadata noise: AppleDouble xattr sidecars (`._*`) and Finder's +/// `.DS_Store`. Written through the mount on every mac file operation — +/// without this they'd sync into the org volume for every other consumer to +/// see. PUTs are accepted-and-dropped rather than rejected: a failing +/// AppleDouble write breaks the whole Finder copy (rclone #7503). +pub fn is_mac_junk(path: &str) -> bool { + let name = basename(path); + name.starts_with("._") || name == ".DS_Store" +} + +/// WebDAV href for a node: mount prefix, per-segment encoded in-volume path, +/// trailing slash for collections. +pub fn href_for(mount_prefix: &str, path: &str, is_dir: bool) -> String { + let mut href = String::from(mount_prefix); + if !path.is_empty() { + for segment in path.split('/') { + href.push('/'); + href.push_str(&urlencoding::encode(segment)); + } + } + if is_dir && !href.ends_with('/') { + href.push('/'); + } + href +} + +pub fn prop_response(mount_prefix: &str, node: &OrgFsNode) -> String { + let type_and_len = if node.is_dir { + "".to_string() + } else { + format!( + "{}", + node.size + ) + }; + format!( + "{href}\ + \ + {name}\ + {lastmod}\ + {type_and_len}\ + HTTP/1.1 200 OK", + href = xml_escape(&href_for(mount_prefix, &node.path, node.is_dir)), + name = xml_escape(basename(&node.path)), + lastmod = http_date(node.updated_at_secs), + ) +} + +/// The empty-prop 200 PROPPATCH acknowledgement — rclone may PROPPATCH +/// mtimes; accepting as a no-op keeps it from erroring the transfer. +pub fn proppatch_response(mount_prefix: &str, path: &str) -> String { + format!( + "{href}\ + HTTP/1.1 200 OK\ + ", + href = xml_escape(&href_for(mount_prefix, path, false)), + ) +} + +pub fn multistatus(bodies: &[String]) -> String { + format!( + "\ + {}", + bodies.concat() + ) +} + +/// Parse a single `bytes=a-b` range against a known length. +/// `None` = absent, malformed, or not satisfiable. +pub fn parse_range(header: Option<&str>, size: u64) -> Option<(u64, u64)> { + let raw = header?.trim(); + let spec = raw.strip_prefix("bytes=")?; + let (a, b) = spec.split_once('-')?; + if b.contains('-') || b.contains(',') { + return None; + } + let (start, end) = if a.is_empty() { + // suffix: last N bytes + let n: u64 = b.parse().ok()?; + if n == 0 { + return None; + } + (size.saturating_sub(n), size.checked_sub(1)?) + } else { + let start: u64 = a.parse().ok()?; + let end = if b.is_empty() { + size.checked_sub(1)? + } else { + b.parse::().ok()?.min(size.checked_sub(1)?) + }; + (start, end) + }; + if start > end || start >= size { + return None; + } + Some((start, end)) +} + +/// `Www, DD Mon YYYY HH:MM:SS GMT` — the only format `http.TimeFormat` +/// (and therefore rclone's `getlastmodified` parse) accepts. +pub fn http_date(epoch_secs: i64) -> String { + let days = epoch_secs.div_euclid(86_400); + let secs = epoch_secs.rem_euclid(86_400); + let (year, month, day) = civil_from_days(days); + // 1970-01-01 was a Thursday (index 4 in WEEKDAYS). + let weekday = (days + 4).rem_euclid(7) as usize; + format!( + "{wd}, {day:02} {mon} {year:04} {h:02}:{m:02}:{s:02} GMT", + wd = WEEKDAYS[weekday], + mon = MONTHS[(month - 1) as usize], + h = secs / 3600, + m = (secs % 3600) / 60, + s = secs % 60, + ) +} + +pub fn now_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// `YYYY-MM-DDTHH:MM:SS[.fff][Z|±HH:MM]` → epoch seconds. Anything else is +/// `None`, and the caller substitutes "now" (matching the TS +/// `new Date(node.updatedAt || Date.now())` fallback). +pub fn iso_to_epoch_secs(iso: &str) -> Option { + let bytes = iso.as_bytes(); + if bytes.len() < 19 { + return None; + } + let num = |from: usize, to: usize| iso.get(from..to)?.parse::().ok(); + let (year, month, day) = (num(0, 4)?, num(5, 7)?, num(8, 10)?); + let (hour, minute, second) = (num(11, 13)?, num(14, 16)?, num(17, 19)?); + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + let mut secs = civil_to_days(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second; + + let tail = &iso[19..]; + let offset_at = tail.rfind(['+', '-']); + if let Some(i) = offset_at { + let offset = &tail[i..]; + let sign = if offset.starts_with('-') { -1 } else { 1 }; + let digits: String = offset[1..].chars().filter(char::is_ascii_digit).collect(); + if digits.len() >= 4 { + let hours: i64 = digits[0..2].parse().ok()?; + let minutes: i64 = digits[2..4].parse().ok()?; + secs -= sign * (hours * 3600 + minutes * 60); + } + } + Some(secs) +} + +/// Howard Hinnant's `civil_from_days` — days since the Unix epoch to +/// `(year, month, day)`, proleptic Gregorian, no leap seconds. +fn civil_from_days(days: i64) -> (i64, i64, i64) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// The inverse of [`civil_from_days`]. +fn civil_to_days(year: i64, month: i64, day: i64) -> i64 { + let y = if month <= 2 { year - 1 } else { year }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = if month > 2 { month - 3 } else { month + 9 }; + let doy = (153 * mp + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/// Resolve a `Destination` header to an in-volume path within the SAME +/// mount. `Err` carries the status to return: 502 for a cross-server or +/// cross-volume destination (RFC 4918 §9.9.2), which this layer cannot +/// perform as a single upstream `move`. +pub fn parse_destination( + destination: &str, + mount_prefix: &str, + host: Option<&str>, +) -> Result { + let raw = destination.trim(); + let path = match raw + .strip_prefix("http://") + .or_else(|| raw.strip_prefix("https://")) + { + Some(rest) => { + let (authority, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, "/"), + }; + if let Some(expected) = host { + if !authority.eq_ignore_ascii_case(expected) { + return Err(502); + } + } + path + } + None => raw, + }; + let path = path.split(['?', '#']).next().unwrap_or(path); + let rest = path.strip_prefix(mount_prefix).ok_or(502u16)?; + if !(rest.is_empty() || rest.starts_with('/')) { + return Err(502); + } + let mut segments = Vec::new(); + for raw in rest.split('/').filter(|s| !s.is_empty()) { + let decoded = decode(raw); + if decoded == "." || decoded == ".." { + return Err(400); + } + segments.push(decoded); + } + Ok(segments.join("/")) +} + +/// Volumes the desktop must never write to. `public-` volumes are +/// generated by the org's public-set sync (`file-storage/public-sets.ts`); +/// a write through a mount would be silently clobbered by the next sync. +/// Enforced HERE rather than trusting rclone's `--read-only` mount flag. +pub fn is_read_only_volume(volume: &str) -> bool { + volume == "public" || volume.starts_with("public-") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn node(path: &str, is_dir: bool, size: u64) -> OrgFsNode { + OrgFsNode { + path: path.to_string(), + is_dir, + size, + updated_at_secs: 1_700_000_000, + } + } + + #[test] + fn xml_escape_covers_the_four_entities() { + assert_eq!( + xml_escape("a&bd\"e"), + "a&b<c>d"e".to_string() + ); + } + + #[test] + fn parse_target_splits_org_volume_and_path() { + let t = parse_target( + "/_sandbox/orgfs/acme/home/docs/a.md", + "/acme/home/docs/a.md", + ) + .unwrap(); + assert_eq!(t.org, "acme"); + assert_eq!(t.volume, "home"); + assert_eq!(t.path, "docs/a.md"); + assert_eq!(t.mount_prefix, "/_sandbox/orgfs/acme/home"); + } + + #[test] + fn parse_target_handles_the_volume_root_with_and_without_a_trailing_slash() { + for (original, relative) in [ + ("/_sandbox/orgfs/acme/home", "/acme/home"), + ("/_sandbox/orgfs/acme/home/", "/acme/home/"), + ] { + let t = parse_target(original, relative).unwrap(); + assert_eq!(t.path, ""); + assert_eq!(t.mount_prefix, "/_sandbox/orgfs/acme/home"); + } + } + + #[test] + fn parse_target_decodes_percent_encoded_segments() { + let t = parse_target( + "/_sandbox/orgfs/acme/home/my%20docs/a%2Bb.md", + "/acme/home/my%20docs/a%2Bb.md", + ) + .unwrap(); + assert_eq!(t.path, "my docs/a+b.md"); + } + + #[test] + fn parse_target_rejects_a_bare_org_and_traversal_segments() { + assert_eq!( + parse_target("/_sandbox/orgfs/acme", "/acme"), + Err(TargetError::NotAVolume) + ); + assert_eq!( + parse_target("/_sandbox/orgfs/acme/home/../x", "/acme/home/../x"), + Err(TargetError::Traversal) + ); + assert_eq!( + parse_target("/_sandbox/orgfs/acme/home/%2e%2e/x", "/acme/home/%2e%2e/x"), + Err(TargetError::Traversal) + ); + } + + #[test] + fn href_carries_the_mount_prefix_and_encodes_each_segment() { + assert_eq!( + href_for("/_sandbox/orgfs/acme/home", "my docs/a.md", false), + "/_sandbox/orgfs/acme/home/my%20docs/a.md" + ); + assert_eq!( + href_for("/_sandbox/orgfs/acme/home", "my docs", true), + "/_sandbox/orgfs/acme/home/my%20docs/" + ); + // The volume root is a collection: prefix plus exactly one slash. + assert_eq!( + href_for("/_sandbox/orgfs/acme/home", "", true), + "/_sandbox/orgfs/acme/home/" + ); + } + + #[test] + fn prop_response_shape_matches_the_ts_daemon() { + let dir = prop_response("/p/v", &node("docs", true, 0)); + assert!(dir.contains("/p/v/docs/")); + assert!(dir.contains("")); + assert!(!dir.contains("getcontentlength")); + assert!(dir.contains("docs")); + + let file = prop_response("/p/v", &node("docs/a.md", false, 42)); + assert!(file.contains("/p/v/docs/a.md")); + assert!(file.contains("42")); + assert!(file.contains("HTTP/1.1 200 OK")); + } + + #[test] + fn multistatus_wraps_bodies_in_the_dav_envelope() { + let xml = multistatus(&["".to_string()]); + assert!(xml.starts_with("")); + assert!(xml.contains("")); + } + + #[test] + fn parse_range_handles_open_closed_and_suffix_forms() { + assert_eq!(parse_range(Some("bytes=0-9"), 100), Some((0, 9))); + assert_eq!(parse_range(Some("bytes=10-"), 100), Some((10, 99))); + assert_eq!(parse_range(Some("bytes=-10"), 100), Some((90, 99))); + // end past EOF clamps + assert_eq!(parse_range(Some("bytes=90-999"), 100), Some((90, 99))); + } + + #[test] + fn parse_range_rejects_unsatisfiable_and_malformed_specs() { + assert_eq!(parse_range(None, 100), None); + assert_eq!(parse_range(Some("items=0-9"), 100), None); + assert_eq!(parse_range(Some("bytes=0-9,20-29"), 100), None); + assert_eq!(parse_range(Some("bytes=100-200"), 100), None); + assert_eq!(parse_range(Some("bytes=9-0"), 100), None); + assert_eq!(parse_range(Some("bytes=-0"), 100), None); + assert_eq!(parse_range(Some("bytes=0-9"), 0), None); + } + + #[test] + fn http_date_renders_rfc_1123_gmt() { + assert_eq!(http_date(0), "Thu, 01 Jan 1970 00:00:00 GMT"); + assert_eq!(http_date(784_111_777), "Sun, 06 Nov 1994 08:49:37 GMT"); + assert_eq!(http_date(1_700_000_000), "Tue, 14 Nov 2023 22:13:20 GMT"); + } + + #[test] + fn iso_round_trips_through_http_date() { + assert_eq!( + iso_to_epoch_secs("2023-11-14T22:13:20.000Z"), + Some(1_700_000_000) + ); + assert_eq!(iso_to_epoch_secs("1970-01-01T00:00:00Z"), Some(0)); + // Offsets are normalized to UTC. + assert_eq!( + iso_to_epoch_secs("2023-11-14T23:13:20+01:00"), + Some(1_700_000_000) + ); + assert_eq!(iso_to_epoch_secs(""), None); + assert_eq!(iso_to_epoch_secs("not a date at all"), None); + } + + #[test] + fn destination_resolves_absolute_urls_and_bare_paths() { + let prefix = "/_sandbox/orgfs/acme/home"; + assert_eq!( + parse_destination( + "http://127.0.0.1:4000/_sandbox/orgfs/acme/home/b%20.md", + prefix, + Some("127.0.0.1:4000") + ), + Ok("b .md".to_string()) + ); + assert_eq!( + parse_destination("/_sandbox/orgfs/acme/home/docs/b.md", prefix, None), + Ok("docs/b.md".to_string()) + ); + // Destination == the volume root. + assert_eq!( + parse_destination("/_sandbox/orgfs/acme/home/", prefix, None), + Ok(String::new()) + ); + } + + #[test] + fn destination_outside_this_mount_is_a_bad_gateway() { + let prefix = "/_sandbox/orgfs/acme/home"; + // Different host. + assert_eq!( + parse_destination( + "http://evil.example/_sandbox/orgfs/acme/home/b.md", + prefix, + Some("127.0.0.1:4000") + ), + Err(502) + ); + // Different volume. + assert_eq!( + parse_destination("/_sandbox/orgfs/acme/outputs/b.md", prefix, None), + Err(502) + ); + // Prefix match must land on a segment boundary. + assert_eq!( + parse_destination("/_sandbox/orgfs/acme/homework/b.md", prefix, None), + Err(502) + ); + assert_eq!( + parse_destination("/_sandbox/orgfs/acme/home/../x", prefix, None), + Err(400) + ); + } + + #[test] + fn mac_junk_is_recognized_by_basename_only() { + assert!(is_mac_junk("._a.md")); + assert!(is_mac_junk("docs/._a.md")); + assert!(is_mac_junk(".DS_Store")); + assert!(is_mac_junk("docs/.DS_Store")); + assert!(!is_mac_junk("docs/a.md")); + assert!(!is_mac_junk("._weird/a.md")); + } + + #[test] + fn public_volumes_are_read_only() { + assert!(is_read_only_volume("public")); + assert!(is_read_only_volume("public-skills")); + assert!(!is_read_only_volume("home")); + assert!(!is_read_only_volume("uploads")); + assert!(!is_read_only_volume("outputs")); + assert!(!is_read_only_volume("publications")); + } +} diff --git a/apps/native/crates/local-api/src/routes/webdav/junk.rs b/apps/native/crates/local-api/src/routes/webdav/junk.rs new file mode 100644 index 0000000000..4a80bea3d8 --- /dev/null +++ b/apps/native/crates/local-api/src/routes/webdav/junk.rs @@ -0,0 +1,208 @@ +//! In-memory shadow store for macOS AppleDouble sidecars. +//! +//! ## Why this exists +//! +//! macOS (Ventura and later) stamps `com.apple.provenance` on every file and +//! directory a non-App-Store process creates. NFS advertises no native extended +//! attributes, so the kernel spills that xattr into a 4096-byte `._` +//! AppleDouble sidecar — for EVERY file and EVERY directory the agent writes. +//! None of it belongs in the organization's filesystem. +//! +//! Simply refusing them does not work, and the obvious refusal is worse than +//! doing nothing. rclone re-reads an object right after uploading it to confirm +//! the write, so answering `PUT` with `201` and the confirming +//! `PROPFIND`/`GET` with `404` puts rclone in an unbounded retry loop: +//! +//! ```text +//! ERROR : adtest3/._h.txt: Failed to copy: object not found +//! ERROR : adtest3/._h.txt: vfs cache: failed to upload try #1, will retry in 2s +//! ERROR : ._adtest3: vfs cache: failed to upload try #2, will retry in 4s +//! …never converges, and the noise masks real failures. +//! ``` +//! +//! So the junk has to look like it was stored without ever being stored. This +//! module keeps the sidecars in memory, where reads can satisfy rclone and +//! nothing reaches `ORG_FS`. Directory listings still omit them, so the user +//! never sees `._` entries in their org filesystem. +//! +//! ## Bounded on purpose +//! +//! An agent can write an unbounded number of files, so this cannot grow +//! without limit. It is capped by entry count and total bytes, evicting oldest +//! first. Losing an evicted sidecar is harmless: the worst case is that the +//! kernel re-derives the xattr and writes it again. + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use bytes::Bytes; + +use super::dav::OrgFsNode; + +/// Sidecars are 4096 bytes each, so this caps the store around 4 MB. +const MAX_ENTRIES: usize = 1024; + +struct Entry { + body: Bytes, + is_dir: bool, + updated_at_secs: i64, + /// Insertion order, for oldest-first eviction. + seq: u64, +} + +#[derive(Default)] +struct Store { + entries: HashMap, + next_seq: u64, +} + +fn store() -> &'static Mutex { + static STORE: OnceLock> = OnceLock::new(); + STORE.get_or_init(|| Mutex::new(Store::default())) +} + +fn lock() -> std::sync::MutexGuard<'static, Store> { + // A poisoned lock still holds usable junk; there is no invariant here + // worth failing a request over. + store() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Namespaced so two volumes cannot alias each other's sidecars. +fn key(org: &str, volume: &str, path: &str) -> String { + format!("{org}\u{0}{volume}\u{0}{path}") +} + +/// Record a sidecar write. Replaces any previous body at the same path. +pub fn put(org: &str, volume: &str, path: &str, body: Bytes, is_dir: bool) { + let mut store = lock(); + let seq = store.next_seq; + store.next_seq += 1; + store.entries.insert( + key(org, volume, path), + Entry { + body, + is_dir, + updated_at_secs: now_secs(), + seq, + }, + ); + // Evict oldest-first until back under the cap. + while store.entries.len() > MAX_ENTRIES { + let Some(oldest) = store + .entries + .iter() + .min_by_key(|(_, entry)| entry.seq) + .map(|(k, _)| k.clone()) + else { + break; + }; + store.entries.remove(&oldest); + } +} + +/// The stored body, for `GET`. +pub fn get(org: &str, volume: &str, path: &str) -> Option { + lock() + .entries + .get(&key(org, volume, path)) + .map(|e| e.body.clone()) +} + +/// A node describing the sidecar, for `PROPFIND`/`HEAD` — this is what makes +/// rclone's post-upload confirming read succeed. +pub fn stat(org: &str, volume: &str, path: &str) -> Option { + let store = lock(); + let entry = store.entries.get(&key(org, volume, path))?; + Some(OrgFsNode { + path: path.to_string(), + is_dir: entry.is_dir, + size: entry.body.len() as u64, + updated_at_secs: entry.updated_at_secs, + }) +} + +/// Forget a sidecar, for `DELETE` and for the source of a `MOVE`. +pub fn remove(org: &str, volume: &str, path: &str) { + lock().entries.remove(&key(org, volume, path)); +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Unique per test: the store is process-global, and the suite is parallel. + fn vol(tag: &str) -> (String, String) { + (format!("org-{tag}"), format!("vol-{tag}")) + } + + #[test] + fn a_written_sidecar_reads_back() { + let (o, v) = vol("readback"); + put(&o, &v, "d/._a.txt", Bytes::from_static(b"xattr"), false); + + assert_eq!(get(&o, &v, "d/._a.txt").as_deref(), Some(&b"xattr"[..])); + let node = stat(&o, &v, "d/._a.txt").expect("stat"); + assert_eq!(node.size, 5); + assert!(!node.is_dir); + assert_eq!(node.path, "d/._a.txt"); + } + + #[test] + fn an_absent_sidecar_is_none_so_the_caller_can_404() { + let (o, v) = vol("absent"); + assert!(get(&o, &v, "._nope").is_none()); + assert!(stat(&o, &v, "._nope").is_none()); + } + + #[test] + fn remove_forgets_it() { + let (o, v) = vol("remove"); + put(&o, &v, "._x", Bytes::from_static(b"1"), false); + remove(&o, &v, "._x"); + assert!(stat(&o, &v, "._x").is_none()); + } + + /// Two volumes must not see each other's sidecars. + #[test] + fn volumes_are_namespaced() { + put("org-ns", "vol-a", "._same", Bytes::from_static(b"a"), false); + put( + "org-ns", + "vol-b", + "._same", + Bytes::from_static(b"bb"), + false, + ); + + assert_eq!(stat("org-ns", "vol-a", "._same").unwrap().size, 1); + assert_eq!(stat("org-ns", "vol-b", "._same").unwrap().size, 2); + } + + /// The reason the store is bounded: an agent can write forever. + #[test] + fn the_store_is_bounded_and_evicts_oldest_first() { + let (o, v) = vol("bound"); + for i in 0..(MAX_ENTRIES + 16) { + put(&o, &v, &format!("._f{i}"), Bytes::from_static(b"x"), false); + } + // The newest survive; the very first are gone. + assert!(stat(&o, &v, &format!("._f{}", MAX_ENTRIES + 15)).is_some()); + assert!(stat(&o, &v, "._f0").is_none()); + + let held = lock() + .entries + .keys() + .filter(|k| k.starts_with(&format!("{o}\u{0}{v}\u{0}"))) + .count(); + assert!(held <= MAX_ENTRIES, "held {held} entries"); + } +} diff --git a/apps/native/crates/local-api/src/routes/webdav/org_fs.rs b/apps/native/crates/local-api/src/routes/webdav/org_fs.rs new file mode 100644 index 0000000000..5dde09482a --- /dev/null +++ b/apps/native/crates/local-api/src/routes/webdav/org_fs.rs @@ -0,0 +1,416 @@ +//! The filesystem surface the WebDAV layer serves, plus its one production +//! implementation: an HTTP client for the studio's org-fs contract +//! (`/api/:org/fs/:volume/*`, `apps/api/src/api/routes/org-fs.ts`). +//! +//! Port of `packages/sandbox/daemon/org-fs/api.ts` + `client.ts`, minus the +//! entire token-provisioning path. The daemon needed a short-lived fs-scoped +//! API key relayed through `ORGFS_CONFIG` because a cluster pod has no +//! identity of its own; local-api already holds the signed-in user's session, +//! so every call goes out through [`crate::routes::upstream::send_org_request`] +//! with the Keychain-backed access token attached server-side. There is no key +//! to mint, store, rotate, or leak. +//! +//! The trait exists so `webdav.rs` can be exercised against an in-memory +//! volume — the protocol translation is what needs testing, not reqwest. + +use axum::body::{Body, Bytes}; +use axum::http::{header, HeaderMap, HeaderValue, Method, StatusCode}; +use serde_json::Value; + +use super::dav::{iso_to_epoch_secs, now_secs, OrgFsNode}; +use crate::routes::upstream::{send_org_request, UpstreamCallError}; + +/// Carries an HTTP status so the WebDAV layer can map it to a response — +/// `org-fs/api.ts::OrgFsApiError`. +#[derive(Debug, Clone)] +pub struct OrgFsError { + pub status: StatusCode, + pub message: String, +} + +impl OrgFsError { + fn new(status: StatusCode, message: impl Into) -> Self { + OrgFsError { + status, + message: message.into(), + } + } +} + +impl From for OrgFsError { + fn from(err: UpstreamCallError) -> Self { + match err { + UpstreamCallError::NotSignedIn => OrgFsError::new( + StatusCode::UNAUTHORIZED, + "not signed in to the upstream deployment", + ), + UpstreamCallError::Unreachable(msg) => OrgFsError::new(StatusCode::BAD_GATEWAY, msg), + } + } +} + +/// A byte range served straight from the object store — see +/// [`OrgFs::read_stream`]. +pub struct StreamedRead { + pub status: StatusCode, + pub content_length: Option, + pub content_range: Option, + pub body: Body, +} + +#[async_trait::async_trait] +pub trait OrgFs: Send + Sync { + /// Immediate children of a directory (`""` = volume root). + async fn list_dir(&self, path: &str) -> Result, OrgFsError>; + /// Metadata for one entry, or `None` if absent. + async fn stat(&self, path: &str) -> Result, OrgFsError>; + /// Full bytes of a file. + async fn read(&self, path: &str) -> Result, OrgFsError>; + /// Stream a read straight from the byte store (presigned URL), pushing an + /// optional `Range` down so only the requested bytes move — where + /// [`OrgFs::read`] buffers every byte through the studio AND this process. + /// `None` means no streamable URL was available and the caller should fall + /// back to [`OrgFs::read`]. + async fn read_stream( + &self, + path: &str, + range: Option<&str>, + ) -> Result, OrgFsError>; + /// Create or overwrite a file. + async fn write( + &self, + path: &str, + body: Bytes, + content_type: Option<&str>, + ) -> Result<(), OrgFsError>; + /// Create a directory and its ancestors. Idempotent. + async fn mkdir(&self, path: &str) -> Result<(), OrgFsError>; + /// Delete a file, or a directory and everything under it. + async fn remove(&self, path: &str) -> Result<(), OrgFsError>; + /// Move or rename. + async fn rename(&self, from: &str, to: &str) -> Result<(), OrgFsError>; +} + +/// The studio-backed implementation. One instance per (org, volume) — built +/// per request, since it holds nothing but two strings. +pub struct UpstreamOrgFs { + base: String, +} + +impl UpstreamOrgFs { + pub fn new(org: &str, volume: &str) -> Self { + UpstreamOrgFs { + base: format!( + "/api/{}/fs/{}", + urlencoding::encode(org), + urlencoding::encode(volume) + ), + } + } + + fn url(&self, op: &str, params: &[(&str, &str)]) -> String { + let query = params + .iter() + .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) + .collect::>() + .join("&"); + if query.is_empty() { + format!("{}/{op}", self.base) + } else { + format!("{}/{op}?{query}", self.base) + } + } + + async fn send( + &self, + method: Method, + url: &str, + headers: HeaderMap, + body: Bytes, + ) -> Result { + Ok(send_org_request(method, url, headers, body).await?) + } + + /// Turn a non-2xx upstream response into an [`OrgFsError`], preferring the + /// contract's `{"error": "..."}` body over a bare status line. + async fn fail(response: reqwest::Response) -> OrgFsError { + let status = StatusCode::from_u16(response.status().as_u16()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let detail = response + .json::() + .await + .ok() + .and_then(|body| { + body.get("error") + .and_then(Value::as_str) + .map(str::to_string) + }) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + OrgFsError::new(status, detail) + } + + /// The plain HTTP client for presigned object-store URLs. Deliberately + /// separate from the upstream proxy client: a presigned URL is external to + /// the studio and must never carry the session bearer. + fn presign_client() -> &'static reqwest::Client { + static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); + CLIENT.get_or_init(reqwest::Client::new) + } +} + +fn json_accept_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(header::ACCEPT, HeaderValue::from_static("application/json")); + headers +} + +/// One entry as returned by `/api/:org/fs/:volume/*`. +fn node_from_json(value: &Value) -> Option { + let path = value.get("path")?.as_str()?.to_string(); + let is_dir = value.get("kind").and_then(Value::as_str) == Some("dir"); + Some(OrgFsNode { + path, + is_dir, + size: value.get("size").and_then(Value::as_u64).unwrap_or(0), + updated_at_secs: value + .get("updatedAt") + .and_then(Value::as_str) + .and_then(iso_to_epoch_secs) + .unwrap_or_else(now_secs), + }) +} + +#[async_trait::async_trait] +impl OrgFs for UpstreamOrgFs { + async fn list_dir(&self, path: &str) -> Result, OrgFsError> { + let url = self.url("list", &[("path", path)]); + let response = self + .send(Method::GET, &url, json_accept_headers(), Bytes::new()) + .await?; + if !response.status().is_success() { + return Err(Self::fail(response).await); + } + let body: Value = response + .json() + .await + .map_err(|e| OrgFsError::new(StatusCode::BAD_GATEWAY, e.to_string()))?; + Ok(body + .get("entries") + .and_then(Value::as_array) + .map(|entries| entries.iter().filter_map(node_from_json).collect()) + .unwrap_or_default()) + } + + async fn stat(&self, path: &str) -> Result, OrgFsError> { + let url = self.url("stat", &[("path", path)]); + let response = self + .send(Method::GET, &url, json_accept_headers(), Bytes::new()) + .await?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if !response.status().is_success() { + return Err(Self::fail(response).await); + } + let body: Value = response + .json() + .await + .map_err(|e| OrgFsError::new(StatusCode::BAD_GATEWAY, e.to_string()))?; + Ok(body.get("entry").and_then(node_from_json)) + } + + async fn read(&self, path: &str) -> Result, OrgFsError> { + let url = self.url("read", &[("path", path)]); + let response = self + .send(Method::GET, &url, HeaderMap::new(), Bytes::new()) + .await?; + if !response.status().is_success() { + return Err(Self::fail(response).await); + } + response + .bytes() + .await + .map(|bytes| bytes.to_vec()) + .map_err(|e| OrgFsError::new(StatusCode::BAD_GATEWAY, e.to_string())) + } + + async fn read_stream( + &self, + path: &str, + range: Option<&str>, + ) -> Result, OrgFsError> { + let url = self.url("read", &[("path", path), ("presign", "1")]); + let presign = self + .send(Method::GET, &url, json_accept_headers(), Bytes::new()) + .await?; + if presign.status() == reqwest::StatusCode::NOT_FOUND { + return Err(Self::fail(presign).await); + } + if !presign.status().is_success() { + return Ok(None); // presign unavailable — buffered fallback + } + let Ok(body) = presign.json::().await else { + return Ok(None); + }; + let Some(signed) = body.get("url").and_then(Value::as_str) else { + return Ok(None); + }; + // Dev storage presigns to inline `data:` URLs (the whole base64 + // payload) — no push-down win there; the buffered path is cheaper. + let lower = signed.to_ascii_lowercase(); + if !(lower.starts_with("http://") || lower.starts_with("https://")) { + return Ok(None); + } + + let mut request = Self::presign_client().get(signed); + if let Some(range) = range { + request = request.header(header::RANGE, range); + } + // A presigned host unreachable from THIS machine (a localhost MinIO + // endpoint the studio can reach and the desktop cannot) falls back to + // the buffered read rather than failing the transfer. + let Ok(upstream) = request.send().await else { + return Ok(None); + }; + let status = StatusCode::from_u16(upstream.status().as_u16()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + if !matches!( + status, + StatusCode::OK | StatusCode::PARTIAL_CONTENT | StatusCode::RANGE_NOT_SATISFIABLE + ) { + return Ok(None); // expired/stale URL — the buffered path is authoritative + } + let content_length = upstream.headers().get(header::CONTENT_LENGTH).cloned(); + let content_range = upstream.headers().get(header::CONTENT_RANGE).cloned(); + Ok(Some(StreamedRead { + status, + content_length, + content_range, + body: Body::from_stream(upstream.bytes_stream()), + })) + } + + async fn write( + &self, + path: &str, + body: Bytes, + content_type: Option<&str>, + ) -> Result<(), OrgFsError> { + let url = self.url("file", &[("path", path)]); + let mut headers = json_accept_headers(); + let content_type = content_type + .and_then(|value| HeaderValue::from_str(value).ok()) + .unwrap_or_else(|| HeaderValue::from_static("application/octet-stream")); + headers.insert(header::CONTENT_TYPE, content_type); + let response = self.send(Method::PUT, &url, headers, body).await?; + if !response.status().is_success() { + return Err(Self::fail(response).await); + } + Ok(()) + } + + async fn mkdir(&self, path: &str) -> Result<(), OrgFsError> { + let url = self.url("dir", &[("path", path)]); + let response = self + .send(Method::POST, &url, json_accept_headers(), Bytes::new()) + .await?; + if !response.status().is_success() { + return Err(Self::fail(response).await); + } + Ok(()) + } + + async fn remove(&self, path: &str) -> Result<(), OrgFsError> { + let url = self.url("file", &[("path", path)]); + let response = self + .send(Method::DELETE, &url, json_accept_headers(), Bytes::new()) + .await?; + if !response.status().is_success() { + return Err(Self::fail(response).await); + } + Ok(()) + } + + async fn rename(&self, from: &str, to: &str) -> Result<(), OrgFsError> { + let url = self.url("move", &[]); + let mut headers = json_accept_headers(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + let body = Bytes::from( + serde_json::to_vec(&serde_json::json!({ "from": from, "to": to })) + .map_err(|e| OrgFsError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?, + ); + let response = self.send(Method::POST, &url, headers, body).await?; + if !response.status().is_success() { + return Err(Self::fail(response).await); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn urls_encode_the_org_volume_and_path() { + let fs = UpstreamOrgFs::new("acme corp", "public-skills"); + assert_eq!( + fs.url("list", &[("path", "my docs/a+b.md")]), + "/api/acme%20corp/fs/public-skills/list?path=my%20docs%2Fa%2Bb.md" + ); + assert_eq!( + fs.url("move", &[]), + "/api/acme%20corp/fs/public-skills/move" + ); + assert_eq!( + fs.url("read", &[("path", ""), ("presign", "1")]), + "/api/acme%20corp/fs/public-skills/read?path=&presign=1" + ); + } + + #[test] + fn node_from_json_maps_the_contract_entry_shape() { + let node = node_from_json(&json!({ + "path": "docs/a.md", + "kind": "file", + "size": 42, + "updatedAt": "2023-11-14T22:13:20.000Z", + })) + .expect("valid entry"); + assert_eq!( + node, + OrgFsNode { + path: "docs/a.md".to_string(), + is_dir: false, + size: 42, + updated_at_secs: 1_700_000_000, + } + ); + + let dir = node_from_json(&json!({ "path": "docs", "kind": "dir", "size": 0 })) + .expect("valid dir entry"); + assert!(dir.is_dir); + // A missing/unparseable `updatedAt` falls back to now, never to 1970 — + // rclone treats a 1970 mtime as "older than everything" and re-syncs. + assert!(dir.updated_at_secs > 1_700_000_000); + } + + #[test] + fn node_from_json_rejects_an_entry_without_a_path() { + assert!(node_from_json(&json!({ "kind": "file" })).is_none()); + } + + #[test] + fn upstream_call_errors_map_to_401_and_502() { + let unauthorized: OrgFsError = UpstreamCallError::NotSignedIn.into(); + assert_eq!(unauthorized.status, StatusCode::UNAUTHORIZED); + let unreachable: OrgFsError = UpstreamCallError::Unreachable("boom".to_string()).into(); + assert_eq!(unreachable.status, StatusCode::BAD_GATEWAY); + assert_eq!(unreachable.message, "boom"); + } +} diff --git a/apps/native/crates/local-api/src/routes/ws_proxy.rs b/apps/native/crates/local-api/src/routes/ws_proxy.rs new file mode 100644 index 0000000000..d3b49fe93f --- /dev/null +++ b/apps/native/crates/local-api/src/routes/ws_proxy.rs @@ -0,0 +1,676 @@ +//! WebSocket upgrades on non-`/_sandbox` paths — HMR, app WS — proxy +//! transparently once a dev server is known. Byte-parity target (in +//! observable behavior, not implementation strategy — see below): +//! `daemon/ws-proxy.ts` + `proxy/websocket.ts`, oracle +//! `daemon.ws-proxy.e2e.test.ts`. +//! +//! `routes/proxy.rs::fallback` detects the upgrade and hands the pending +//! downstream [`WebSocketUpgrade`] to [`upgrade`]. The upstream handshake is +//! completed first so its selected subprotocol and application response +//! headers are part of the downstream `101`; only then are the two upgraded +//! streams bridged. +//! +//! ## Why this does NOT hand-roll the upstream WS frame, unlike the daemon +//! +//! `ws-proxy.ts`'s file header explains its upstream leg hand-rolls the +//! HTTP upgrade over a raw Node `net.Socket` because Bun's own WebSocket +//! client closes its TCP socket with RST instead of FIN, which trips a +//! Node-24-based dev server (Vite) into treating a clean disconnect as an +//! unhandled socket error and exiting. That's a Bun-runtime-specific +//! footgun. `tokio-tungstenite`'s client (backed by a plain `tokio::net:: +//! TcpStream`) performs an ordinary FIN-based close on drop/`.close()`, so +//! the byte-parity target here is the *observable* WebSocket protocol +//! behavior the daemon.ws-proxy.e2e.test.ts suite pins (echo, subprotocol +//! forwarding, close-code propagation both directions, dead-port/no-port +//! 1011s) — not the specific "avoid Bun's RST" implementation detail, which +//! doesn't apply to this runtime. + +use std::time::Duration; + +use axum::{ + extract::ws::{ + CloseFrame as AxumCloseFrame, Message as AxumMessage, WebSocket, WebSocketUpgrade, + }, + http::{header, HeaderMap, HeaderName, HeaderValue}, + response::Response, +}; +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::tungstenite::{ + self, client::IntoClientRequest, protocol::frame::coding::CloseCode, + protocol::CloseFrame as TsCloseFrame, +}; + +/// TCP-connect probe budget per candidate loopback address — matches +/// `proxy/loopback.ts`'s `PROBE_TIMEOUT_MS` in spirit (bounded so a +/// firewalled/filtered address doesn't hang the handshake). +const CONNECT_TIMEOUT: Duration = Duration::from_millis(2000); + +const REASON_NO_UPSTREAM: &str = "no upstream dev server"; +const REASON_UNREACHABLE: &str = "upstream not reachable"; + +type UpstreamSocket = + tokio_tungstenite::WebSocketStream>; + +struct UpstreamHandshake { + socket: UpstreamSocket, + headers: HeaderMap, + selected_protocol: Option, +} + +/// Completes the upstream WebSocket handshake before accepting the browser's +/// pending downstream upgrade. +/// +/// The ordering matters: `Sec-WebSocket-Protocol`, every application +/// `Set-Cookie`, and other end-to-end response headers exist only on the +/// upstream `101`, and browsers need them on their own `101`. Accepting the +/// downstream first makes those headers impossible to propagate. +pub async fn upgrade( + downstream: WebSocketUpgrade, + port: Option, + path_and_query: String, + requested_protocols: Vec, + request_headers: HeaderMap, +) -> Response { + let Some(port) = port else { + return failed_upgrade_response(downstream, requested_protocols, REASON_NO_UPSTREAM); + }; + + let Some(upstream) = connect_upstream( + port, + &path_and_query, + &requested_protocols, + &request_headers, + ) + .await + else { + return failed_upgrade_response(downstream, requested_protocols, REASON_UNREACHABLE); + }; + + let UpstreamHandshake { + socket, + headers, + selected_protocol, + } = upstream; + let downstream = match selected_protocol { + Some(protocol) => downstream.protocols([protocol]), + None => downstream, + }; + let mut response = downstream.on_upgrade(move |client| bridge(client, socket)); + copy_upstream_response_headers(&headers, response.headers_mut()); + response +} + +fn failed_upgrade_response( + downstream: WebSocketUpgrade, + requested_protocols: Vec, + reason: &'static str, +) -> Response { + // Preserve the existing wire behavior for "not running" and dead-port + // cases: complete the browser handshake, then close it with a useful 1011 + // reason. This lets the frontend's WebSocket error path distinguish a + // temporarily unavailable dev server from an HTTP routing failure. + let downstream = if requested_protocols.is_empty() { + downstream + } else { + downstream.protocols(requested_protocols) + }; + downstream.on_upgrade(move |mut client| async move { + close_client(&mut client, 1011, reason).await; + }) +} + +async fn bridge(mut client: WebSocket, upstream: UpstreamSocket) { + let (mut up_write, mut up_read) = upstream.split(); + + loop { + tokio::select! { + client_msg = client.recv() => { + match client_msg { + Some(Ok(AxumMessage::Close(frame))) => { + let _ = up_write.send(tungstenite::Message::Close(frame.map(to_ts_close))).await; + let _ = up_write.close().await; + break; + } + Some(Ok(msg)) => { + if let Some(ts_msg) = to_tungstenite(msg) { + if up_write.send(ts_msg).await.is_err() { + break; + } + } + } + Some(Err(_)) | None => { + // Client vanished without a clean close frame — + // forward a close to upstream too rather than + // leaking the connection. + let _ = up_write.close().await; + break; + } + } + } + up_msg = up_read.next() => { + match up_msg { + Some(Ok(tungstenite::Message::Close(frame))) => { + let (code, reason) = match frame { + Some(f) => (u16::from(f.code), f.reason.to_string()), + None => (1005, String::new()), + }; + close_client(&mut client, code, &reason).await; + break; + } + Some(Ok(ts_msg)) => { + if let Some(axum_msg) = from_tungstenite(ts_msg) { + if client.send(axum_msg).await.is_err() { + break; + } + } + } + Some(Err(_)) | None => { + // Upstream dropped without a close frame (crash, + // reset) — mirrors `ws-proxy.ts`'s + // `socket.on("close", () => closeClient(ws))` + // (no explicit code). + let _ = client.close().await; + break; + } + } + } + } + } +} + +async fn close_client(client: &mut WebSocket, code: u16, reason: &str) { + let _ = client + .send(AxumMessage::Close(Some(AxumCloseFrame { + code, + reason: reason.to_string().into(), + }))) + .await; +} + +/// Tries `[::1]` then `127.0.0.1`, matching `proxy/loopback.ts`'s +/// dual-stack preference. Returns `None` if neither accepts a WS handshake +/// within [`CONNECT_TIMEOUT`]. +async fn connect_upstream( + port: u16, + path_and_query: &str, + protocols: &[String], + request_headers: &HeaderMap, +) -> Option { + for host in ["[::1]", "127.0.0.1"] { + let url = format!("ws://{host}:{port}{path_and_query}"); + let attempt = async { + let mut request = url.into_client_request().ok()?; + if !protocols.is_empty() { + // tungstenite 0.23 does not trim comma-split protocol tokens. + let value = protocols.join(","); + if let Ok(header_value) = HeaderValue::from_str(&value) { + request + .headers_mut() + .insert(header::SEC_WEBSOCKET_PROTOCOL, header_value); + } + } + copy_downstream_request_headers(request_headers, request.headers_mut()); + match tokio_tungstenite::connect_async(request).await { + Ok(result) => Some(result), + Err(error) => { + tracing::debug!(%error, host, port, "upstream WebSocket handshake failed"); + None + } + } + }; + if let Ok(Some((socket, response))) = tokio::time::timeout(CONNECT_TIMEOUT, attempt).await { + let headers = response.headers().clone(); + let selected_protocol = headers + .get(header::SEC_WEBSOCKET_PROTOCOL) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + return Some(UpstreamHandshake { + socket, + headers, + selected_protocol, + }); + } + } + None +} + +fn copy_downstream_request_headers(source: &HeaderMap, destination: &mut HeaderMap) { + let connection_tokens = connection_tokens(source); + for name in source.keys() { + if is_handshake_owned_request_header(name) + || is_hop_by_hop(name) + || connection_tokens.iter().any(|token| token == name.as_str()) + { + continue; + } + destination.remove(name); + for value in source.get_all(name) { + destination.append(name.clone(), value.clone()); + } + } +} + +fn copy_upstream_response_headers(source: &HeaderMap, destination: &mut HeaderMap) { + let connection_tokens = connection_tokens(source); + for name in source.keys() { + if is_handshake_owned_response_header(name) + || is_hop_by_hop(name) + || connection_tokens.iter().any(|token| token == name.as_str()) + { + continue; + } + destination.remove(name); + for value in source.get_all(name) { + destination.append(name.clone(), value.clone()); + } + } +} + +fn connection_tokens(headers: &HeaderMap) -> Vec { + headers + .get_all(header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect() +} + +fn is_handshake_owned_request_header(name: &HeaderName) -> bool { + name == header::HOST + || name == header::SEC_WEBSOCKET_KEY + || name == header::SEC_WEBSOCKET_VERSION + || name == header::SEC_WEBSOCKET_PROTOCOL + || name + .as_str() + .eq_ignore_ascii_case("sec-websocket-extensions") +} + +fn is_handshake_owned_response_header(name: &HeaderName) -> bool { + name == header::SEC_WEBSOCKET_ACCEPT + || name == header::SEC_WEBSOCKET_PROTOCOL + || name + .as_str() + .eq_ignore_ascii_case("sec-websocket-extensions") +} + +fn is_hop_by_hop(name: &HeaderName) -> bool { + matches!( + name.as_str().to_ascii_lowercase().as_str(), + "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) +} + +#[cfg(test)] +fn application_header_values(headers: &HeaderMap, name: &HeaderName) -> Vec { + headers + .get_all(name) + .iter() + .filter_map(|value| value.to_str().ok()) + .map(str::to_owned) + .collect() +} + +#[cfg(test)] +fn header_value<'a>(headers: &'a HeaderMap, name: &'static str) -> Option<&'a str> { + headers.get(name).and_then(|value| value.to_str().ok()) +} + +#[cfg(test)] +fn text_message(message: Option>) -> String { + match message { + Some(Ok(tungstenite::Message::Text(text))) => text, + other => panic!("expected text WebSocket message, got {other:?}"), + } +} + +fn to_ts_close(frame: AxumCloseFrame<'static>) -> TsCloseFrame<'static> { + TsCloseFrame { + code: CloseCode::from(frame.code), + reason: frame.reason, + } +} + +fn to_tungstenite(msg: AxumMessage) -> Option { + match msg { + AxumMessage::Text(t) => Some(tungstenite::Message::Text(t)), + AxumMessage::Binary(b) => Some(tungstenite::Message::Binary(b)), + AxumMessage::Ping(p) => Some(tungstenite::Message::Ping(p)), + AxumMessage::Pong(p) => Some(tungstenite::Message::Pong(p)), + AxumMessage::Close(frame) => Some(tungstenite::Message::Close(frame.map(to_ts_close))), + } +} + +fn from_tungstenite(msg: tungstenite::Message) -> Option { + match msg { + tungstenite::Message::Text(t) => Some(AxumMessage::Text(t)), + tungstenite::Message::Binary(b) => Some(AxumMessage::Binary(b)), + tungstenite::Message::Ping(p) => Some(AxumMessage::Ping(p)), + tungstenite::Message::Pong(p) => Some(AxumMessage::Pong(p)), + tungstenite::Message::Close(frame) => { + Some(AxumMessage::Close(frame.map(|f| AxumCloseFrame { + code: u16::from(f.code), + reason: f.reason, + }))) + } + // Raw `Frame` variant is only ever produced by the low-level codec + // internals, never observed when reading via the high-level + // stream/sink — see tungstenite's own doc comment on the variant. + tungstenite::Message::Frame(_) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + extract::{ws::WebSocketUpgrade, OriginalUri, State}, + routing::get, + Router, + }; + use serde_json::json; + use tokio::net::TcpListener; + + #[test] + fn close_code_roundtrips_through_tungstenite() { + // 4009 is in tungstenite's `Library` private-use range (4000..=4999) + // and must survive the u16 -> CloseCode -> u16 roundtrip unchanged + // — this is exactly the code the "server-initiated close propagates" + // oracle test asserts. + let code = CloseCode::from(4009u16); + assert_eq!(u16::from(code), 4009); + } + + #[test] + fn well_known_close_codes_roundtrip() { + for code in [1000u16, 1001, 1011, 3000, 4999] { + assert_eq!(u16::from(CloseCode::from(code)), code); + } + } + + #[test] + fn axum_to_tungstenite_close_frame_preserves_code_and_reason() { + let frame = AxumCloseFrame { + code: 4009, + reason: "server done".into(), + }; + let ts = to_ts_close(frame); + assert_eq!(u16::from(ts.code), 4009); + assert_eq!(ts.reason, "server done"); + } + + #[test] + fn message_conversion_roundtrips_text_and_binary() { + let text = to_tungstenite(AxumMessage::Text("ping".to_string())).unwrap(); + assert_eq!(text, tungstenite::Message::Text("ping".to_string())); + let back = from_tungstenite(text).unwrap(); + assert_eq!(back, AxumMessage::Text("ping".to_string())); + + let bin = to_tungstenite(AxumMessage::Binary(vec![1, 2, 3])).unwrap(); + assert_eq!(bin, tungstenite::Message::Binary(vec![1, 2, 3])); + } + + #[test] + fn tungstenite_frame_variant_is_dropped_not_panicked() { + // `Frame` is a low-level codec-internal variant we must never see + // in practice (see this file's doc comment on the match arm), but + // the conversion must stay total rather than panic if it ever did. + let raw = tungstenite::Message::Binary(vec![]); + assert!(from_tungstenite(raw).is_some()); + } + + #[tokio::test] + async fn connect_upstream_returns_none_for_a_dead_port() { + let free_port = { + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + let result = connect_upstream(free_port, "/", &[], &HeaderMap::new()).await; + assert!(result.is_none()); + } + + #[test] + fn websocket_upstream_keeps_all_end_to_end_application_headers() { + let mut source = HeaderMap::new(); + source.insert( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer app-token"), + ); + source.insert( + header::COOKIE, + HeaderValue::from_static("sandbox-session=abc; decocms-local-session=control-secret"), + ); + source.insert( + header::ORIGIN, + HeaderValue::from_static("http://localhost:61234"), + ); + source.insert( + HeaderName::from_static("x-sandbox-app"), + HeaderValue::from_static("keep"), + ); + source.insert( + header::SEC_WEBSOCKET_KEY, + HeaderValue::from_static("downstream-owned"), + ); + + let mut destination = HeaderMap::new(); + destination.insert( + header::SEC_WEBSOCKET_KEY, + HeaderValue::from_static("upstream-owned"), + ); + copy_downstream_request_headers(&source, &mut destination); + + assert_eq!( + destination[header::AUTHORIZATION], + HeaderValue::from_static("Bearer app-token") + ); + assert_eq!( + destination[header::COOKIE], + HeaderValue::from_static("sandbox-session=abc; decocms-local-session=control-secret") + ); + assert_eq!( + destination[header::ORIGIN], + HeaderValue::from_static("http://localhost:61234") + ); + assert_eq!(destination["x-sandbox-app"], "keep"); + assert_eq!( + destination[header::SEC_WEBSOCKET_KEY], + HeaderValue::from_static("upstream-owned") + ); + } + + #[test] + fn websocket_hop_headers_are_not_forwarded() { + let mut source = HeaderMap::new(); + source.insert( + header::CONNECTION, + HeaderValue::from_static("Upgrade, x-one-hop"), + ); + source.insert( + HeaderName::from_static("x-one-hop"), + HeaderValue::from_static("remove"), + ); + source.insert( + HeaderName::from_static("x-end-to-end"), + HeaderValue::from_static("keep"), + ); + + let mut destination = HeaderMap::new(); + copy_downstream_request_headers(&source, &mut destination); + assert!(!destination.contains_key(header::CONNECTION)); + assert!(!destination.contains_key("x-one-hop")); + assert_eq!(destination["x-end-to-end"], "keep"); + } + + #[derive(Clone, Copy)] + struct DownstreamState { + upstream_port: u16, + } + + async fn upstream_handler( + ws: WebSocketUpgrade, + headers: HeaderMap, + OriginalUri(uri): OriginalUri, + ) -> Response { + let observed = json!({ + "authorization": header_value(&headers, "authorization"), + "cookie": header_value(&headers, "cookie"), + "origin": header_value(&headers, "origin"), + "custom": header_value(&headers, "x-sandbox-app"), + "pathAndQuery": uri.path_and_query().map(|value| value.as_str()), + }) + .to_string(); + + let mut response = ws + .protocols(["sandbox.v2"]) + .on_upgrade(move |mut socket| async move { + if socket.send(AxumMessage::Text(observed)).await.is_err() { + return; + } + while let Some(Ok(message)) = socket.recv().await { + match message { + AxumMessage::Text(text) => { + if socket + .send(AxumMessage::Text(format!("echo:{text}"))) + .await + .is_err() + { + return; + } + } + AxumMessage::Close(frame) => { + let _ = socket.send(AxumMessage::Close(frame)).await; + return; + } + _ => {} + } + } + }); + response.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_static("session=one; Path=/; HttpOnly; SameSite=Strict"), + ); + response.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_static("theme=dark; Path=/; SameSite=Lax"), + ); + response.headers_mut().insert( + HeaderName::from_static("x-sandbox-handshake"), + HeaderValue::from_static("preserved"), + ); + response + } + + async fn downstream_handler( + State(state): State, + ws: WebSocketUpgrade, + headers: HeaderMap, + OriginalUri(uri): OriginalUri, + ) -> Response { + let requested_protocols = headers + .get(header::SEC_WEBSOCKET_PROTOCOL) + .and_then(|value| value.to_str().ok()) + .map(|value| value.split(',').map(str::trim).map(str::to_owned).collect()) + .unwrap_or_default(); + upgrade( + ws, + Some(state.upstream_port), + uri.path_and_query() + .map_or_else(|| uri.path().to_owned(), |value| value.as_str().to_owned()), + requested_protocols, + headers, + ) + .await + } + + async fn spawn_router(app: Router) -> (u16, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (port, task) + } + + #[tokio::test] + async fn two_leg_handshake_preserves_application_headers_and_upstream_response() { + let (upstream_port, upstream_task) = + spawn_router(Router::new().route("/socket", get(upstream_handler))).await; + let downstream = Router::new() + .route("/socket", get(downstream_handler)) + .with_state(DownstreamState { upstream_port }); + let (downstream_port, downstream_task) = spawn_router(downstream).await; + + let mut request = format!("ws://127.0.0.1:{downstream_port}/socket?room=one") + .into_client_request() + .unwrap(); + request.headers_mut().insert( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer sandbox-token"), + ); + request.headers_mut().insert( + header::COOKIE, + HeaderValue::from_static("sandbox-session=abc; decocms-local-session=control-secret"), + ); + request.headers_mut().insert( + header::ORIGIN, + HeaderValue::from_static("http://localhost:61234"), + ); + request.headers_mut().insert( + HeaderName::from_static("x-sandbox-app"), + HeaderValue::from_static("custom-value"), + ); + request.headers_mut().insert( + header::SEC_WEBSOCKET_PROTOCOL, + HeaderValue::from_static("sandbox.v1,sandbox.v2"), + ); + + let (mut socket, response) = tokio_tungstenite::connect_async(request) + .await + .expect("downstream handshake"); + assert_eq!( + response.headers()[header::SEC_WEBSOCKET_PROTOCOL], + HeaderValue::from_static("sandbox.v2") + ); + assert_eq!( + response.headers()["x-sandbox-handshake"], + HeaderValue::from_static("preserved") + ); + assert_eq!( + application_header_values(response.headers(), &header::SET_COOKIE), + vec![ + "session=one; Path=/; HttpOnly; SameSite=Strict", + "theme=dark; Path=/; SameSite=Lax", + ] + ); + + let observed = text_message(socket.next().await); + let observed: serde_json::Value = serde_json::from_str(&observed).unwrap(); + assert_eq!(observed["authorization"], "Bearer sandbox-token"); + assert_eq!( + observed["cookie"], + "sandbox-session=abc; decocms-local-session=control-secret" + ); + assert_eq!(observed["origin"], "http://localhost:61234"); + assert_eq!(observed["custom"], "custom-value"); + assert_eq!(observed["pathAndQuery"], "/socket?room=one"); + + socket + .send(tungstenite::Message::Text("hello".to_owned())) + .await + .unwrap(); + assert_eq!(text_message(socket.next().await), "echo:hello"); + socket.close(None).await.unwrap(); + + downstream_task.abort(); + upstream_task.abort(); + } +} diff --git a/apps/native/crates/local-api/src/sandbox/manager.rs b/apps/native/crates/local-api/src/sandbox/manager.rs new file mode 100644 index 0000000000..25c173ca04 --- /dev/null +++ b/apps/native/crates/local-api/src/sandbox/manager.rs @@ -0,0 +1,2538 @@ +//! [`SandboxManager`] — `HashMap>`, plus the +//! `ensure()` entry point that (idempotently, with concurrent-caller +//! coalescing) drives a fresh-or-existing [`Sandbox`]'s clone -> install -> +//! start pipeline. See `sandbox`'s module doc for the overall design. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use futures::future::join_all; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; + +use crate::config::ConfigStore; +use crate::events::Broadcaster; +use crate::log_store::LogStore; +use crate::setup::{SetupOrchestrator, SetupShutdownResult, Step}; +use crate::tasks::{KillSignal, TaskRegistry, TaskStatus}; + +const BRANCH_STATUS_POLL_INTERVAL: Duration = Duration::from_secs(3); + +/// True if this sandbox has a `dev`/`start` task currently Running. `ensure()` +/// uses it to decide whether a no-op-clone dispatch still needs to (re)start +/// the dev server — e.g. after an app relaunch dropped the previous process's +/// dev child while the worktree stayed on disk. +fn dev_task_running(sandbox: &Sandbox) -> bool { + sandbox + .tasks + .list(Some(&[TaskStatus::Running])) + .iter() + .any(|t| matches!(t.log_name.as_deref(), Some("dev") | Some("start"))) +} + +/// The clone target + workload hints a caller resolved from a dispatch +/// payload (decopilot's `sandbox` block, or the daemon-parity family's +/// `workspace.repo`/`workspace.branch`) — everything [`SandboxManager::ensure`] +/// needs to (re-)apply the per-handle `TenantConfig`. +/// +/// `virtual_mcp_id` + `branch` are the ONLY fields that feed +/// [`SandboxManager::compute_handle`] — the rest only affect the config +/// patch applied to whichever [`Sandbox`] that handle resolves to. +/// +/// `Serialize`/`Deserialize`: this is also the exact shape persisted to +/// `/sandboxes//sandbox-config.json` (see the `persist` +/// module doc) so a restarted process can RE-`ensure()` a handle it forgot +/// in memory — `compute_handle` is a one-way hash, so this sidecar is the +/// only way back from a bare handle string to "what repo/branch is this". +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GitSandboxConfig { + pub virtual_mcp_id: String, + pub clone_url: String, + /// `None`/empty defaults to `"main"` — see [`SandboxManager::ensure`]. + pub branch: Option, + pub runtime: Option, + pub package_manager: Option, + pub package_manager_path: Option, + pub git_user_name: Option, + pub git_user_email: Option, + /// Organization slug this sandbox belongs to, for the shared org + /// filesystem mounts at `/orgs//` (see + /// `docs/org-fs-plan.md`). The mounts are keyed by org — not by sandbox — + /// so the slug has to travel with the config rather than staying at the + /// request layer where it is known. + /// + /// `Option` because not every caller knows it: `/_sandbox/dispatch` parses + /// its config out of a workspace block that carries no org. Such a call + /// inherits the persisted slug via `merge_durable_config`, so a sandbox + /// only has to learn its org once. + #[serde(default)] + pub org_slug: Option, +} + +/// One handle's isolated slice of the sandbox pipeline: its own workdir +/// (`/sandboxes//repo`) and its own +/// config/tasks/broadcaster/orchestrator quadruple — deliberately the SAME +/// four types `crate::lib::start()` wires up for the single global sandbox, +/// just constructed again per handle instead of once per process. +pub struct Sandbox { + pub handle: String, + pub workdir: PathBuf, + pub config: Arc, + pub tasks: Arc, + pub broadcaster: Arc, + pub setup: Arc, + registry_monitor_started: AtomicBool, + branch_monitor_started: AtomicBool, +} + +/// `HashMap>` plus a per-handle async lock so two +/// concurrent `ensure()` calls for a handle that doesn't exist YET don't +/// both `git clone` into the same not-yet-created directory — see +/// [`SandboxManager::ensure`]. +pub struct SandboxManager { + app_root: PathBuf, + registry: super::registry::SandboxRegistry, + closing: AtomicBool, + sandboxes: Mutex>>, + /// Operation locks, one per handle currently being (or about to be) + /// ensured. The lock covers the WHOLE config -> clone/checkout -> cascade + /// scheduling operation, not just the `Sandbox`'s map insertion: two + /// callers running git against the same workdir concurrently can leave a + /// half-created repository even though they share the same `Sandbox` Arc. + /// Entries are never removed — a handle lives for the process lifetime + /// once first requested, so the map stays bounded by the number of + /// DISTINCT (virtualMcpId, branch) pairs a session ever dispatches, not by + /// call volume. + locks: Mutex>>>, + /// The handle whose dev server the reverse proxy serves for a plain + /// (headerless) preview request — see [`SandboxManager::active`]. A + /// browser iframe navigation cannot attach the `x-decocms-sandbox-handle` + /// header, so the preview iframe (which loads the local-api origin + /// directly) relies on this: `ensure()` marks its handle active, so the + /// most-recently-dispatched thread's sandbox is what the preview shows. + /// The webview can also set it explicitly on thread focus (see + /// `POST /_sandbox/preview-handle`) for switching threads without re-running. + active_handle: Mutex>, + /// Change feed for `active_handle` — headerless `/_sandbox/events` + /// streams follow the ACTIVE sandbox across changes (a stream opened + /// before any sandbox existed would otherwise stay pinned to the + /// process-global broadcaster forever, rendering a blank drawer — + /// observed live 2026-07-22). `set_active` publishes; the events route + /// watches. + active_watch: tokio::sync::watch::Sender>, + /// Per-handle process-generation feeds. An explicit xterm/SSE stream must + /// follow a stopped sandbox when Resume replaces its in-memory `Sandbox` + /// Arc, independently of whichever other chat is currently active. + generation_watches: Mutex>>>>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SandboxShutdownResult { + pub handle: String, + pub result: SetupShutdownResult, +} + +impl SandboxManager { + pub fn new(app_root: PathBuf) -> Arc { + let registry = super::registry::SandboxRegistry::open(app_root.clone()) + .unwrap_or_else(|error| panic!("native sandbox registry failed to open: {error}")); + let persisted_active = registry.active_handle().ok().flatten(); + Arc::new(Self { + app_root, + registry, + closing: AtomicBool::new(false), + sandboxes: Mutex::new(HashMap::new()), + locks: Mutex::new(HashMap::new()), + active_handle: Mutex::new(persisted_active.clone()), + active_watch: tokio::sync::watch::channel(persisted_active).0, + generation_watches: Mutex::new(HashMap::new()), + }) + } + + /// The sandbox the reverse proxy serves for a headerless preview request: + /// the last handle `ensure()`-d, or one explicitly focused by the webview. + /// `None` until the first git-backed dispatch — the proxy then falls back + /// to the global (non-git) orchestrator, exactly as before. + pub fn active(&self) -> Option> { + let handle = self.active_handle.lock().ok()?.clone()?; + self.get(&handle) + } + + /// Point the headerless preview at a registered `handle` (idempotent). + /// Unknown handles are rejected so a frontend-computed identity can never + /// become a persisted pointer to a sandbox Rust has no config for. + /// + /// Also best-effort persists `handle` to disk (see the `persist` module + /// doc) so a HEADERLESS resolve after a process restart can find its way + /// back to the last-focused sandbox via [`Self::resurrect_active`] — + /// `active_handle` above is in-memory only and starts `None` every boot. + /// Subscribe to active-handle changes (see `active_watch`). + pub fn watch_active(&self) -> tokio::sync::watch::Receiver> { + self.active_watch.subscribe() + } + + /// Subscribe to replacements of one durable handle's in-memory process + /// generation. Unlike `watch_active`, this is identity-scoped: focusing a + /// different chat cannot hide a Stop -> Resume replacement from an xterm + /// that is still following this handle. + pub fn watch_generation( + &self, + handle: &str, + ) -> tokio::sync::watch::Receiver>> { + let current = self.get(handle); + let mut watches = self + .generation_watches + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let sender = watches.entry(handle.to_string()).or_insert_with(|| { + let (sender, _receiver) = tokio::sync::watch::channel(current.clone()); + sender + }); + if current.is_some() && sender.borrow().is_none() { + sender.send_replace(current); + } + sender.subscribe() + } + + fn publish_generation(&self, handle: &str, generation: Option>) { + let mut watches = self + .generation_watches + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let sender = watches.entry(handle.to_string()).or_insert_with(|| { + let (sender, _receiver) = tokio::sync::watch::channel(None); + sender + }); + sender.send_replace(generation); + } + + pub fn set_active(&self, handle: &str) -> Result<(), String> { + self.registry.set_active(handle)?; + if let Ok(mut guard) = self.active_handle.lock() { + *guard = Some(handle.to_string()); + } + // Keep the pre-registry files readable during the migration window. + super::persist::write_active_handle(&self.app_root, handle); + let _ = self.active_watch.send(Some(handle.to_string())); + Ok(()) + } + + /// Whether `handle` has durable config, independently of whether this + /// process has materialized its live Rust objects yet. + pub fn is_registered(&self, handle: &str) -> Result { + self.registry.contains(handle) + } + + /// Durable active pointer without materializing or starting its sandbox. + pub fn registered_active_handle(&self) -> Result, String> { + self.registry.active_handle() + } + + /// Returns the durable record for lifecycle/API responses. + pub(crate) fn registry_record( + &self, + handle: &str, + ) -> Result, String> { + self.registry.record(handle) + } + + /// `-` — reuses prod's shape + /// (`packages/sandbox/server/provider/shared/handle.ts`) for legibility; + /// `userId` is constant on desktop (single-user machine) so it's omitted + /// from the hash input entirely rather than hashing a fixed placeholder. + /// `hash16` = the first 8 bytes (16 hex chars) of + /// `sha256(":")`. + pub fn compute_handle(virtual_mcp_id: &str, branch: &str) -> String { + let slug = slugify_branch(branch); + let mut hasher = Sha256::new(); + hasher.update(virtual_mcp_id.as_bytes()); + hasher.update(b":"); + hasher.update(branch.as_bytes()); + let digest = hasher.finalize(); + let hash16: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect(); + format!("{slug}-{hash16}") + } + + /// Bring up this sandbox's organization filesystem, and say in the + /// transcript what happened. + /// + /// The VIEW is built synchronously: it is a handful of `mkdir`s and + /// symlinks, and the dispatch path gates the agent's org-filesystem prompt + /// on that directory existing, so deferring it would let the first turn of + /// a brand-new sandbox run without knowing the org exists at all. + /// + /// The MOUNTS are warmed earlier, when the app first touches the org (see + /// `org_mount::warm`), so this only WAITS for them — briefly, and usually + /// not at all. Backgrounding the mount here instead would leave a window + /// in which the agent reads `org/` before it is attached, sees an empty + /// directory and concludes the organization is empty; waiting makes the + /// outcome deterministic, and the timeout keeps a broken mount from + /// blocking provisioning. + /// + /// The `[org-fs]` line exists because the two failure modes are otherwise + /// indistinguishable to the person reading it: an org with nothing in it + /// and an org filesystem that never came up BOTH show an empty `org/`. + /// Silence there would reproduce exactly the class of bug this feature was + /// built to avoid — an agent reporting "no files" when the truth is "no + /// filesystem". It is emitted from the background task, once the outcome + /// is actually known. + /// + /// Never fails the caller. A sandbox without an org filesystem still runs. + async fn ensure_org_filesystem(&self, sandbox: &Sandbox, handle: &str, org_slug: &str) { + let linked = super::org_view::ensure_org_view(&self.app_root, handle, org_slug).await; + if !linked { + self.report_org_fs( + sandbox, + format!( + "[org-fs] could not build the org/ view for '{org_slug}' — the organization filesystem is unavailable\r\n" + ), + ) + .await; + return; + } + + let mounted = super::org_mount::wait_ready(&self.app_root, org_slug).await; + let line = if mounted { + format!("[org-fs] organization filesystem ready for '{org_slug}'\r\n") + } else { + format!( + "[org-fs] organization filesystem for '{org_slug}' is NOT mounted — org/ will read as empty\r\n" + ) + }; + self.report_org_fs(sandbox, line).await; + } + + /// One `[org-fs]` line into this sandbox's setup transcript. + async fn report_org_fs(&self, sandbox: &Sandbox, line: String) { + sandbox + .tasks + .logs() + .append(&crate::log_store::app_key("setup"), &line) + .await; + sandbox + .broadcaster + .emit("log", json!({ "source": "setup", "data": line })); + } + + /// Where a git-backed run's checkout belongs — + /// `/sandboxes//repo` — computed from the config alone, + /// so it is known even when [`Self::ensure`] never succeeded. + /// + /// Exists so a failed `ensure` cannot route a git-backed run into the + /// process-global `state.repo_dir`. That directory is shared by every + /// thread: falling back to it would let two threads write over each + /// other's files, and would put the agent somewhere the user's worktree + /// isn't — writes would silently land outside the branch they belong to. + /// A git-backed run stays under its own handle or it doesn't run at all. + pub fn workdir_for(&self, cfg: &GitSandboxConfig) -> PathBuf { + let handle = Self::compute_handle(&cfg.virtual_mcp_id, normalized_branch(cfg)); + self.app_root.join("sandboxes").join(handle).join("repo") + } + + /// Looks up an already-created sandbox by handle — used by + /// `routes/proxy.rs` to resolve the sniffed dev port for a specific + /// handle. Returns `None` for a handle never `ensure()`-d (never + /// creates one — that's `ensure()`'s job). + pub fn get(&self, handle: &str) -> Option> { + self.lock_sandboxes().get(handle).cloned() + } + + /// Stable `Arc` snapshot used by shutdown. No manager lock is held while + /// child processes are signaled or awaited. + pub fn snapshot(&self) -> Vec> { + self.lock_sandboxes().values().cloned().collect() + } + + pub fn is_closing(&self) -> bool { + self.closing.load(Ordering::SeqCst) + } + + /// Synchronous first phase of shutdown: permanently rejects creation and + /// closes every setup queue before any asynchronous reap begins. Repeated + /// calls are harmless; `shutdown_all` invokes it too so standalone callers + /// cannot forget the admission fence. + pub fn begin_shutdown(&self) { + self.closing.store(true, Ordering::SeqCst); + for sandbox in self.snapshot() { + sandbox.setup.close(); + } + } + + /// Permanently closes sandbox creation/setup admission, synchronously + /// closes every known orchestrator, then shuts all of them down concurrently + /// under shared TERM/KILL grace periods. + pub async fn shutdown_all( + &self, + term_grace: Duration, + kill_grace: Duration, + ) -> Vec { + self.begin_shutdown(); + let sandboxes = self.snapshot(); + join_all(sandboxes.into_iter().map(|sandbox| async move { + let result = sandbox.setup.shutdown(term_grace, kill_grace).await; + SandboxShutdownResult { + handle: sandbox.handle.clone(), + result, + } + })) + .await + } + + /// Resolves `handle`, resurrecting it from its persisted + /// [`GitSandboxConfig`] sidecar (see the `persist` module doc) when it + /// isn't currently known in memory — the common case after a backend + /// process restart (a dev-loop rebuild, or the desktop app relaunching): + /// the in-memory `sandboxes` map above starts empty every boot, but the + /// workdir + sidecar survive on disk. + /// + /// - `Ok(Some(sandbox))` — already known, or successfully resurrected via + /// a fresh [`Self::ensure`] call against the persisted config (which + /// re-runs the safe `checkout_existing` path against the already-cloned + /// workdir — see `setup::clone::run`'s own doc comment — and, since a + /// restarted process always starts with a fresh, unconfigured + /// `ConfigStore`, unconditionally re-drives the install -> start + /// cascade too). + /// - `Ok(None)` — genuinely unknown: no sidecar was ever written for this + /// handle. A real caller/state bug, not a resurrection candidate. + /// - `Err(_)` — a sidecar exists but resurrection itself failed (e.g. the + /// workdir's git remote is no longer reachable) — distinct from + /// `Ok(None)` so a caller can surface a more specific error than + /// "unknown handle". + pub async fn resurrect(self: &Arc, handle: &str) -> Result>, String> { + if let Some(sandbox) = self.get(handle) { + return Ok(Some(sandbox)); + } + let cfg = match self.registry.record(handle)? { + Some(record) => record.config, + None => match super::persist::read_sidecar(&self.app_root, handle) { + Some(config) => config, + None => return Ok(None), + }, + }; + let sandbox = self.provision(&cfg).await?; + tracing::info!( + handle = %handle, + "sandbox resurrected from its persisted config (in-memory state was lost, likely a backend restart)" + ); + Ok(Some(sandbox)) + } + + /// Headerless counterpart of [`Self::resurrect`]: consults the persisted + /// "last active handle" pointer (survives a restart even though the + /// in-memory `active_handle` field above doesn't — see [`Self::active`]) + /// and resurrects THAT handle. `Ok(None)` when nothing was ever + /// persisted — a genuinely fresh process, or one that only ever used the + /// plain non-git path. + pub async fn resurrect_active(self: &Arc) -> Result>, String> { + if let Some(sandbox) = self.active() { + return Ok(Some(sandbox)); + } + let handle = self + .registry + .active_handle()? + .or_else(|| super::persist::read_active_handle(&self.app_root)); + let Some(handle) = handle else { + return Ok(None); + }; + self.resurrect(&handle).await + } + + /// Materialize only the in-process routing objects for a persisted + /// sandbox. Unlike [`Self::resurrect`], this never clones, installs, or + /// starts anything. Observability uses it after a backend restart so an + /// explicit `events?handle=...` can replay retained files before the user + /// chooses to resume the sandbox. + pub async fn adopt(&self, handle: &str) -> Result>, String> { + if let Some(sandbox) = self.get(handle) { + return Ok(Some(sandbox)); + } + let Some(record) = self.registry.record(handle)? else { + return Ok(None); + }; + let handle_lock = self.handle_lock(handle); + let _permit = handle_lock.lock().await; + if let Some(sandbox) = self.get(handle) { + return Ok(Some(sandbox)); + } + let sandbox = self.get_or_create_locked(handle)?; + let branch = normalized_branch(&record.config).to_string(); + if let Err(error) = self.apply_config(&sandbox, &record.config, &branch) { + self.lock_sandboxes().remove(handle); + return Err(error); + } + Self::monitor_branch_status(&sandbox); + self.publish_generation(handle, Some(sandbox.clone())); + tracing::info!( + handle, + "adopted persisted sandbox metadata without starting it" + ); + Ok(Some(sandbox)) + } + + /// Metadata-only counterpart of [`Self::resurrect_active`]. + pub async fn adopt_active(&self) -> Result>, String> { + if let Some(sandbox) = self.active() { + return Ok(Some(sandbox)); + } + let Some(handle) = self.registry.active_handle()? else { + return Ok(None); + }; + self.adopt(&handle).await + } + + /// Stop fence for a durable sandbox generation. Closing the current + /// orchestrator before signaling its setup/dev children guarantees an + /// in-flight install cannot cascade into Start after Stop returned. The + /// live object is evicted; a later `ensure`/Start creates a fresh + /// orchestrator from the durable config, which is the next generation. + /// + /// `Ok(None)` means the handle is truly unknown. A registered handle with + /// no live object is already stopped and returns `Ok(Some(0))` without + /// materializing it. + pub async fn stop_registered(&self, handle: &str) -> Result, String> { + if !self.registry.contains(handle)? { + return Ok(None); + } + let handle_lock = self.handle_lock(handle); + let _permit = handle_lock.lock().await; + let Some(sandbox) = self.get(handle) else { + let record = self + .registry + .record(handle)? + .ok_or_else(|| format!("unknown sandbox handle: {handle}"))?; + let observed = if record.sandbox_path.is_dir() { + "stopped" + } else { + "absent" + }; + self.registry + .mark_state(handle, "stopped", observed, record.error.as_deref())?; + return Ok(Some(0)); + }; + + // The close flag is the generation fence checked between every + // clone/install/start cascade step. Set it before signaling children. + sandbox.setup.close(); + let task_ids: Vec = sandbox + .tasks + .list(Some(&[TaskStatus::Running])) + .into_iter() + .filter(|task| { + matches!( + task.log_name.as_deref(), + Some("setup") | Some("dev") | Some("start") + ) + }) + .map(|task| task.id) + .collect(); + let killed = match terminate_task_ids(&sandbox.tasks, &task_ids).await { + Ok(killed) => killed, + Err(message) => { + self.registry + .mark_state(handle, "stopped", "failed", Some(&message))?; + return Err(message); + } + }; + sandbox + .setup + .transition_lifecycle(json!({ "phase": "idle" })); + self.lock_sandboxes().remove(handle); + self.publish_generation(handle, None); + self.registry + .mark_state(handle, "stopped", "stopped", None)?; + Ok(Some(killed)) + } + + /// Restart one live registered generation without closing it. The old + /// dev process is fully reaped under the per-handle lock before Start is + /// admitted, preventing old/new servers from racing for the same port. + pub async fn restart_registered(&self, handle: &str) -> Result, String> { + if !self.registry.contains(handle)? { + return Ok(None); + } + let handle_lock = self.handle_lock(handle); + let _permit = handle_lock.lock().await; + let Some(sandbox) = self.get(handle) else { + return Ok(None); + }; + let task_ids: Vec = sandbox + .tasks + .list(Some(&[TaskStatus::Running])) + .into_iter() + .filter(|task| matches!(task.log_name.as_deref(), Some("dev") | Some("start"))) + .map(|task| task.id) + .collect(); + let killed = terminate_task_ids(&sandbox.tasks, &task_ids).await?; + if !sandbox.setup.resume_from(Step::Start) { + return Err("setup worker is unavailable".to_string()); + } + self.registry + .mark_state(handle, "running", "starting", None)?; + self.registry + .mark_observed(handle, "starting", None, Some("start"))?; + Ok(Some(killed)) + } + + /// UI control-plane entry point. It durably registers and activates the + /// sandbox, then returns as soon as the serialized setup worker accepts + /// the appropriate step. Unlike [`Self::ensure`], it does not await git: + /// the caller can connect `events?handle=...` immediately and watch clone + /// output live, and Stop can fence/cancel that generation while clone or + /// install is still running. + pub async fn provision( + self: &Arc, + cfg: &GitSandboxConfig, + ) -> Result, String> { + if self.is_closing() { + return Err("sandbox manager is shutting down".to_string()); + } + let branch = normalized_branch(cfg).to_string(); + let handle = Self::compute_handle(&cfg.virtual_mcp_id, &branch); + let handle_lock = self.handle_lock(&handle); + let _permit = handle_lock.lock().await; + if self.is_closing() { + return Err("sandbox manager is shutting down".to_string()); + } + + let was_known = self.get(&handle).is_some(); + let previous_record = self.registry.record(&handle)?; + let canonical_config = merge_durable_config(cfg, previous_record.as_ref())?; + let sandbox = self.get_or_create_locked(&handle)?; + let was_running = dev_task_running(&sandbox); + let pipeline_in_flight = sandbox.setup.is_running() || sandbox.setup.pending_count() > 0; + let transition = match self.apply_config(&sandbox, &canonical_config, &branch) { + Ok(transition) => transition, + Err(error) => { + if !was_known { + self.lock_sandboxes().remove(&handle); + } + return Err(error); + } + }; + let sandbox_path = self.app_root.join("sandboxes").join(&handle); + self.registry + .upsert_config(&handle, &canonical_config, &sandbox_path, &sandbox.workdir)?; + // The sandbox's view onto the shared org filesystem. Best-effort by + // design (see `org_view`): a sandbox whose view cannot be built still + // runs, the agent simply has no org files — the same outcome as a + // mount that is down. + if let Some(org_slug) = canonical_config.org_slug.as_deref() { + self.ensure_org_filesystem(&sandbox, &handle, org_slug) + .await; + } + self.set_active(&handle)?; + super::persist::write_sidecar(&self.app_root, &handle, &canonical_config); + self.publish_generation(&handle, Some(sandbox.clone())); + Self::monitor_branch_status(&sandbox); + + if transition == "no-op" && (was_running || pipeline_in_flight) { + if sandbox.broadcaster.subscriber_count() > 0 { + crate::routes::git::emit_branch_event(&sandbox.workdir, &sandbox.broadcaster).await; + } + let observed = previous_record + .as_ref() + .map(|record| record.observed_status.as_str()) + .unwrap_or(if was_running { + "starting" + } else { + "provisioning" + }); + let error = previous_record + .as_ref() + .and_then(|record| record.error.as_deref()); + self.registry + .mark_state(&handle, "running", observed, error)?; + return Ok(sandbox); + } + + self.monitor_registry_lifecycle(&sandbox); + + // A durable, already-checked-out worktree needs only its dev process + // after an app/manager restart. Runtime/package-manager changes still + // reinstall; a genuinely new workdir runs the full clone cascade. + // A fresh process has an empty ConfigStore, so `apply_config` reports + // `bootstrap` even when the durable workload changed since the prior + // run. Compare with SQLite as well: a runtime/package-manager/path + // change must resume at Install, never reuse the old installation and + // jump straight to Start. + let durable_workload_changed = previous_record + .as_ref() + .is_some_and(|record| workload_differs(&record.config, &canonical_config)); + let runtime_changed = matches!(transition.as_str(), "runtime-change" | "pm-change") + || durable_workload_changed; + let repo_valid = if pipeline_in_flight && runtime_changed { + // The serialized current generation will finish acquiring the + // repo before it reaches this subsequently queued install. + true + } else { + crate::routes::git::is_git_repo(&sandbox.workdir).await + }; + if !repo_valid { + // A cancelled git clone can leave `.git` and object files behind + // without being a usable repository. `git clone ` + // refuses that non-empty destination, so clear only this managed + // workdir before retrying the full Clone step. + if sandbox.workdir.exists() { + tokio::fs::remove_dir_all(&sandbox.workdir) + .await + .map_err(|error| { + format!( + "failed to clear invalid sandbox workdir {:?}: {error}", + sandbox.workdir + ) + })?; + // This workdir is a worktree of the shared canonical repo (see + // `sandbox::repo_store`), which still lists it after the + // directory is gone — and git refuses to re-add a worktree at a + // path it already knows. Prune so the Clone step below can + // re-create it. + super::repo_store::prune_worktrees(&self.app_root, &canonical_config.clone_url) + .await; + } + tokio::fs::create_dir_all(&sandbox.workdir) + .await + .map_err(|error| { + format!( + "failed to recreate sandbox workdir {:?}: {error}", + sandbox.workdir + ) + })?; + } + let resume_step = previous_record + .as_ref() + .map(|record| record.resume_step.as_str()) + .unwrap_or("clone"); + let step = if !repo_valid { + Step::Clone + } else if runtime_changed { + Step::Install + } else { + match resume_step { + "clone" => Step::Clone, + "install" => Step::Install, + _ => Step::Start, + } + }; + if repo_valid && sandbox.broadcaster.subscriber_count() > 0 { + crate::routes::git::emit_branch_event(&sandbox.workdir, &sandbox.broadcaster).await; + } + if !sandbox.setup.resume_from(step) { + let message = format!("setup worker rejected the {} step", step.as_str()); + let _ = self + .registry + .mark_state(&handle, "running", "failed", Some(&message)); + return Err(format!("sandbox {handle} {message}")); + } + let observed = if step == Step::Start { + "starting" + } else { + "provisioning" + }; + self.registry + .mark_state(&handle, "running", observed, None)?; + self.registry + .mark_observed(&handle, observed, None, Some(step.as_str()))?; + Ok(sandbox) + } + + fn monitor_registry_lifecycle(self: &Arc, sandbox: &Arc) { + if sandbox + .registry_monitor_started + .swap(true, Ordering::SeqCst) + { + return; + } + let mut receiver = sandbox.broadcaster.subscribe(); + let manager = Arc::downgrade(self); + let sandbox_generation = Arc::downgrade(sandbox); + let handle = sandbox.handle.clone(); + tokio::spawn(async move { + loop { + let event = match receiver.recv().await { + Ok(event) => event, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => return, + }; + if event.name != "lifecycle" { + continue; + } + let Some(state) = event.data.get("state") else { + continue; + }; + let Some(phase) = state.get("phase").and_then(Value::as_str) else { + continue; + }; + let (observed, error, resume_step) = match phase { + "running" => ("running", None, Some("start")), + "starting" => ("starting", None, Some("start")), + "cloning" | "checking-out" => ("provisioning", None, Some("clone")), + "installing" => ("provisioning", None, Some("install")), + "clone-failed" => ( + "failed", + state.get("error").and_then(Value::as_str), + Some("clone"), + ), + "install-failed" => ( + "failed", + state.get("error").and_then(Value::as_str), + Some("install"), + ), + "start-failed" => ( + "failed", + state.get("error").and_then(Value::as_str), + Some("start"), + ), + "idle" => ("stopped", None, None), + _ => continue, + }; + let Some(manager) = manager.upgrade() else { + return; + }; + let Some(generation) = sandbox_generation.upgrade() else { + return; + }; + let Some(current) = manager.get(&handle) else { + return; + }; + if !Arc::ptr_eq(&generation, ¤t) { + return; + } + if let Err(error) = + manager + .registry + .mark_observed(&handle, observed, error, resume_step) + { + tracing::warn!(handle, %error, "failed to persist sandbox lifecycle observation"); + } + } + }); + } + + /// Idempotent, concurrency-safe: (re-)applies `cfg` to whichever + /// [`Sandbox`] `(cfg.virtual_mcp_id, cfg.branch)` resolves to — creating + /// its workdir + orchestrator quadruple on first use — then + /// acquires or repairs the configured checkout before returning. An + /// unchanged live sandbox uses a single branch probe; creation, config + /// changes, and drift use the full clone/checkout path. Install + start + /// continue asynchronously on the sandbox's own orchestrator worker — a + /// dev server can take much longer to boot than a caller dispatching a + /// harness run should have to wait. + pub async fn ensure(self: &Arc, cfg: &GitSandboxConfig) -> Result, String> { + let sandbox = self.ensure_inner(cfg, std::future::ready(())).await?; + self.monitor_registry_lifecycle(&sandbox); + Self::monitor_branch_status(&sandbox); + Ok(sandbox) + } + + /// Implementation seam used by the concurrency regression test to pause a + /// caller immediately AFTER it acquires the per-handle operation lock. In + /// production `ensure()` passes a ready future, so this adds no behavior; + /// the seam lets the test prove the second caller cannot enter until the + /// first has completed the whole git/config operation (rather than merely + /// waiting for the `Sandbox` map insertion). + async fn ensure_inner( + &self, + cfg: &GitSandboxConfig, + after_lock: F, + ) -> Result, String> + where + F: std::future::Future, + { + if self.is_closing() { + return Err("sandbox manager is shutting down".to_string()); + } + let branch = normalized_branch(cfg).to_string(); + let handle = Self::compute_handle(&cfg.virtual_mcp_id, &branch); + + // Serialize the ENTIRE ensure operation for one handle. The previous + // implementation released this lock immediately after inserting the + // shared `Sandbox` Arc, which still let both callers race + // `clone::run()` against the same not-yet-created `.git` directory. + // That produced intermittent successful `ensure()` results backed by + // a missing/half-created worktree under the full parallel test suite. + let handle_lock = self.handle_lock(&handle); + let _permit = handle_lock.lock().await; + after_lock.await; + + if self.is_closing() { + return Err("sandbox manager is shutting down".to_string()); + } + + let previous_record = self.registry.record(&handle)?; + let canonical_config = merge_durable_config(cfg, previous_record.as_ref())?; + let sandbox = self.get_or_create_locked(&handle)?; + let transition = self.apply_config(&sandbox, &canonical_config, &branch)?; + let sandbox_path = self.app_root.join("sandboxes").join(&handle); + self.registry + .upsert_config(&handle, &canonical_config, &sandbox_path, &sandbox.workdir)?; + // The sandbox's view onto the shared org filesystem. Best-effort by + // design (see `org_view`): a sandbox whose view cannot be built still + // runs, the agent simply has no org files — the same outcome as a + // mount that is down. + if let Some(org_slug) = canonical_config.org_slug.as_deref() { + self.ensure_org_filesystem(&sandbox, &handle, org_slug) + .await; + } + // This dispatch's sandbox becomes the headerless-preview target only + // after its complete identity is durably registered. + self.set_active(&handle)?; + // Persist the exact config that got this handle here — see the + // `persist` module doc: `compute_handle` is a one-way hash, so this + // sidecar is the only way a LATER process (a restarted one that + // forgot this handle's in-memory `Sandbox`) can `ensure()` it again + // via [`Self::resurrect`]. Written only after `apply_config` above + // succeeded, so a rejected/invalid patch never persists a bogus + // sidecar. + super::persist::write_sidecar(&self.app_root, &handle, &canonical_config); + self.publish_generation(&handle, Some(sandbox.clone())); + + // Repeated dispatches dominate this path. For an unchanged live + // sandbox, one branch probe preserves the checkout guarantee; the + // full clone/checkout path is reserved for creation, config changes, + // or a managed workdir that drifted. + let git_started = Instant::now(); + let (clone_ok, git_path) = match sandbox.setup.current_config() { + Some(config) + if transition == "no-op" + && crate::setup::clone::checkout_is_current(&sandbox.setup, &config).await => + { + (true, "current-checkout") + } + Some(config) => { + let repaired = crate::setup::clone::run(&sandbox.setup, &config).await; + let verified = repaired + && crate::setup::clone::checkout_is_current(&sandbox.setup, &config).await; + (verified, "clone-or-checkout") + } + None => (true, "no-repository"), + }; + tracing::info!( + handle = %handle, + transition = %transition, + clone_ok, + git_path, + git_elapsed_ms = git_started.elapsed().as_millis(), + "sandbox ensure: repository ready" + ); + if !clone_ok { + let message = "git clone/checkout failed; inspect the setup log"; + let _ = self + .registry + .mark_state(&handle, "running", "failed", Some(message)); + let _ = self + .registry + .mark_observed(&handle, "failed", Some(message), Some("clone")); + return Err(format!("sandbox {handle}: {message}")); + } + if git_path == "current-checkout" && sandbox.broadcaster.subscriber_count() > 0 { + crate::routes::git::emit_branch_event(&sandbox.workdir, &sandbox.broadcaster).await; + } + + if transition != "no-op" { + // Config changed (first use of this sandbox, or repo/runtime + // changed) → run the full install → start cascade. + if !sandbox.setup.resume_from(Step::Install) { + let message = "setup worker rejected the install/start cascade"; + let _ = self + .registry + .mark_state(&handle, "running", "failed", Some(message)); + return Err(format!("sandbox {handle} {message}")); + } + self.registry + .mark_state(&handle, "running", "provisioning", None)?; + self.registry + .mark_observed(&handle, "provisioning", None, Some("install"))?; + } else if !dev_task_running(&sandbox) { + // Config unchanged AND no dev server is running for this + // sandbox — e.g. a FRESH app process (the previous launch's + // dev child died with it while the worktree persisted on + // disk), or the dev server crashed. Bring it back up WITHOUT + // re-cloning/re-installing (Start only), so the preview isn't + // stuck on "starting…" forever. Without this, a no-op clone + // skipped the cascade entirely and the dev server never came + // back after a relaunch. + tracing::info!( + handle = %handle, + "sandbox ensure: no-op config but no dev server running → resume_from(Start)" + ); + if !sandbox.setup.resume_from(Step::Start) { + let message = "setup worker rejected the dev-server restart"; + let _ = self + .registry + .mark_state(&handle, "running", "failed", Some(message)); + return Err(format!("sandbox {handle} {message}")); + } + self.registry + .mark_state(&handle, "running", "starting", None)?; + self.registry + .mark_observed(&handle, "starting", None, Some("start"))?; + } else { + self.registry + .mark_state(&handle, "running", "running", None)?; + self.registry + .mark_observed(&handle, "running", None, Some("start"))?; + } + + Ok(sandbox) + } + + /// Returns the existing sandbox or constructs it. The caller MUST hold the + /// corresponding [`Self::handle_lock`] permit; keeping this helper + /// synchronous makes it impossible to accidentally suspend while a + /// partially-built value is being inserted. + fn get_or_create_locked(&self, handle: &str) -> Result, String> { + // One lock acquisition linearizes close-vs-create: if shutdown sets + // `closing` while an ensure already holds this guard, its insertion is + // visible to shutdown's later snapshot; if shutdown wins first, no new + // sandbox can appear behind that snapshot. + let mut sandboxes = self.lock_sandboxes(); + if self.is_closing() { + return Err("sandbox manager is shutting down".to_string()); + } + if let Some(sandbox) = sandboxes.get(handle) { + return Ok(sandbox.clone()); + } + + let sandbox_root = self.app_root.join("sandboxes").join(handle); + let workdir = sandbox_root.join("repo"); + std::fs::create_dir_all(&workdir) + .map_err(|e| format!("failed to create sandbox workdir {workdir:?}: {e}"))?; + + let config = Arc::new(ConfigStore::new()); + // `/sandboxes//logs` — a sibling of `repo` above, + // this handle's OWN durable log home (see the `log_store` module doc). + // Isolated per handle so two branches of the same repo never share + // (or race) a "setup"/"dev" transcript. + let logs = Arc::new(LogStore::new(sandbox_root.join("logs"))); + let tasks = Arc::new(TaskRegistry::new_with_child_lifetime_lock( + logs, + self.app_root.join(".decocms").join("child-lifetime.lock"), + )); + let broadcaster = Arc::new(Broadcaster::new()); + let setup = SetupOrchestrator::new( + workdir.clone(), + self.app_root.clone(), + config.clone(), + tasks.clone(), + broadcaster.clone(), + ); + let sandbox = Arc::new(Sandbox { + handle: handle.to_string(), + workdir, + config, + tasks, + broadcaster, + setup, + registry_monitor_started: AtomicBool::new(false), + branch_monitor_started: AtomicBool::new(false), + }); + sandboxes.insert(handle.to_string(), sandbox.clone()); + Ok(sandbox) + } + + /// Keep branch metadata live while this sandbox has an SSE observer. + /// + /// The old Bun daemon combined recursive filesystem watches with a + /// three-second poll fallback. Native uses only the bounded fallback: + /// one weak-reference task per materialized sandbox, no retained output + /// buffer, and no Git work while nobody subscribes. The first observed + /// snapshot is emitted deliberately even though the events handshake also + /// recomputes one; doing so closes the race where a worktree edit lands + /// between handshake computation and the poller's first baseline. + fn monitor_branch_status(sandbox: &Arc) { + if sandbox.branch_monitor_started.swap(true, Ordering::SeqCst) { + return; + } + + let sandbox = Arc::downgrade(sandbox); + tokio::spawn(async move { + let mut poll = tokio::time::interval(BRANCH_STATUS_POLL_INTERVAL); + poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut last = None; + + loop { + poll.tick().await; + let Some(sandbox) = sandbox.upgrade() else { + return; + }; + if sandbox.broadcaster.subscriber_count() == 0 { + last = None; + continue; + } + + let next = crate::routes::git::branch_snapshot(&sandbox.workdir).await; + if next == last { + continue; + } + if let Some(meta) = next.as_ref() { + sandbox.broadcaster.emit("branch", json!({ "meta": meta })); + } + last = next; + } + }); + } + + fn handle_lock(&self, handle: &str) -> Arc> { + let mut guard = self.lock_locks(); + guard + .entry(handle.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + } + + /// Builds and applies the `TenantConfig` patch for `cfg`/`branch`, + /// returning the resulting transition kind. Only fields `cfg` actually + /// carries are included in the patch — `ConfigStore::patch`'s deep-merge + /// leaves any field a LATER, less-complete `ensure()` call omits + /// untouched (see `config/merge.rs`'s "field absent -> leave existing" + /// semantics), so a repeat dispatch that doesn't repeat the workload + /// hint never erases a runtime/packageManager set by an earlier one. + fn apply_config( + &self, + sandbox: &Sandbox, + cfg: &GitSandboxConfig, + branch: &str, + ) -> Result { + let mut repository = Map::new(); + repository.insert("cloneUrl".to_string(), json!(cfg.clone_url)); + repository.insert("branch".to_string(), json!(branch)); + let mut git = Map::new(); + git.insert("repository".to_string(), Value::Object(repository)); + // `validate_git` rejects an `identity` object missing EITHER field — + // only attach it when both are present so an incomplete hint never + // 400s the whole patch. + if let (Some(name), Some(email)) = (&cfg.git_user_name, &cfg.git_user_email) { + if !name.is_empty() && !email.is_empty() { + git.insert( + "identity".to_string(), + json!({ "userName": name, "userEmail": email }), + ); + } + } + + let mut application = Map::new(); + if let Some(runtime) = &cfg.runtime { + if !runtime.is_empty() { + application.insert("runtime".to_string(), json!(runtime)); + } + } + let mut package_manager = Map::new(); + if let Some(pm) = &cfg.package_manager { + if !pm.is_empty() { + package_manager.insert("name".to_string(), json!(pm)); + } + } + if let Some(path) = &cfg.package_manager_path { + if !path.is_empty() { + package_manager.insert("path".to_string(), json!(path)); + } + } + if !package_manager.is_empty() { + application.insert("packageManager".to_string(), Value::Object(package_manager)); + } + + let mut patch = Map::new(); + patch.insert("git".to_string(), Value::Object(git)); + if !application.is_empty() { + patch.insert("application".to_string(), Value::Object(application)); + } + + let outcome = sandbox + .config + .patch(Value::Object(patch)) + .map_err(|e| format!("sandbox config rejected: {}", e.body))?; + Ok(outcome.transition.to_string()) + } + + fn lock_sandboxes(&self) -> std::sync::MutexGuard<'_, HashMap>> { + match self.sandboxes.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + } + } + + fn lock_locks( + &self, + ) -> std::sync::MutexGuard<'_, HashMap>>> { + match self.locks.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + } + } +} + +async fn wait_tasks_terminal(tasks: &TaskRegistry, ids: &[String]) { + loop { + if ids.iter().all(|id| { + tasks + .get(id) + .is_none_or(|task| task.status != TaskStatus::Running) + }) { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +const TASK_TERM_GRACE: Duration = Duration::from_secs(2); +const TASK_KILL_REAP_WARNING: Duration = Duration::from_secs(2); +/// Final bounded wait after KILL. This is deliberately much longer than the +/// diagnostic threshold: a real Next/Bun process tree was observed publishing +/// its terminal proof just after the old two-second deadline, causing a false +/// 409 followed by an immediately-successful retry. The per-handle lock stays +/// held for this entire budget, so the new server is never admitted early. +const TASK_KILL_REAP_DEADLINE: Duration = Duration::from_secs(15); + +async fn terminate_task_ids(tasks: &TaskRegistry, ids: &[String]) -> Result { + terminate_task_ids_with_timings( + tasks, + ids, + TASK_TERM_GRACE, + TASK_KILL_REAP_WARNING, + TASK_KILL_REAP_DEADLINE, + ) + .await +} + +async fn terminate_task_ids_with_timings( + tasks: &TaskRegistry, + ids: &[String], + term_grace: Duration, + kill_reap_warning: Duration, + kill_reap_deadline: Duration, +) -> Result { + let mut killed = 0usize; + for task_id in ids { + if tasks.kill(task_id, KillSignal::Term) == Some(true) { + killed += 1; + } + } + if tokio::time::timeout(term_grace, wait_tasks_terminal(tasks, ids)) + .await + .is_err() + { + let mut unsignalable = Vec::new(); + for task_id in ids { + if tasks + .get(task_id) + .is_some_and(|task| task.status == TaskStatus::Running) + { + let signaled = tasks.kill(task_id, KillSignal::Kill) == Some(true); + if !signaled + && tasks + .get(task_id) + .is_some_and(|task| task.status == TaskStatus::Running) + { + unsignalable.push(task_id.clone()); + } + } + } + if !unsignalable.is_empty() { + return Err(format!( + "could not signal process task(s): {}", + unsignalable.join(", ") + )); + } + + let terminal = wait_tasks_terminal(tasks, ids); + tokio::pin!(terminal); + let kill_started = tokio::time::Instant::now(); + let warning_after = kill_reap_warning.min(kill_reap_deadline); + if tokio::time::timeout(warning_after, terminal.as_mut()) + .await + .is_err() + { + if warning_after < kill_reap_deadline { + tracing::warn!( + task_ids = ?ids, + "process task reap exceeded its warning threshold; retaining the restart fence" + ); + } + // KILL was durably accepted by each live task's ProcessController, + // but only its detached process owner can prove the entire group + // gone and finalize the registry entry. Keep the per-handle + // restart lock until that proof arrives; admitting Start early + // would race old and new servers for the same port. + let remaining_budget = kill_reap_deadline.saturating_sub(kill_started.elapsed()); + if tokio::time::timeout(remaining_budget, terminal.as_mut()) + .await + .is_err() + { + let remaining: Vec = ids + .iter() + .filter(|task_id| { + tasks + .get(task_id) + .is_some_and(|task| task.status == TaskStatus::Running) + }) + .cloned() + .collect(); + if remaining.is_empty() { + return Ok(killed); + } + return Err(format!( + "could not reap process task(s): {}", + remaining.join(", ") + )); + } + } + } + Ok(killed) +} + +pub(crate) async fn terminate_tasks_by_log_name( + tasks: &TaskRegistry, + log_names: &[&str], +) -> Result { + let ids: Vec = tasks + .list(Some(&[TaskStatus::Running])) + .into_iter() + .filter(|task| { + task.log_name + .as_deref() + .is_some_and(|name| log_names.contains(&name)) + }) + .map(|task| task.id) + .collect(); + terminate_task_ids(tasks, &ids).await +} + +/// Make the durable registry the canonical source for fields the frontend may +/// omit on a later `ensure` call. A handle intentionally identifies only a +/// virtual MCP + branch, so accepting a different repository URL for an +/// existing handle would silently retarget the worktree. Optional workload and +/// git-identity hints, on the other hand, are merged from the prior record so a +/// sparse control-plane request cannot erase them. +fn merge_durable_config( + incoming: &GitSandboxConfig, + previous: Option<&super::registry::SandboxRecord>, +) -> Result { + let Some(previous) = previous else { + return Ok(incoming.clone()); + }; + let persisted = &previous.config; + if persisted.virtual_mcp_id != incoming.virtual_mcp_id + || normalized_branch(persisted) != normalized_branch(incoming) + { + return Err(format!( + "sandbox {} identity does not match its durable registry record", + previous.handle + )); + } + if persisted.clone_url.trim() != incoming.clone_url.trim() { + return Err(format!( + "sandbox {} repository is immutable (registered {:?}, requested {:?})", + previous.handle, persisted.clone_url, incoming.clone_url + )); + } + + let mut merged = incoming.clone(); + merged.clone_url = persisted.clone_url.clone(); + merged.branch = prefer_present(&incoming.branch, &persisted.branch); + merged.org_slug = prefer_present(&incoming.org_slug, &persisted.org_slug); + merged.runtime = prefer_present(&incoming.runtime, &persisted.runtime); + merged.package_manager = prefer_present(&incoming.package_manager, &persisted.package_manager); + merged.package_manager_path = prefer_present( + &incoming.package_manager_path, + &persisted.package_manager_path, + ); + merged.git_user_name = prefer_present(&incoming.git_user_name, &persisted.git_user_name); + merged.git_user_email = prefer_present(&incoming.git_user_email, &persisted.git_user_email); + Ok(merged) +} + +fn prefer_present(incoming: &Option, persisted: &Option) -> Option { + incoming + .as_ref() + .filter(|value| !value.trim().is_empty()) + .or_else(|| persisted.as_ref().filter(|value| !value.trim().is_empty())) + .cloned() +} + +fn workload_differs(left: &GitSandboxConfig, right: &GitSandboxConfig) -> bool { + left.runtime != right.runtime + || left.package_manager != right.package_manager + || left.package_manager_path != right.package_manager_path +} + +fn normalized_branch(config: &GitSandboxConfig) -> &str { + config + .branch + .as_deref() + .filter(|branch| !branch.is_empty()) + .unwrap_or("main") +} + +/// Lowercases, collapses any run of non-alphanumeric characters to a single +/// `-`, trims leading/trailing dashes, and caps length — a legible directory +/// name fragment, not a byte-parity port of any specific TS slugify (no +/// oracle test pins the exact slug alphabet, only that DIFFERENT branches +/// slugify to DIFFERENT-enough strings for a human skimming +/// `/sandboxes/` to tell them apart; uniqueness itself comes from +/// the hash suffix, not the slug). +fn slugify_branch(branch: &str) -> String { + const MAX_LEN: usize = 40; + let mut out = String::with_capacity(branch.len()); + let mut last_dash = false; + for ch in branch.chars() { + let lower = ch.to_ascii_lowercase(); + if lower.is_ascii_alphanumeric() { + out.push(lower); + last_dash = false; + } else if !last_dash { + out.push('-'); + last_dash = true; + } + } + let trimmed = out.trim_matches('-'); + let clipped: String = trimmed.chars().take(MAX_LEN).collect(); + let clipped = clipped.trim_end_matches('-'); + if clipped.is_empty() { + "branch".to_string() + } else { + clipped.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + use std::process::Command; + + fn git(dir: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git failed to spawn"); + assert!( + output.status.success(), + "git {args:?} failed in {dir:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn git_stdout(dir: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git failed to spawn"); + assert!( + output.status.success(), + "git {args:?} failed in {dir:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + + /// A bare "origin" plus two branches (`main`, `feature`) each with a + /// distinguishing `BRANCH.txt` marker file, so a test can assert which + /// branch actually landed in a given clone. + fn setup_two_branch_repo() -> (tempfile::TempDir, String) { + let root = tempfile::tempdir().unwrap(); + let bare_dir = root.path().join("origin.git"); + let work_dir = root.path().join("author"); + std::fs::create_dir_all(&bare_dir).unwrap(); + std::fs::create_dir_all(&work_dir).unwrap(); + git(&bare_dir, &["init", "--bare", "-q"]); + git(&work_dir, &["init", "-q", "-b", "main"]); + git(&work_dir, &["config", "user.name", "Test User"]); + git(&work_dir, &["config", "user.email", "test@example.com"]); + std::fs::write(work_dir.join("BRANCH.txt"), "main\n").unwrap(); + git(&work_dir, &["add", "."]); + git(&work_dir, &["commit", "-q", "-m", "initial"]); + let bare_str = bare_dir.to_str().unwrap().to_string(); + git(&work_dir, &["remote", "add", "origin", &bare_str]); + git(&work_dir, &["push", "-q", "-u", "origin", "main"]); + git(&bare_dir, &["symbolic-ref", "HEAD", "refs/heads/main"]); + + git(&work_dir, &["checkout", "-q", "-b", "feature"]); + std::fs::write(work_dir.join("BRANCH.txt"), "feature\n").unwrap(); + git(&work_dir, &["commit", "-q", "-am", "feature commit"]); + git(&work_dir, &["push", "-q", "-u", "origin", "feature"]); + + (root, bare_str) + } + + fn task_registry(root: &Path) -> Arc { + Arc::new(TaskRegistry::new(Arc::new(LogStore::new( + root.join("logs"), + )))) + } + + fn running_task( + id: &str, + controller: Option<&crate::tasks::ProcessController>, + ) -> crate::tasks::TaskEntry { + crate::tasks::TaskEntry::new( + crate::tasks::TaskSummary { + id: id.to_string(), + command: "bun run dev".to_string(), + status: TaskStatus::Running, + exit_code: None, + started_at: 0, + finished_at: None, + timed_out: false, + truncated: false, + log_name: Some("dev".to_string()), + intentional: None, + }, + controller.map(crate::tasks::ProcessController::kill_handle), + ) + } + + #[tokio::test] + async fn restart_waits_for_terminal_proof_after_kill_warning() { + let root = tempfile::tempdir().unwrap(); + let tasks = task_registry(root.path()); + let controller = crate::tasks::ProcessController::new(); + tasks.insert(running_task("slow-reap", Some(&controller))); + + let owner = { + let tasks = tasks.clone(); + let controller = controller.clone(); + tokio::spawn(async move { + assert_eq!(controller.wait_for_change(None).await, KillSignal::Term); + assert_eq!( + controller.wait_for_change(Some(KillSignal::Term)).await, + KillSignal::Kill + ); + // Deliberately outlive the kill warning. The warning is not a + // correctness deadline: the task owner still holds the old + // process-group fence and will publish the terminal proof. + tokio::time::sleep(Duration::from_millis(50)).await; + tasks.finalize("slow-reap", TaskStatus::Killed, 137, false); + }) + }; + + let ids = vec!["slow-reap".to_string()]; + let killed = tokio::time::timeout( + Duration::from_secs(1), + terminate_task_ids_with_timings( + &tasks, + &ids, + Duration::from_millis(5), + Duration::from_millis(5), + Duration::from_millis(200), + ), + ) + .await + .expect("restart waits for the owner rather than hanging") + .expect("a late-but-successful reap is not a conflict"); + owner.await.unwrap(); + + assert_eq!(killed, 1); + assert_eq!(tasks.get("slow-reap").unwrap().status, TaskStatus::Killed); + } + + #[tokio::test] + async fn restart_returns_a_bounded_error_when_kill_never_reaps() { + let root = tempfile::tempdir().unwrap(); + let tasks = task_registry(root.path()); + let controller = crate::tasks::ProcessController::new(); + tasks.insert(running_task("never-reaps", Some(&controller))); + + let owner = { + let controller = controller.clone(); + tokio::spawn(async move { + assert_eq!(controller.wait_for_change(None).await, KillSignal::Term); + assert_eq!( + controller.wait_for_change(Some(KillSignal::Term)).await, + KillSignal::Kill + ); + // Model an owner that accepted KILL but cannot prove group + // quiescence. It deliberately leaves the task Running. + }) + }; + + let ids = vec!["never-reaps".to_string()]; + let error = tokio::time::timeout( + Duration::from_secs(1), + terminate_task_ids_with_timings( + &tasks, + &ids, + Duration::from_millis(5), + Duration::from_millis(5), + Duration::from_millis(30), + ), + ) + .await + .expect("the final reap deadline bounds the restart request") + .expect_err("an unproven old process group remains a conflict"); + owner.await.unwrap(); + + assert_eq!(error, "could not reap process task(s): never-reaps"); + assert_eq!( + tasks.get("never-reaps").unwrap().status, + TaskStatus::Running + ); + } + + #[tokio::test] + async fn restart_rejects_a_running_task_without_a_signal_owner() { + let root = tempfile::tempdir().unwrap(); + let tasks = task_registry(root.path()); + tasks.insert(running_task("unowned", None)); + + let ids = vec!["unowned".to_string()]; + let error = terminate_task_ids_with_timings( + &tasks, + &ids, + Duration::from_millis(5), + Duration::from_millis(5), + Duration::from_millis(30), + ) + .await + .expect_err("a running task with no kill owner cannot make progress"); + + assert_eq!(error, "could not signal process task(s): unowned"); + assert_eq!(tasks.get("unowned").unwrap().status, TaskStatus::Running); + } + + #[test] + fn compute_handle_is_deterministic_and_branch_sensitive() { + let a1 = SandboxManager::compute_handle("vmcp-1", "main"); + let a2 = SandboxManager::compute_handle("vmcp-1", "main"); + assert_eq!(a1, a2, "same inputs must hash identically"); + + let b = SandboxManager::compute_handle("vmcp-1", "feature"); + assert_ne!(a1, b, "different branches must produce different handles"); + + let c = SandboxManager::compute_handle("vmcp-2", "main"); + assert_ne!( + a1, c, + "different virtualMcpId must produce different handles" + ); + + assert!(a1.starts_with("main-"), "handle should be slug-prefixed"); + assert!(b.starts_with("feature-")); + } + + /// A git-backed run must resolve under its OWN handle even when `ensure` + /// never ran, so a failed clone can never route it into the shared + /// process-global repo dir where threads would overwrite each other. + #[test] + fn workdir_for_is_per_handle_and_never_the_shared_repo_dir() { + let root = tempfile::tempdir().expect("tempdir"); + let manager = SandboxManager::new(root.path().to_path_buf()); + let config = |branch: Option<&str>| GitSandboxConfig { + virtual_mcp_id: "vmcp-1".to_string(), + clone_url: "https://github.com/acme/site.git".to_string(), + branch: branch.map(str::to_string), + ..Default::default() + }; + + let main = manager.workdir_for(&config(Some("main"))); + assert!( + main.starts_with(root.path().join("sandboxes")) && main.ends_with("repo"), + "expected /sandboxes//repo, got {main:?}" + ); + // The shared dir every non-git-backed run uses — see `lib.rs`. + assert_ne!(main, root.path().join("repo")); + + // Same handle inputs agree with `ensure`'s own derivation, including + // the `None`/empty -> "main" default, so the fallback lands exactly + // where a later successful ensure will put the checkout. + assert_eq!(main, manager.workdir_for(&config(None))); + assert_eq!(main, manager.workdir_for(&config(Some("")))); + assert_eq!( + main.parent().and_then(|p| p.file_name()), + Some(SandboxManager::compute_handle("vmcp-1", "main").as_ref()) + ); + // Two branches of one agent must not share a directory. + assert_ne!(main, manager.workdir_for(&config(Some("feature")))); + } + + #[test] + fn slugify_branch_collapses_unsafe_characters() { + assert_eq!(slugify_branch("feature/foo_bar"), "feature-foo-bar"); + assert_eq!(slugify_branch("MAIN"), "main"); + assert_eq!(slugify_branch("---"), "branch"); + assert_eq!(slugify_branch(""), "branch"); + } + + #[tokio::test] + async fn generation_watch_is_scoped_to_one_handle_and_survives_replacement() { + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + let mut watched = manager.watch_generation("sandbox-a"); + + let first = { + let lock = manager.handle_lock("sandbox-a"); + let _permit = lock.lock().await; + manager.get_or_create_locked("sandbox-a").unwrap() + }; + manager.publish_generation("sandbox-a", Some(first.clone())); + watched.changed().await.unwrap(); + assert!(Arc::ptr_eq( + watched.borrow_and_update().as_ref().unwrap(), + &first + )); + + // Unrelated focus/generation traffic cannot consume or retarget A's + // notification channel. + let other = { + let lock = manager.handle_lock("sandbox-b"); + let _permit = lock.lock().await; + manager.get_or_create_locked("sandbox-b").unwrap() + }; + manager.publish_generation("sandbox-b", Some(other)); + assert!( + tokio::time::timeout(Duration::from_millis(25), watched.changed()) + .await + .is_err() + ); + + manager.lock_sandboxes().remove("sandbox-a"); + manager.publish_generation("sandbox-a", None); + watched.changed().await.unwrap(); + assert!(watched.borrow_and_update().is_none()); + + let replacement = { + let lock = manager.handle_lock("sandbox-a"); + let _permit = lock.lock().await; + manager.get_or_create_locked("sandbox-a").unwrap() + }; + manager.publish_generation("sandbox-a", Some(replacement.clone())); + watched.changed().await.unwrap(); + assert!(Arc::ptr_eq( + watched.borrow_and_update().as_ref().unwrap(), + &replacement + )); + assert!(!Arc::ptr_eq(&first, &replacement)); + } + + #[tokio::test] + async fn ensure_clones_two_branches_into_independent_directories() { + let (_root, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + + let sandbox_main = manager + .ensure(&GitSandboxConfig { + virtual_mcp_id: "vmcp-1".to_string(), + clone_url: clone_url.clone(), + branch: Some("main".to_string()), + ..Default::default() + }) + .await + .expect("ensure(main) succeeds"); + let sandbox_feature = manager + .ensure(&GitSandboxConfig { + virtual_mcp_id: "vmcp-1".to_string(), + clone_url: clone_url.clone(), + branch: Some("feature".to_string()), + ..Default::default() + }) + .await + .expect("ensure(feature) succeeds"); + + assert_ne!( + sandbox_main.workdir, sandbox_feature.workdir, + "different branches must land in different workdirs" + ); + assert_eq!( + std::fs::read_to_string(sandbox_main.workdir.join("BRANCH.txt")) + .unwrap() + .trim(), + "main" + ); + assert_eq!( + std::fs::read_to_string(sandbox_feature.workdir.join("BRANCH.txt")) + .unwrap() + .trim(), + "feature" + ); + + // Writing into the feature workdir must never touch main's. + std::fs::write(sandbox_feature.workdir.join("only-in-feature.txt"), "x").unwrap(); + assert!(!sandbox_main.workdir.join("only-in-feature.txt").exists()); + } + + #[tokio::test] + async fn ensure_is_idempotent_for_the_same_handle() { + let (_root, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + + let cfg = GitSandboxConfig { + virtual_mcp_id: "vmcp-1".to_string(), + clone_url, + branch: Some("main".to_string()), + ..Default::default() + }; + let first = manager.ensure(&cfg).await.expect("first ensure succeeds"); + let git_tasks_after_first = first + .tasks + .list(None) + .into_iter() + .filter(|task| task.command.starts_with("git ")) + .count(); + assert!( + git_tasks_after_first > 0, + "the first ensure must acquire the repository" + ); + + let mut events = first.broadcaster.subscribe(); + let second = manager.ensure(&cfg).await.expect("second ensure succeeds"); + assert!( + Arc::ptr_eq(&first, &second), + "repeat ensure() for the same handle must return the SAME Sandbox" + ); + let branch = tokio::time::timeout(Duration::from_secs(1), async { + loop { + let event = events + .recv() + .await + .expect("sandbox broadcaster remains open"); + if event.name == "branch" { + break event; + } + } + }) + .await + .expect("a no-op/current-checkout ensure refreshes branch observers"); + assert_eq!(branch.data["meta"]["branch"], "main"); + let git_tasks_after_second = second + .tasks + .list(None) + .into_iter() + .filter(|task| task.command.starts_with("git ")) + .count(); + assert_eq!( + git_tasks_after_second, git_tasks_after_first, + "an unchanged checkout must not rerun the registered clone/checkout path" + ); + } + + #[tokio::test] + async fn branch_monitor_emits_working_tree_changes_for_an_observed_sandbox() { + let (_root, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + let sandbox = manager + .ensure(&GitSandboxConfig { + virtual_mcp_id: "vmcp-live-branch".to_string(), + clone_url, + branch: Some("main".to_string()), + ..Default::default() + }) + .await + .expect("ensure succeeds"); + let mut events = sandbox.broadcaster.subscribe(); + + let initial = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let event = events.recv().await.expect("branch monitor stays live"); + if event.name == "branch" { + break event; + } + } + }) + .await + .expect("observed sandbox receives the poller's initial snapshot"); + assert_eq!(initial.data["meta"]["workingTreeDirty"], false); + + std::fs::write(sandbox.workdir.join("untracked.txt"), "changed\n").unwrap(); + let changed = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let event = events.recv().await.expect("branch monitor stays live"); + if event.name == "branch" + && event.data["meta"]["workingTreeDirty"].as_bool() == Some(true) + { + break event; + } + } + }) + .await + .expect("working-tree edit reaches the live branch stream"); + assert_eq!(changed.data["meta"]["branch"], "main"); + } + + #[tokio::test] + async fn ensure_repairs_a_drifted_branch_before_returning() { + let (_root, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + let cfg = GitSandboxConfig { + virtual_mcp_id: "vmcp-drift".to_string(), + clone_url, + branch: Some("main".to_string()), + ..Default::default() + }; + + let sandbox = manager.ensure(&cfg).await.expect("first ensure succeeds"); + git(&sandbox.workdir, &["checkout", "-q", "-b", "drifted"]); + assert_eq!( + git_stdout(&sandbox.workdir, &["rev-parse", "--abbrev-ref", "HEAD"]), + "drifted" + ); + + manager + .ensure(&cfg) + .await + .expect("repeat ensure restores configured branch"); + assert_eq!( + git_stdout(&sandbox.workdir, &["rev-parse", "--abbrev-ref", "HEAD"]), + "main", + "the no-op fast path must fall back to checkout when the workdir drifted" + ); + } + + #[tokio::test] + async fn ensure_rejects_a_failed_drift_repair_instead_of_returning_wrong_branch() { + let (_root, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + let cfg = GitSandboxConfig { + virtual_mcp_id: "vmcp-dirty-drift".to_string(), + clone_url, + branch: Some("main".to_string()), + ..Default::default() + }; + + let sandbox = manager.ensure(&cfg).await.expect("first ensure succeeds"); + git(&sandbox.workdir, &["config", "user.name", "Test User"]); + git( + &sandbox.workdir, + &["config", "user.email", "test@example.com"], + ); + git(&sandbox.workdir, &["checkout", "-q", "-b", "drifted"]); + std::fs::write(sandbox.workdir.join("BRANCH.txt"), "drifted committed\n").unwrap(); + git(&sandbox.workdir, &["add", "BRANCH.txt"]); + git( + &sandbox.workdir, + &["commit", "-q", "-m", "drift away from main"], + ); + std::fs::write(sandbox.workdir.join("BRANCH.txt"), "dirty conflict\n").unwrap(); + + let error = match manager.ensure(&cfg).await { + Ok(_) => panic!("a checkout conflict must fail the sandbox ensure"), + Err(error) => error, + }; + assert!( + error.contains("git clone/checkout failed"), + "the caller must receive a checkout failure, got: {error}" + ); + assert_eq!( + git_stdout(&sandbox.workdir, &["rev-parse", "--abbrev-ref", "HEAD"]), + "drifted", + "the failed checkout remains on the wrong branch and must never be returned as ready" + ); + } + + #[tokio::test] + async fn ensure_coalesces_concurrent_callers_for_a_brand_new_handle() { + let (_root, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + + let cfg = GitSandboxConfig { + virtual_mcp_id: "vmcp-1".to_string(), + clone_url, + branch: Some("main".to_string()), + ..Default::default() + }; + + // Pause each caller at a deterministic point immediately after it + // acquires the per-handle operation lock. While the first is paused, + // the second must NOT reach that point: this proves the lock covers + // more than `Sandbox` map insertion. After releasing the first, the + // second must only enter once the first has cloned and fully returned. + let (entered_tx, mut entered_rx) = tokio::sync::mpsc::unbounded_channel(); + let permits = Arc::new(tokio::sync::Semaphore::new(0)); + + let first_gate = { + let entered_tx = entered_tx.clone(); + let permits = permits.clone(); + async move { + entered_tx.send("first").unwrap(); + permits.acquire().await.unwrap().forget(); + } + }; + let second_gate = { + let entered_tx = entered_tx.clone(); + let permits = permits.clone(); + async move { + entered_tx.send("second").unwrap(); + permits.acquire().await.unwrap().forget(); + } + }; + drop(entered_tx); + + let coordinator = async { + let first = tokio::time::timeout(std::time::Duration::from_secs(5), entered_rx.recv()) + .await + .expect("one ensure caller acquires the operation lock") + .expect("entry channel remains open"); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), entered_rx.recv()) + .await + .is_err(), + "the second ensure caller entered while the first still held the operation lock" + ); + + permits.add_permits(1); + let second = + tokio::time::timeout(std::time::Duration::from_secs(15), entered_rx.recv()) + .await + .expect("second ensure enters after the first fully returns") + .expect("entry channel remains open for the second caller"); + assert_ne!(first, second); + permits.add_permits(1); + }; + + let (a, b, ()) = tokio::join!( + manager.ensure_inner(&cfg, first_gate), + manager.ensure_inner(&cfg, second_gate), + coordinator, + ); + let a = a.expect("first concurrent ensure succeeds"); + let b = b.expect("second concurrent ensure succeeds"); + assert!( + Arc::ptr_eq(&a, &b), + "concurrent ensure() calls for the same not-yet-created handle must coalesce onto one Sandbox" + ); + assert_eq!( + std::fs::read_to_string(a.workdir.join("BRANCH.txt")) + .unwrap() + .trim(), + "main" + ); + } + + #[tokio::test] + async fn concurrent_provision_calls_coalesce_one_setup_generation() { + let (_root, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + let config = GitSandboxConfig { + virtual_mcp_id: "vmcp-concurrent-provision".to_string(), + clone_url, + branch: Some("main".to_string()), + ..Default::default() + }; + + let (first, second) = tokio::join!(manager.provision(&config), manager.provision(&config)); + let first = first.unwrap(); + let second = second.unwrap(); + assert!(Arc::ptr_eq(&first, &second)); + + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if !first.setup.is_running() && first.setup.pending_count() == 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("the single setup generation drains"); + let clone_tasks = first + .tasks + .list(None) + .into_iter() + .filter(|task| task.command.starts_with("git clone ")) + .count(); + assert_eq!( + clone_tasks, 1, + "repeat provision must not queue Clone twice" + ); + } + + #[tokio::test] + async fn provision_discards_partial_git_directory_before_clone_retry() { + let (_origin, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + let config = GitSandboxConfig { + virtual_mcp_id: "vmcp-partial-git".to_string(), + clone_url, + branch: Some("main".to_string()), + ..Default::default() + }; + let handle = SandboxManager::compute_handle("vmcp-partial-git", "main"); + let sandbox_path = app_root.path().join("sandboxes").join(&handle); + let workdir = sandbox_path.join("repo"); + std::fs::create_dir_all(workdir.join(".git")).unwrap(); + std::fs::write(workdir.join("partial-object"), "incomplete clone").unwrap(); + manager + .registry + .upsert_config(&handle, &config, &sandbox_path, &workdir) + .unwrap(); + manager + .registry + .mark_state(&handle, "stopped", "stopped", None) + .unwrap(); + + let sandbox = manager.provision(&config).await.unwrap(); + assert!( + !sandbox.workdir.join("partial-object").exists(), + "invalid partial clone is cleared before the retry is queued" + ); + tokio::time::timeout(Duration::from_secs(10), async { + while !crate::routes::git::is_git_repo(&sandbox.workdir).await { + tokio::task::yield_now().await; + } + }) + .await + .expect("retry produces a valid repository"); + } + + #[tokio::test] + async fn stale_generation_lifecycle_cannot_overwrite_resumed_generation() { + let (_origin, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + let config = GitSandboxConfig { + virtual_mcp_id: "vmcp-stale-monitor".to_string(), + clone_url, + branch: Some("main".to_string()), + ..Default::default() + }; + let old = manager.ensure(&config).await.unwrap(); + manager.stop_registered(&old.handle).await.unwrap().unwrap(); + let current = manager.provision(&config).await.unwrap(); + assert!(!Arc::ptr_eq(&old, ¤t)); + + tokio::time::timeout(Duration::from_secs(5), async { + while current.setup.is_running() || current.setup.pending_count() > 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + + current.broadcaster.emit( + "lifecycle", + json!({"state":{"phase":"running","port":3210,"htmlSupport":true}}), + ); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + if manager + .registry_record(¤t.handle) + .unwrap() + .unwrap() + .observed_status + == "running" + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + + old.broadcaster.emit( + "lifecycle", + json!({"state":{"phase":"clone-failed","error":"stale failure"}}), + ); + tokio::task::yield_now().await; + let record = manager.registry_record(¤t.handle).unwrap().unwrap(); + assert_eq!(record.observed_status, "running"); + assert_ne!(record.error.as_deref(), Some("stale failure")); + } + + #[tokio::test] + async fn ensure_rejects_an_unrelated_clone_url_change_for_the_same_handle() { + // `ConfigStore::patch`'s identity-conflict guard fires if the SAME + // handle is somehow re-ensured with a genuinely different repo — a + // defensive check since `virtualMcpId`+`branch` should always + // determine one repo, but a caller bug (or a real handle collision) + // must fail loudly, not silently clone over the wrong directory. + let (_root, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + + manager + .ensure(&GitSandboxConfig { + virtual_mcp_id: "vmcp-1".to_string(), + clone_url, + branch: Some("main".to_string()), + ..Default::default() + }) + .await + .expect("first ensure succeeds"); + + let result = manager + .ensure(&GitSandboxConfig { + virtual_mcp_id: "vmcp-1".to_string(), + clone_url: "https://example.com/totally/different.git".to_string(), + branch: Some("main".to_string()), + ..Default::default() + }) + .await; + let Err(err) = result else { + panic!("a different cloneUrl for the same handle must be rejected"); + }; + assert!(err.contains("immutable")); + } + + // --- Resurrection (persisted-sidecar self-heal) ------------------------- + + #[tokio::test] + async fn resurrect_returns_none_for_a_handle_with_no_sidecar() { + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + let result = manager + .resurrect("never-ensured-handle") + .await + .expect("no sidecar is not an error"); + assert!(result.is_none()); + } + + #[tokio::test] + async fn resurrect_returns_the_in_memory_sandbox_without_touching_disk_when_already_known() { + let (_root, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + let sandbox = manager + .ensure(&GitSandboxConfig { + virtual_mcp_id: "vmcp-resurrect-known".to_string(), + clone_url, + branch: Some("main".to_string()), + ..Default::default() + }) + .await + .expect("ensure succeeds"); + + let resurrected = manager + .resurrect(&sandbox.handle) + .await + .expect("resurrect ok") + .expect("sandbox found"); + assert!( + Arc::ptr_eq(&sandbox, &resurrected), + "an already-known handle must return the SAME Sandbox, not re-ensure a new one" + ); + } + + #[tokio::test] + async fn resurrect_rebuilds_a_forgotten_handle_from_its_persisted_sidecar_on_a_fresh_manager() { + // Simulates a backend process restart: the FIRST manager `ensure()`s + // a handle (writing its sidecar + workdir to disk), then a SECOND, + // entirely fresh `SandboxManager` over the SAME app_root (empty + // in-memory `sandboxes` map, exactly like a real relaunch) resolves + // that same handle via `resurrect()` alone. + let (_root, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + + let manager_a = SandboxManager::new(app_root.path().to_path_buf()); + let original = manager_a + .ensure(&GitSandboxConfig { + virtual_mcp_id: "vmcp-resurrect-forgotten".to_string(), + clone_url, + branch: Some("main".to_string()), + ..Default::default() + }) + .await + .expect("ensure succeeds"); + let handle = original.handle.clone(); + let workdir = original.workdir.clone(); + drop(manager_a); + + let manager_b = SandboxManager::new(app_root.path().to_path_buf()); + assert!( + manager_b.get(&handle).is_none(), + "a fresh manager must start with an empty in-memory map" + ); + + let resurrected = manager_b + .resurrect(&handle) + .await + .expect("resurrect ok") + .expect("sidecar found and re-ensure succeeded"); + assert_eq!(resurrected.workdir, workdir); + assert_eq!( + std::fs::read_to_string(resurrected.workdir.join("BRANCH.txt")) + .unwrap() + .trim(), + "main", + "resurrection must land on the SAME (already-cloned) workdir, not a fresh clone" + ); + assert!( + manager_b.get(&handle).is_some(), + "a resurrected handle must now be known in memory too" + ); + } + + #[tokio::test] + async fn adopt_rebuilds_routing_metadata_without_starting_setup() { + let (_root, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + let manager_a = SandboxManager::new(app_root.path().to_path_buf()); + let original = manager_a + .ensure(&GitSandboxConfig { + virtual_mcp_id: "vmcp-adopt-only".to_string(), + clone_url, + branch: Some("main".to_string()), + ..Default::default() + }) + .await + .expect("initial ensure succeeds"); + let handle = original.handle.clone(); + drop(manager_a); + + let manager_b = SandboxManager::new(app_root.path().to_path_buf()); + let adopted = manager_b + .adopt(&handle) + .await + .expect("metadata adoption succeeds") + .expect("durable record exists"); + assert_eq!(adopted.handle, handle); + assert!(adopted.tasks.list(None).is_empty()); + assert_eq!(adopted.setup.lifecycle_snapshot()["phase"], "idle"); + let record = manager_b.registry_record(&handle).unwrap().unwrap(); + assert_eq!(record.observed_status, "stopped"); + } + + #[tokio::test] + async fn stop_during_install_fences_old_cascade_and_preserves_install_checkpoint() { + let (_root, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + let config = GitSandboxConfig { + virtual_mcp_id: "vmcp-stop-install-fence".to_string(), + clone_url, + branch: Some("main".to_string()), + ..Default::default() + }; + let original = manager.ensure(&config).await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + while original.setup.is_running() || original.setup.pending_count() > 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + original + .broadcaster + .emit("lifecycle", json!({"state":{"phase":"installing"}})); + tokio::time::timeout(Duration::from_secs(2), async { + while manager + .registry_record(&original.handle) + .unwrap() + .unwrap() + .resume_step + != "install" + { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + + // Deterministically model the install child at the exact boundary + // Stop must own; unlike a timing-dependent real package manager this + // cannot finish before the assertion reaches the stop fence. + let controller = crate::tasks::ProcessController::new(); + original.tasks.insert(crate::tasks::TaskEntry::new( + crate::tasks::TaskSummary { + id: "install-in-flight".to_string(), + command: "bun install".to_string(), + status: TaskStatus::Running, + exit_code: None, + started_at: 0, + finished_at: None, + timed_out: false, + truncated: false, + log_name: Some("setup".to_string()), + intentional: None, + }, + Some(controller.kill_handle()), + )); + let task_owner = { + let tasks = original.tasks.clone(); + let controller = controller.clone(); + tokio::spawn(async move { + let signal = controller.wait_for_change(None).await; + tasks.finalize( + "install-in-flight", + TaskStatus::Killed, + signal.exit_code(), + false, + ); + }) + }; + + let killed_count = manager + .stop_registered(&original.handle) + .await + .unwrap() + .unwrap(); + assert!(killed_count >= 1); + task_owner.await.unwrap(); + assert_eq!(controller.requested(), Some(KillSignal::Term)); + assert!(original.setup.is_closed()); + assert!( + !original.setup.resume_from(Step::Start), + "the stopped generation cannot cascade into or enqueue Start" + ); + assert!(manager.get(&original.handle).is_none()); + assert_eq!( + manager + .registry_record(&original.handle) + .unwrap() + .unwrap() + .resume_step, + "install", + "Stop preserves the earliest safe step instead of skipping an incomplete install" + ); + + let resumed = manager.provision(&config).await.unwrap(); + assert!(!Arc::ptr_eq(&original, &resumed)); + assert!(!resumed.setup.is_closed()); + assert_eq!( + manager + .registry_record(&resumed.handle) + .unwrap() + .unwrap() + .desired_status, + "running" + ); + } + + #[tokio::test] + async fn resurrect_active_returns_none_when_nothing_was_ever_active() { + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + let result = manager + .resurrect_active() + .await + .expect("no persisted active handle is not an error"); + assert!(result.is_none()); + } + + #[tokio::test] + async fn resurrect_active_rebuilds_the_last_active_handle_on_a_fresh_manager_after_a_restart() { + let (_root, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + + let manager_a = SandboxManager::new(app_root.path().to_path_buf()); + let original = manager_a + .ensure(&GitSandboxConfig { + virtual_mcp_id: "vmcp-resurrect-active".to_string(), + clone_url, + branch: Some("main".to_string()), + ..Default::default() + }) + .await + .expect("ensure succeeds (also marks this handle active)"); + let workdir = original.workdir.clone(); + drop(manager_a); + + // Fresh manager, same app_root — `active_handle` is `None` in memory, + // but the persisted `.active-handle` pointer file survives. + let manager_b = SandboxManager::new(app_root.path().to_path_buf()); + assert!(manager_b.active().is_none()); + + let resurrected = manager_b + .resurrect_active() + .await + .expect("resurrect_active ok") + .expect("persisted active handle resolved"); + assert_eq!(resurrected.workdir, workdir); + // The headerless path is now warm too, for a SUBSEQUENT call. + assert!(manager_b.active().is_some()); + } + + #[tokio::test] + async fn resurrect_active_prefers_the_in_memory_active_sandbox_over_disk() { + let (_root, clone_url) = setup_two_branch_repo(); + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + let sandbox = manager + .ensure(&GitSandboxConfig { + virtual_mcp_id: "vmcp-resurrect-prefers-memory".to_string(), + clone_url, + branch: Some("main".to_string()), + ..Default::default() + }) + .await + .expect("ensure succeeds"); + + let resurrected = manager + .resurrect_active() + .await + .expect("resurrect_active ok") + .expect("active sandbox found"); + assert!( + Arc::ptr_eq(&sandbox, &resurrected), + "an already-active in-memory sandbox must be returned directly, no disk read needed" + ); + } + + #[tokio::test] + async fn shutdown_all_closes_and_reaps_every_sandbox_concurrently() { + let app_root = tempfile::tempdir().unwrap(); + let manager = SandboxManager::new(app_root.path().to_path_buf()); + let mut sandboxes = Vec::new(); + for handle in ["one", "two"] { + let lock = manager.handle_lock(handle); + let _permit = lock.lock().await; + sandboxes.push( + manager + .get_or_create_locked(handle) + .expect("sandbox construction succeeds"), + ); + } + + let both_signaled = Arc::new(tokio::sync::Barrier::new(2)); + let mut owners = Vec::new(); + for sandbox in &sandboxes { + let id = sandbox.tasks.next_id(); + let controller = crate::tasks::ProcessController::new(); + assert!(sandbox.setup.register_task(crate::tasks::TaskEntry::new( + crate::tasks::TaskSummary { + id: id.clone(), + command: "test-child".to_string(), + status: TaskStatus::Running, + exit_code: None, + started_at: 0, + finished_at: None, + timed_out: false, + truncated: false, + log_name: None, + intentional: None, + }, + Some(controller.kill_handle()), + ))); + let tasks = sandbox.tasks.clone(); + let barrier = both_signaled.clone(); + owners.push(tokio::spawn(async move { + assert_eq!( + controller.wait_for_change(None).await, + crate::tasks::KillSignal::Term + ); + barrier.wait().await; + tasks.finalize(&id, TaskStatus::Killed, 143, false); + })); + } + + let results = tokio::time::timeout( + Duration::from_millis(500), + manager.shutdown_all(Duration::from_secs(1), Duration::from_secs(1)), + ) + .await + .expect("all sandbox shutdowns run concurrently"); + for owner in owners { + owner.await.unwrap(); + } + + assert_eq!(results.len(), 2); + assert!(results.iter().all(|result| { + result.result.tasks.initially_running == 1 + && result.result.tasks.term_signaled == 1 + && result.result.tasks.remaining.is_empty() + })); + assert!(sandboxes.iter().all(|sandbox| sandbox.setup.is_closed())); + assert!(manager.is_closing()); + assert!(manager.ensure(&GitSandboxConfig::default()).await.is_err()); + } +} diff --git a/apps/native/crates/local-api/src/sandbox/mod.rs b/apps/native/crates/local-api/src/sandbox/mod.rs new file mode 100644 index 0000000000..85e17134fa --- /dev/null +++ b/apps/native/crates/local-api/src/sandbox/mod.rs @@ -0,0 +1,59 @@ +//! Per-handle git-sandbox workdir manager — see +//! the native Git-sandbox contract for the target design this +//! module implements. +//! +//! `local-api` boots with exactly ONE `AppState.repo_dir`/`AppState.setup` +//! pair (the "plain" path: a user opens an already-checked-out project +//! folder, no clone involved — see `crate::setup`'s own module doc). That +//! path is UNCHANGED by this module. +//! +//! For a git-BACKED virtual MCP (a chat pinned to a `{cloneUrl, branch}`), +//! prod (`bunx decocms link`) gives every `(virtualMcpId, branch)` pair its +//! OWN full clone directory, keyed by a derived `handle` — see the design +//! doc's "Prod contract" section. [`SandboxManager`] is that same idea, +//! adapted to run in-process (no per-handle daemon subprocess, no +//! `.localhost` routing — both dropped for the reasons the design +//! doc's "Desktop adaptation" section explains): one [`Sandbox`] per handle, +//! each with its OWN `workdir`, `ConfigStore`, `TaskRegistry`, `Broadcaster`, +//! and `SetupOrchestrator` — the EXISTING clone -> install -> start pipeline +//! (`crate::setup`), just instantiated per-workdir instead of once globally. +//! +//! Callers (`routes/intercept/decopilot.rs::send_message`, +//! `routes/dispatch.rs::build_run_spec`) call [`SandboxManager::ensure`] with +//! a [`GitSandboxConfig`] derived from the dispatch payload's `sandbox` +//! block (decopilot) or `workspace.repo`/`workspace.branch` (the daemon-parity +//! dispatch family), then use the returned [`Sandbox::workdir`] as the +//! harness's spawn `cwd` instead of the single global `state.repo_dir`. +//! `routes/proxy.rs` reads a [`Sandbox`]'s own `setup`/`config` to resolve +//! the SNIFFED dev port for that specific handle (see that file's module +//! doc for the header-based routing convention). + +pub mod manager; +pub mod org_mount; +pub mod org_prompt; +pub mod org_view; +pub(crate) mod persist; +pub(crate) mod registry; +pub mod repo_store; +pub mod target; + +pub use manager::{GitSandboxConfig, SandboxManager}; +pub use target::SandboxTarget; + +/// Request header a caller sets to route a per-handle sandbox request (preview +/// proxy, tasks, scripts, setup) at a SPECIFIC git-sandbox handle instead of +/// the process-global path — see `routes/proxy.rs`'s module doc's "Dev-port +/// resolution" section and [`crate::sandbox::target`]. The TS side echoes this +/// exact header name; keep it the ONE source of truth. +pub const SANDBOX_HANDLE_HEADER: &str = "x-decocms-sandbox-handle"; + +/// Extracts the sandbox handle from [`SANDBOX_HANDLE_HEADER`] on a request, if +/// present and valid UTF-8 — the header-side counterpart to `events.rs`'s +/// `?handle=` query param. Empty values are treated as absent so a caller that +/// sends an empty header behaves like a headerless request. +pub fn handle_from_headers(headers: &axum::http::HeaderMap) -> Option<&str> { + headers + .get(SANDBOX_HANDLE_HEADER) + .and_then(|v| v.to_str().ok()) + .filter(|s| !s.is_empty()) +} diff --git a/apps/native/crates/local-api/src/sandbox/org_mount.rs b/apps/native/crates/local-api/src/sandbox/org_mount.rs new file mode 100644 index 0000000000..81dbbb7432 --- /dev/null +++ b/apps/native/crates/local-api/src/sandbox/org_mount.rs @@ -0,0 +1,627 @@ +//! Lifecycle of the shared org-filesystem NFS mounts. +//! +//! The volumes are mounted once per organization under `/orgs/` +//! and each sandbox links into them (see [`super::org_view`]). This module owns +//! the mounts themselves; P2 of `apps/native/docs/org-fs-plan.md`. +//! +//! ## Why a boot sweep exists at all +//! +//! An NFS mount outlives the process that served it. When the app is SIGKILLed +//! — which happens routinely during development, and to any app the user force +//! quits — rclone dies but the kernel keeps the mount in its table, backed by +//! nothing. The directory is then unusable: the first access blocks for about +//! eight seconds and every later one fails instantly with `ETIMEDOUT`, so the +//! sandbox that owns it can never be re-provisioned at the same path. +//! +//! This is not theoretical. Before this module existed, a development machine +//! had accumulated **21** such ghosts from the previous TS `link` daemon, with +//! no `rclone` process alive to back any of them. +//! +//! `umount -f` reclaims one instantly, and exits non-zero harmlessly when the +//! path is not a mount — so the sweep can run unconditionally at boot without +//! first proving anything about each entry. +//! +//! ## Why entries are keyed by mountpoint, never by source +//! +//! rclone derives its NFS export name from the remote plus a config hash, so +//! several distinct mounts share one source string: three different mountpoints +//! were observed all reporting `localhost:/wd{KnsWa}`. Only the mountpoint +//! identifies a mount, so only the mountpoint decides what this sweeps. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use tokio::process::{Child, Command}; + +use super::org_view::ORG_VOLUMES; + +/// What `rclone` needs to satisfy local-api's own guard. +/// +/// A bearer is NOT sufficient: the shipped app runs embedded, where the guard +/// wants the exact `Host`, the exact `Origin` on unsafe methods (and +/// `PROPFIND` is unsafe — rclone sends no `Origin` by default), and the +/// per-launch session cookie. Without all three, listing fails with +/// `couldn't list files: forbidden origin: 403`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MountCredentials { + /// `http://` — the host string must match what the + /// guard compares against, byte for byte, or every request is rejected. + pub base_url: String, + pub origin: String, + pub cookie: String, +} + +fn credentials_cell() -> &'static OnceLock { + static CREDENTIALS: OnceLock = OnceLock::new(); + &CREDENTIALS +} + +/// The published credentials, for anything else that must satisfy this +/// process's own guard — currently the agent MCP handed to a CLI harness. +pub fn local_credentials() -> Option<&'static MountCredentials> { + credentials_cell().get() +} + +/// Publish the credentials once at boot. Mirrors the process-global +/// `set_preview_port` convention rather than adding an `AppState` field, +/// which this module family may not do. +pub fn set_credentials(credentials: MountCredentials) { + let _ = credentials_cell().set(credentials); +} + +/// Live `rclone` children, keyed by mountpoint. +/// +/// They are RETAINED here on purpose: `Child` is spawned with +/// `kill_on_drop`, so dropping one would tear down the mount it is serving. +fn children() -> &'static Mutex> { + static CHILDREN: OnceLock>> = OnceLock::new(); + CHILDREN.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Per-org mount state. +/// +/// The mounts are warmed when the app first touches an organization (see +/// [`warm`]), so by the time a sandbox needs them they are already up. That +/// means this state is consulted from a HOT path — every org-scoped request — +/// so every transition has to be a lock and a map lookup, never work. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MountState { + /// A mount attempt is running. Nobody else should start one. + InFlight, + /// Every volume is attached. + Ready, + /// The last attempt failed; refuse new ones until this passes. + /// + /// Without a cooldown a failing org would be retried by EVERY org-scoped + /// request the webview makes — a spawn storm against an upstream that is + /// already unhappy (the usual cause is simply that the user has not + /// finished signing in). + Failed { until: Instant }, +} + +/// How long a failed org is left alone before another attempt. +const FAILURE_COOLDOWN: Duration = Duration::from_secs(30); + +/// How long [`wait_ready`] blocks a sandbox ensure on a mount that is not up +/// yet. Short on purpose: with warm mounts this never elapses, so it only +/// matters when something is broken — exactly when blocking provisioning is +/// the wrong trade. The sandbox proceeds with an empty view and says so. +pub const READY_TIMEOUT: Duration = Duration::from_secs(2); + +fn states() -> &'static Mutex> { + static STATES: OnceLock>> = OnceLock::new(); + STATES.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn lock_states() -> std::sync::MutexGuard<'static, HashMap> { + states() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Claim the right to mount `org_slug`, or `None` if it is already mounted, +/// already being mounted, or cooling off after a failure. +fn claim(org_slug: &str) -> Option<()> { + let mut states = lock_states(); + match states.get(org_slug) { + Some(MountState::Ready) | Some(MountState::InFlight) => None, + Some(MountState::Failed { until }) if Instant::now() < *until => None, + _ => { + states.insert(org_slug.to_string(), MountState::InFlight); + Some(()) + } + } +} + +fn settle(org_slug: &str, ok: bool) { + let state = if ok { + MountState::Ready + } else { + MountState::Failed { + until: Instant::now() + FAILURE_COOLDOWN, + } + }; + lock_states().insert(org_slug.to_string(), state); +} + +/// Start mounting an organization's volumes, without waiting for them. +/// +/// Called when the app first touches an org — which is both "the app booted +/// into this org" and "the user switched to it" — so the mounts are warming +/// while the user is still navigating to an agent. Cheap and idempotent: an +/// org that is ready, in flight, or cooling off returns immediately. +pub fn warm(app_root: &Path, org_slug: &str) { + if claim(org_slug).is_none() { + return; + } + let app_root = app_root.to_path_buf(); + let org = org_slug.to_string(); + tokio::spawn(async move { + let ok = mount_all(&app_root, &org).await; + settle(&org, ok); + }); +} + +/// Wait for an organization's volumes to be attached, up to +/// [`READY_TIMEOUT`]. +/// +/// Returns whether they are. Starts the mount itself if nothing has — a +/// sandbox can be ensured without any org-scoped request having preceded it +/// (a resurrect on boot, a test), and such a caller must not wait out the +/// timeout for something nobody started. +pub async fn wait_ready(app_root: &Path, org_slug: &str) -> bool { + warm(app_root, org_slug); + let deadline = Instant::now() + READY_TIMEOUT; + loop { + match lock_states().get(org_slug).copied() { + Some(MountState::Ready) => return true, + Some(MountState::Failed { .. }) | None => return false, + Some(MountState::InFlight) => {} + } + if Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +/// The bundled rclone./// The bundled rclone. +/// +/// Tauri strips the host triple when copying an `externalBin` into +/// `.app/Contents/MacOS/`, so the packaged binary sits beside the app +/// executable. The triple-suffixed path is the `tauri dev` fallback, where +/// nothing has been copied yet. +fn rclone_binary() -> Option { + let exe = std::env::current_exe().ok()?; + let dir = exe.parent()?; + let bundled = dir.join("rclone"); + if bundled.is_file() { + return Some(bundled); + } + // `-apple-darwin` matches both the Rust host triple `fetch-rclone.sh` + // names its output with, and `std::env::consts::ARCH` (`aarch64`/`x86_64`). + let dev = dir.join(format!("rclone-{}-apple-darwin", std::env::consts::ARCH)); + dev.is_file().then_some(dev) +} + +/// Mount every volume of `org_slug` that is not already mounted. +/// +/// Idempotent: a volume whose mountpoint already appears in the mount table is +/// left alone, so a second sandbox in the same org reuses the first one's +/// mounts instead of spawning a duplicate rclone. +/// +/// Best-effort — a failure leaves the volume unmounted, which reads as an +/// empty directory through the sandbox's view rather than as an error. +async fn mount_all(app_root: &Path, org_slug: &str) -> bool { + let (Some(root), Some(creds), Some(bin)) = ( + super::org_view::org_mount_root(app_root, org_slug), + credentials_cell().get(), + rclone_binary(), + ) else { + tracing::debug!(org_slug, "org-fs mount skipped: not configured"); + return false; + }; + + let mounted = mounted_paths().await; + let mut all = true; + for volume in ORG_VOLUMES { + let mountpoint = root.join(volume); + if mounted.contains(&mountpoint) { + continue; + } + if let Err(error) = tokio::fs::create_dir_all(&mountpoint).await { + tracing::warn!(%error, ?mountpoint, "could not create org mountpoint"); + all = false; + continue; + } + match spawn_mount(&bin, &mountpoint, org_slug, volume, creds).await { + Ok(child) => { + children() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(mountpoint.clone(), child); + if !wait_until_mounted(&mountpoint).await { + tracing::warn!(?mountpoint, "org volume did not attach in time"); + all = false; + } + } + Err(error) => { + tracing::warn!(%error, ?mountpoint, "failed to spawn rclone"); + all = false; + } + } + } + all +} + +/// Spawn a supervised `rclone nfsmount` for one volume. +/// +/// Foreground, never `--daemon`: `nfsmount --daemon --rc` is broken in rclone +/// (it exits 1 rather than attaching), so the child has to be supervised here. +/// +/// Everything secret travels by ENVIRONMENT, never argv — argv is readable by +/// any local process through `ps`, which is exactly why the keychain helper +/// avoids it too. +async fn spawn_mount( + bin: &Path, + mountpoint: &Path, + org: &str, + volume: &str, + creds: &MountCredentials, +) -> std::io::Result { + let mut cmd = Command::new(bin); + cmd.arg("nfsmount") + .arg("wd:") + .arg(mountpoint) + .args([ + "--option", + "actimeo=1,locallocks,soft,timeo=100,retrans=2,nobrowse", + ]) + .args(["--vfs-cache-mode", "full"]) + .args(["--vfs-write-back", "1s"]) + .args(["--dir-cache-time", "10s"]) + // rclone's DEFAULTS are unusable here: its VFS downloader retries to a + // hard-coded 10-error limit, so `--timeout` x `--low-level-retries` x + // 11 is the worst-case block when the backing server stops answering + // — about NINE HOURS at the defaults. On loopback a request is either + // instant or dead, so bound it aggressively. + .args(["--timeout", "5s"]) + .args(["--contimeout", "3s"]) + .args(["--low-level-retries", "1"]) + .env("RCLONE_CONFIG_WD_TYPE", "webdav") + .env( + "RCLONE_CONFIG_WD_URL", + format!("{}/_sandbox/orgfs/{org}/{volume}", creds.base_url), + ) + .env("RCLONE_CONFIG_WD_VENDOR", "other") + .env( + "RCLONE_CONFIG_WD_HEADERS", + format!("Origin,{},Cookie,{}", creds.origin, creds.cookie), + ) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + if is_read_only(volume) { + cmd.arg("--read-only"); + } + cmd.spawn() +} + +/// `public` is the org's curated, shared skill sets — this app must never +/// write to it, and saying so at mount time is one more layer under the +/// WebDAV surface's own refusal. +fn is_read_only(volume: &str) -> bool { + volume.starts_with("public") +} + +/// Wait for the kernel to actually attach the mount. +/// +/// The mount TABLE is the authoritative signal. rclone's `rc` API reports +/// ready about 36 ms before the kernel attaches, so trusting it would hand +/// out a path that is not yet a mount. +async fn wait_until_mounted(mountpoint: &Path) -> bool { + for _ in 0..40 { + if mounted_paths().await.contains(&mountpoint.to_path_buf()) { + return true; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + false +} + +/// Every NFS mountpoint currently attached. +async fn mounted_paths() -> Vec { + let Ok(output) = Command::new("mount") + .stdin(std::process::Stdio::null()) + .kill_on_drop(true) + .output() + .await + else { + return Vec::new(); + }; + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(parse_mount_line) + .filter(|(_, fstype)| fstype == "nfs") + .map(|(mountpoint, _)| mountpoint) + .collect() +} + +/// Reclaim every stale org mount left behind by a previous run. +/// +/// Best-effort and unconditional: a path that is not actually mounted simply +/// fails to unmount, which costs one process and nothing else. Scoped to +/// `/orgs/` so it can never touch a mount this app does not own. +pub async fn prune_stale_mounts(app_root: &Path) { + if !cfg!(target_os = "macos") { + return; + } + let Ok(output) = tokio::process::Command::new("mount") + .stdin(std::process::Stdio::null()) + .kill_on_drop(true) + .output() + .await + else { + return; + }; + // Kill the servers BEFORE reclaiming their mountpoints: an orphan still + // answering NFS can keep a mount busy, and once its path is unmounted it + // has nothing left to serve anyway. + kill_orphaned_servers(app_root).await; + + let table = String::from_utf8_lossy(&output.stdout); + for mountpoint in stale_mountpoints(&table, app_root) { + let path = mountpoint.to_string_lossy().into_owned(); + let result = tokio::process::Command::new("umount") + .args(["-f", &path]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true) + .status() + .await; + match result { + Ok(status) if status.success() => { + tracing::info!(mountpoint = %path, "reclaimed a stale org mount") + } + // Not mounted after all, or held by something: the next boot tries + // again, and a sandbox that needs the path reports it. + _ => tracing::debug!(mountpoint = %path, "stale org mount did not unmount"), + } + } +} + +/// Kill `rclone` processes left over from a previous run of this app. +/// +/// `kill_on_drop` only fires when the `Child` is DROPPED. A hard exit — a +/// SIGKILL, or the restart a dev loop performs on every rebuild — runs no +/// destructors, so the children survive, get reparented to init, and leak. One +/// development session accumulated EIGHT of them, each serving a mountpoint +/// that had already been reclaimed. +/// +/// Safe to run unconditionally at boot precisely because it runs at boot: this +/// process has not spawned any mounts yet, so every match is somebody else's +/// corpse. Matching is scoped to argv that references this app root's `orgs/` +/// directory, so an rclone the user runs for their own purposes is untouched. +async fn kill_orphaned_servers(app_root: &Path) { + let Some(marker) = app_root.join("orgs").to_str().map(str::to_string) else { + return; + }; + let Ok(output) = Command::new("ps") + .args(["-eo", "pid=,command="]) + .stdin(std::process::Stdio::null()) + .kill_on_drop(true) + .output() + .await + else { + return; + }; + for pid in orphaned_rclone_pids(&String::from_utf8_lossy(&output.stdout), &marker) { + let _ = Command::new("kill") + .args(["-9", &pid.to_string()]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true) + .status() + .await; + tracing::info!(pid, "killed an orphaned org-fs mount server"); + } +} + +/// PIDs of `rclone` processes whose argv mentions `marker`. +/// +/// Pure so the matching — the part that decides what gets SIGKILLed — is +/// testable without spawning anything. +fn orphaned_rclone_pids(ps_output: &str, marker: &str) -> Vec { + ps_output + .lines() + // All three, because this SIGKILLs what it matches: our orgs path, the + // rclone binary, and the subcommand we actually spawn. Path alone would + // match a `grep` for it; rclone alone would match the user's own. + .filter(|line| { + line.contains(marker) && line.contains("rclone") && line.contains("nfsmount") + }) + .filter_map(|line| line.split_whitespace().next()?.parse::().ok()) + .collect() +} + +/// The NFS mountpoints under `/orgs/` named in a `mount` table. +/// +/// Kept pure so the parsing — the part that can silently sweep the wrong path +/// — is testable without mounting anything. +fn stale_mountpoints(table: &str, app_root: &Path) -> Vec { + let orgs_root = app_root.join("orgs"); + table + .lines() + .filter_map(parse_mount_line) + .filter(|(mountpoint, fstype)| fstype == "nfs" && mountpoint.starts_with(&orgs_root)) + .map(|(mountpoint, _)| mountpoint) + .collect() +} + +/// ` on (, …)` — the BSD `mount` format. +/// +/// Split on the literal separators rather than on whitespace: a mountpoint may +/// contain spaces, and the source is discarded anyway (see this module's doc on +/// why sources cannot identify a mount). +fn parse_mount_line(line: &str) -> Option<(PathBuf, String)> { + let (_, rest) = line.split_once(" on ")?; + let (mountpoint, tail) = rest.rsplit_once(" (")?; + let fstype = tail.split(',').next()?.trim_end_matches(')').trim(); + Some((PathBuf::from(mountpoint), fstype.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Real `mount` output captured on macOS, including the ghosts this sweep + /// exists for. + const TABLE: &str = concat!( + "/dev/disk3s1s1 on / (apfs, sealed, local, read-only, journaled)\n", + "devfs on /dev (devfs, local, nobrowse)\n", + "localhost:/wd{rkiaF} on /app/orgs/acme/home (nfs, nodev, nosuid, nobrowse, mounted by gimenes)\n", + "localhost:/wd{rkiaF} on /app/orgs/acme/uploads (nfs, nodev, nosuid, nobrowse)\n", + "localhost:/wd{other} on /app/orgs/other-org/public (nfs, nodev, nosuid)\n", + "map auto_home on /System/Volumes/Data/home (autofs, automounted, nobrowse)\n", + ); + + #[test] + fn sweeps_every_nfs_mount_under_the_orgs_root() { + let found = stale_mountpoints(TABLE, Path::new("/app")); + assert_eq!( + found, + vec![ + PathBuf::from("/app/orgs/acme/home"), + PathBuf::from("/app/orgs/acme/uploads"), + PathBuf::from("/app/orgs/other-org/public"), + ] + ); + } + + /// The sweep force-unmounts, so scoping is the only thing standing between + /// it and someone else's filesystem. + #[test] + fn never_touches_a_mount_outside_the_orgs_root() { + let table = concat!( + "localhost:/wd{x} on /app/sandboxes/h1/repo (nfs, nodev)\n", + "server:/export on /Volumes/work (nfs, nodev)\n", + "localhost:/wd{y} on /other-app/orgs/acme/home (nfs, nodev)\n", + ); + assert!(stale_mountpoints(table, Path::new("/app")).is_empty()); + } + + #[test] + fn ignores_non_nfs_filesystems_at_the_same_paths() { + let table = concat!( + "/dev/disk5 on /app/orgs/acme/home (apfs, local)\n", + "devfs on /app/orgs/acme/uploads (devfs, local, nobrowse)\n", + ); + assert!(stale_mountpoints(table, Path::new("/app")).is_empty()); + } + + /// A mountpoint may contain spaces — splitting the line on whitespace + /// would truncate the path and unmount something else, or nothing. + #[test] + fn handles_mountpoints_containing_spaces() { + let table = "localhost:/wd{z} on /app/orgs/my org/home (nfs, nodev)\n"; + assert_eq!( + stale_mountpoints(table, Path::new("/app")), + vec![PathBuf::from("/app/orgs/my org/home")] + ); + } + + /// `public` holds the org's curated shared skill sets. The WebDAV surface + /// already refuses writes to it; mounting it read-only is the second layer, + /// so a bug in either one alone cannot corrupt shared content. + /// The leak this exists for: `kill_on_drop` never fires on a hard exit, so + /// rclone children outlive the app and pile up across restarts. + #[test] + fn finds_orphaned_rclone_servers_by_our_own_orgs_path() { + let ps = concat!( + " 100 /App.app/Contents/MacOS/rclone nfsmount wd: /data/orgs/deco/home --option x\n", + " 101 /App.app/Contents/MacOS/rclone nfsmount wd: /data/orgs/acme/uploads\n", + ); + assert_eq!(orphaned_rclone_pids(ps, "/data/orgs"), vec![100, 101]); + } + + /// This SIGKILLs what it matches, so everything it must NOT match is the + /// part worth pinning. + #[test] + fn never_kills_a_process_that_is_not_ours() { + let ps = concat!( + // The user's own rclone, serving their own backup. + " 102 /usr/local/bin/rclone mount remote: /Users/me/backup\n", + // Our app itself. + " 103 /App.app/Contents/MacOS/decocms-desktop\n", + // Something merely MENTIONING the path — a grep, an editor. + " 104 grep -r rclone /data/orgs\n", + // Another app's org mounts, same layout, different root. + " 105 /Other.app/Contents/MacOS/rclone nfsmount wd: /elsewhere/orgs/deco/home\n", + ); + assert!(orphaned_rclone_pids(ps, "/data/orgs").is_empty()); + } + + /// Concurrency: two sandbox ensures for one org must not each spawn their + /// own rclone for the same mountpoint. + #[test] + fn only_one_mount_attempt_per_org_can_be_in_flight() { + lock_states().remove("claim-org"); + assert!(claim("claim-org").is_some(), "first caller claims it"); + assert!(claim("claim-org").is_none(), "second is turned away"); + + settle("claim-org", true); + assert!( + claim("claim-org").is_none(), + "a ready org is not re-mounted" + ); + lock_states().remove("claim-org"); + } + + /// The trap that made the request hook viable: this runs on EVERY + /// org-scoped request, so a failing org must not be retried by each one — + /// that is a spawn storm against an upstream that is already unhappy. + #[test] + fn a_failed_org_is_left_alone_until_its_cooldown_expires() { + lock_states().remove("cooldown-org"); + claim("cooldown-org"); + settle("cooldown-org", false); + assert!(claim("cooldown-org").is_none(), "still cooling off"); + + // Once the cooldown passes, a fresh attempt is allowed. + lock_states().insert( + "cooldown-org".to_string(), + MountState::Failed { + until: Instant::now() - Duration::from_secs(1), + }, + ); + assert!( + claim("cooldown-org").is_some(), + "retried after the cooldown" + ); + lock_states().remove("cooldown-org"); + } + + #[test] + fn public_volumes_mount_read_only() { + assert!(is_read_only("public")); + assert!(is_read_only("public-core")); + for writable in ["home", "uploads", "outputs"] { + assert!(!is_read_only(writable), "{writable} must stay writable"); + } + } + + #[test] + fn tolerates_lines_that_are_not_mount_entries() { + for line in ["", "garbage", "no-separator (nfs)", "x on y"] { + assert!( + stale_mountpoints(line, Path::new("/app")).is_empty(), + "{line:?}" + ); + } + } +} diff --git a/apps/native/crates/local-api/src/sandbox/org_prompt.rs b/apps/native/crates/local-api/src/sandbox/org_prompt.rs new file mode 100644 index 0000000000..ef207aa65d --- /dev/null +++ b/apps/native/crates/local-api/src/sandbox/org_prompt.rs @@ -0,0 +1,134 @@ +//! The organization-filesystem block injected into a desktop harness's system +//! prompt. P4 of `apps/native/docs/org-fs-plan.md`. +//! +//! Adapted from the cluster's `buildOrgFilesystemPrompt` +//! (`apps/api/src/api/routes/decopilot/constants.ts`), which is consumed by the +//! decopilot harness only — the desktop CLIs (`claude-code`, `codex`) receive +//! no system prompt at all without this. +//! +//! ## Why it cannot be reused verbatim +//! +//! Three things differ on the desktop, and each one would be a lie if the +//! cluster's wording were copied across: +//! +//! 1. **Paths are absolute.** The agent's cwd is the git worktree, and `org` +//! is its SIBLING, not a child — a relative `org/...` does not resolve. +//! 2. **Uploads and outputs are thread-scoped subdirectories** of an org-wide +//! volume (`uploads//`), where the cluster hands the agent a +//! per-run symlink at the bare path `org/upload/`. Naming the exact +//! directory here is what replaces that symlink — which is why the desktop +//! needs no per-dispatch relinking and has no race between concurrent runs. +//! 3. **Nothing is auto-loaded.** The cluster promises the memory files "appear +//! in the `` block"; the desktop has no such +//! mechanism, so this block says to READ them instead of claiming they are +//! already in context. Telling an agent its memory is loaded when it is not +//! is worse than saying nothing. + +use std::path::Path; + +/// Build the `` block for one dispatch. +/// +/// `org_dir` is the sandbox's own view directory (`/org`), whose +/// entries are symlinks into the shared per-org mounts. +pub fn build(org_dir: &Path, thread_id: &str, user_id: Option<&str>) -> String { + let org = org_dir.display(); + let user = user_id.unwrap_or(""); + format!( + "\n\ + The organization filesystem is mounted on this machine at `{org}/`. These are \ + absolute paths — your working directory is the git repository, and the \ + organization filesystem is beside it, so relative `org/...` paths will not resolve.\n\ + - `{org}/home/` — the org's shared home folder, editable and shared across every \ + agent, member, and run. Organize it freely with subfolders. Consult it only when a \ + task needs background you don't already have (past decisions, preferences, project \ + facts, gotchas) — then read it first. Do NOT browse it in response to greetings, \ + small talk, or questions you can answer directly. Record durable knowledge here as \ + you learn it; prefer small focused markdown files over one big log, and update stale \ + notes instead of appending duplicates.\n\ + - `{org}/home/MEMORY.md` — the ORGANIZATION memory index: durable facts shared with \ + everyone in this org. Read it when you need that background; it is not loaded for you.\n\ + - `{org}/home/users/{user}/MEMORY.md` — your USER memory index: facts and preferences \ + specific to the current user, not shared with other members. Also read on demand.\n\ + Keep both indexes concise — a curated list of durable facts and one-line pointers to \ + deeper notes elsewhere in the home folder. When you learn something worth remembering \ + across sessions, edit the matching file (user-specific → user memory; org-wide → \ + organization memory); update existing lines instead of appending duplicates.\n\ + - `{org}/public//` — curated read-only skill sets. This directory is mounted \ + read-only; writes to it will fail.\n\ + - `{org}/uploads/{thread_id}/` — files the user attached to THIS conversation are \ + already here; read them directly (no copy step needed).\n\ + - `{org}/outputs/{thread_id}/` — write final deliverables here; they are shared back \ + to the organization under this conversation's folder.\n\ + " + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> String { + build( + Path::new("/data/sandboxes/h1/org"), + "thread-42", + Some("user-7"), + ) + } + + /// The whole point of the desktop variant: a relative `org/...` does not + /// resolve from the agent's cwd, so every path must be absolute. + #[test] + fn every_path_is_absolute() { + let prompt = sample(); + for path in [ + "/data/sandboxes/h1/org/home/", + "/data/sandboxes/h1/org/home/MEMORY.md", + "/data/sandboxes/h1/org/public//", + "/data/sandboxes/h1/org/uploads/thread-42/", + "/data/sandboxes/h1/org/outputs/thread-42/", + ] { + assert!(prompt.contains(path), "missing {path}"); + } + // No bare relative reference that would silently fail to resolve. + assert!(!prompt.contains("`org/home"), "leaked a relative org path"); + assert!( + !prompt.contains("`org/upload"), + "leaked a relative org path" + ); + } + + /// Uploads/outputs are thread-scoped: naming the exact directory is what + /// replaces the cluster's per-run symlink. + #[test] + fn uploads_and_outputs_are_scoped_to_this_thread() { + let other = build(Path::new("/data/sandboxes/h1/org"), "thread-99", None); + assert!(other.contains("/org/uploads/thread-99/")); + assert!(!other.contains("thread-42")); + } + + #[test] + fn the_user_memory_path_carries_the_user_id() { + assert!(sample().contains("/org/home/users/user-7/MEMORY.md")); + let anon = build(Path::new("/o"), "t", None); + assert!(anon.contains("/o/home/users//MEMORY.md")); + } + + /// The desktop auto-loads nothing, so the block must not claim otherwise — + /// an agent told its memory is already in context will not go read it. + #[test] + fn never_claims_memory_is_already_loaded() { + let prompt = sample().to_lowercase(); + for lie in [ + "auto-loaded", + "appear in the ", + "shown in the ", + ] { + assert!(!prompt.contains(lie), "claimed {lie:?}"); + } + } + + #[test] + fn says_public_is_read_only() { + assert!(sample().contains("read-only")); + } +} diff --git a/apps/native/crates/local-api/src/sandbox/org_view.rs b/apps/native/crates/local-api/src/sandbox/org_view.rs new file mode 100644 index 0000000000..8525dc619b --- /dev/null +++ b/apps/native/crates/local-api/src/sandbox/org_view.rs @@ -0,0 +1,250 @@ +//! The per-sandbox view onto the shared org filesystem. +//! +//! The org's volumes are mounted ONCE per organization under +//! `/orgs//`, not once per sandbox: they are org-wide, and +//! every sandbox is the same user on the same machine, so mounting per sandbox +//! would run N copies of rclone serving identical content. Each sandbox then +//! gets a cheap symlink view: +//! +//! ```text +//! /orgs//{home,public,uploads,outputs} ← the real mounts +//! /sandboxes//org/home -> ../../../orgs//home +//! /sandboxes//org/public -> ../../../orgs//public +//! /sandboxes//org/uploads -> ../../../orgs//uploads +//! /sandboxes//org/outputs -> ../../../orgs//outputs +//! ``` +//! +//! ## Why `org` is a SIBLING of `repo`, never inside it +//! +//! The agent's cwd is `/repo` (the git worktree). Putting the org +//! tree inside it would place a network mount under the directory that +//! `bun install`, the dev server, and every git command walk — so a stalled +//! mount could wedge the whole setup pipeline. As a sibling it cannot: the +//! agent reaches it by absolute path (supplied in the harness prompt), and +//! nothing enters the worktree, so unlike the old `decocms link` design there +//! is no `.git/info/exclude` entry to maintain either. +//! +//! ## Why the targets are created even when nothing is mounted +//! +//! A symlink to a missing directory is worse than an empty one: `ls` fails +//! instead of returning nothing, and the agent reports an error rather than +//! "no files". Creating the target directories keeps a mountless sandbox +//! readable-but-empty, which is the agreed degradation, and costs nothing +//! when the mount later lands on top of the same path. + +use std::path::{Path, PathBuf}; + +/// The volume directories a sandbox sees under `org/`. +/// +/// Named for the volumes themselves rather than the old daemon's per-run +/// `upload`/`output` symlinks: narrowing to one thread now happens in the +/// harness prompt (which names `uploads//` directly), so there is no +/// per-dispatch relinking and no race between two runs sharing a sandbox. +pub const ORG_VOLUMES: [&str; 4] = ["home", "public", "uploads", "outputs"]; + +/// `/orgs/` — where an organization's volumes mount. +/// +/// Returns `None` for a slug that could escape the store or collapse two orgs +/// onto one directory, mirroring `repo_store::canonical_repo_dir`'s refusal to +/// guess. +pub fn org_mount_root(app_root: &Path, org_slug: &str) -> Option { + Some(app_root.join("orgs").join(safe_segment(org_slug)?)) +} + +/// `/sandboxes//org` — the sandbox's view directory. +pub fn sandbox_org_dir(app_root: &Path, handle: &str) -> Option { + Some( + app_root + .join("sandboxes") + .join(safe_segment(handle)?) + .join("org"), + ) +} + +/// Create (or repair) the sandbox's `org/` view for `org_slug`. +/// +/// Idempotent: re-running replaces links that point somewhere else and leaves +/// correct ones alone, so a sandbox that changes org — or one whose view was +/// partially created before a crash — converges rather than failing. +/// +/// Best-effort by design. A sandbox whose org view cannot be built still runs; +/// the agent simply has no org filesystem, which is the same outcome as a +/// failed mount. Returns whether the full view is in place, so the caller can +/// report it. +pub async fn ensure_org_view(app_root: &Path, handle: &str, org_slug: &str) -> bool { + let (Some(mount_root), Some(view)) = ( + org_mount_root(app_root, org_slug), + sandbox_org_dir(app_root, handle), + ) else { + tracing::warn!( + handle, + org_slug, + "refusing to build an unsafe org view path" + ); + return false; + }; + + if let Err(error) = tokio::fs::create_dir_all(&view).await { + tracing::warn!(%error, ?view, "failed to create the sandbox org view dir"); + return false; + } + + let mut complete = true; + for volume in ORG_VOLUMES { + let target = mount_root.join(volume); + // Create the mount point BEFORE linking so the link resolves to an + // empty directory rather than dangling (see this module's doc). + if let Err(error) = tokio::fs::create_dir_all(&target).await { + tracing::warn!(%error, ?target, "failed to create the org mount point"); + complete = false; + continue; + } + if !link_volume(app_root, &view.join(volume), &target).await { + complete = false; + } + } + complete +} + +/// Point `link` at `target`, replacing whatever is there if it disagrees. +/// +/// The link is RELATIVE so the whole tree survives being moved (an app-support +/// directory can be migrated between machines or restored from a backup). +async fn link_volume(app_root: &Path, link: &Path, target: &Path) -> bool { + let Some(relative) = relative_link(app_root, link, target) else { + tracing::warn!(?link, ?target, "org view link is not under the app root"); + return false; + }; + // `read_link` (not `metadata`) so a link pointing at a not-yet-mounted + // path still compares equal instead of looking broken. + if tokio::fs::read_link(link).await.ok().as_deref() == Some(relative.as_path()) { + return true; + } + // Anything else occupying the path — a stale link, or a real directory + // left by an older layout — is replaced rather than worked around. + if tokio::fs::symlink_metadata(link).await.is_ok() { + let removed = match tokio::fs::remove_file(link).await { + Ok(()) => true, + Err(_) => tokio::fs::remove_dir_all(link).await.is_ok(), + }; + if !removed { + tracing::warn!(?link, "could not clear the existing org view entry"); + return false; + } + } + match tokio::fs::symlink(&relative, link).await { + Ok(()) => true, + Err(error) => { + tracing::warn!(%error, ?link, ?relative, "failed to link an org volume"); + false + } + } +} + +/// The `../`-relative path from `link`'s parent to `target`. +/// +/// Both are built from the same `app_root`, so the common prefix IS the app +/// root: climb out of the link's parent back to it, then descend to the +/// target. Expressed that way rather than by counting components, because the +/// component counts of two absolute paths say nothing about their relationship +/// — an earlier version subtracted depths and produced a link one level short. +fn relative_link(app_root: &Path, link: &Path, target: &Path) -> Option { + let from = link.parent()?.strip_prefix(app_root).ok()?; + let to = target.strip_prefix(app_root).ok()?; + let mut out = PathBuf::new(); + for _ in from.components() { + out.push(".."); + } + out.push(to); + Some(out) +} + +/// One path segment that cannot escape its parent. +fn safe_segment(segment: &str) -> Option<&str> { + if segment.is_empty() + || segment == "." + || segment == ".." + || segment.contains('/') + || segment.contains('\\') + { + return None; + } + Some(segment) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refuses_slugs_and_handles_that_would_escape() { + let root = Path::new("/app"); + for bad in ["", ".", "..", "a/b", "a\\b"] { + assert_eq!(org_mount_root(root, bad), None, "slug {bad:?}"); + assert_eq!(sandbox_org_dir(root, bad), None, "handle {bad:?}"); + } + assert_eq!( + org_mount_root(root, "acme"), + Some(PathBuf::from("/app/orgs/acme")) + ); + } + + #[tokio::test] + async fn view_links_every_volume_to_the_shared_org_mount() { + let root = tempfile::tempdir().expect("tempdir"); + let app_root = root.path(); + + assert!(ensure_org_view(app_root, "h1", "acme").await); + + for volume in ORG_VOLUMES { + let link = app_root.join("sandboxes/h1/org").join(volume); + let expected = app_root.join("orgs/acme").join(volume); + // Resolves to the shared mount point, and the mount point exists + // so the agent reads an empty dir rather than hitting ENOENT. + assert_eq!( + std::fs::canonicalize(&link).unwrap(), + std::fs::canonicalize(&expected).unwrap(), + "{volume} should resolve to the org mount" + ); + assert!( + std::fs::read_dir(&link).is_ok(), + "{volume} should be listable" + ); + } + } + + /// The whole reason the link is relative: an app-support directory that + /// gets moved or restored from a backup must not break every sandbox. + /// Also pins the exact shape — an earlier version was one `..` short. + #[tokio::test] + async fn links_are_relative_so_the_tree_survives_a_move() { + let root = tempfile::tempdir().expect("tempdir"); + assert!(ensure_org_view(root.path(), "h1", "acme").await); + + let target = std::fs::read_link(root.path().join("sandboxes/h1/org/home")).unwrap(); + assert_eq!(target, PathBuf::from("../../../orgs/acme/home")); + } + + #[tokio::test] + async fn ensure_is_idempotent_and_repairs_a_wrong_link() { + let root = tempfile::tempdir().expect("tempdir"); + let app_root = root.path(); + assert!(ensure_org_view(app_root, "h1", "acme").await); + assert!( + ensure_org_view(app_root, "h1", "acme").await, + "re-run is a no-op" + ); + + // Repoint one volume at the wrong org, as a stale view would be. + let link = app_root.join("sandboxes/h1/org/home"); + std::fs::remove_file(&link).unwrap(); + std::os::unix::fs::symlink("../../../orgs/other/home", &link).unwrap(); + + assert!(ensure_org_view(app_root, "h1", "acme").await); + assert_eq!( + std::fs::canonicalize(&link).unwrap(), + std::fs::canonicalize(app_root.join("orgs/acme/home")).unwrap(), + "a link pointing at another org must be repaired" + ); + } +} diff --git a/apps/native/crates/local-api/src/sandbox/persist.rs b/apps/native/crates/local-api/src/sandbox/persist.rs new file mode 100644 index 0000000000..e0e99d54be --- /dev/null +++ b/apps/native/crates/local-api/src/sandbox/persist.rs @@ -0,0 +1,729 @@ +//! Small on-disk persistence helpers for [`super::manager::SandboxManager`] — +//! survives a backend process restart so a per-handle git sandbox +//! (workdir + git history, already durable — see this crate's `log_store` +//! module doc for the analogous "files are the source of truth" argument +//! for retained logs) can be RESURRECTED instead of orphaned. The in-memory +//! `SandboxManager::sandboxes` map (and `active_handle`) are process-lifetime +//! only (see that module's doc comment) — a real sandbox's clone lives on +//! disk under `/sandboxes//repo` for as long as the user's +//! laptop keeps the directory. Two small sidecar files close the gap between +//! "the workdir survives" and "the process remembers what it's for": +//! +//! - `/sandboxes//sandbox-config.json` — the exact +//! [`super::manager::GitSandboxConfig`] `ensure()` was last called with for +//! this handle. `SandboxManager::compute_handle` is a ONE-WAY hash (no +//! reverse mapping) — without this file a restarted process has no way to +//! learn "what repo/branch does this handle even belong to", so an +//! explicit-handle request can only ever 404 after a restart, even though +//! the workdir it refers to is sitting right there on disk. See +//! the native desktop-runtime audit's investigation +//! into the sandbox-drawer restart bug for the empirical trace that led +//! here. +//! - `/sandboxes/.active-handle` — the last handle +//! `SandboxManager::set_active` pointed at, so a HEADERLESS resolve (the +//! preview iframe, and the drawer's Restart/Stop buttons before a handle +//! header is known/attached) can ALSO self-heal after a restart, not just +//! an explicit-handle request. +//! +//! Every write here is BEST-EFFORT at the request boundary: a failure (disk +//! full, permissions) is logged and swallowed rather than turning a successful +//! setup into an HTTP error. The files themselves are committed atomically via +//! private, same-directory temp files; a failed update preserves the previous +//! valid record. If the FIRST write fails, a future restart falls back to the +//! pre-existing "unknown handle" / "no active sandbox" behavior. + +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +use super::manager::{GitSandboxConfig, SandboxManager}; + +const ACTIVE_HANDLE_RELATIVE_PATH: &str = "sandboxes/.active-handle"; +const SIDECAR_FILE_NAME: &str = "sandbox-config.json"; +const MAX_TEMP_CREATE_ATTEMPTS: usize = 128; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +fn active_handle_write_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn create_private_parent_dir(path: &Path) -> io::Result<()> { + let Some(parent) = path.parent() else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "persistent file has no parent directory", + )); + }; + + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(parent) +} + +fn create_private_temp(path: &Path) -> io::Result<(PathBuf, File)> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "persistent file has no parent directory", + ) + })?; + let target_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid file name"))?; + + for _ in 0..MAX_TEMP_CREATE_ATTEMPTS { + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let temp_path = parent.join(format!( + ".{target_name}.{}.{}.tmp", + std::process::id(), + sequence + )); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + // The sidecar can contain a credential-bearing clone URL and the + // dev record authorizes signals. It must never exist briefly with + // umask-dependent group/world permissions. + options.mode(0o600); + } + match options.open(&temp_path) { + Ok(file) => return Ok((temp_path, file)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "could not reserve a unique atomic-write temp file", + )) +} + +#[cfg(unix)] +fn sync_parent_dir(path: &Path) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "persistent file has no parent directory", + ) + })?; + File::open(parent)?.sync_all() +} + +#[cfg(not(unix))] +fn sync_parent_dir(_path: &Path) -> io::Result<()> { + Ok(()) +} + +fn atomic_replace(path: &Path, contents: &[u8]) -> io::Result<()> { + atomic_replace_with_hook(path, contents, |_| Ok(())) +} + +/// Writes and fsyncs a private same-directory temp file, then atomically +/// replaces `path` and fsyncs its parent directory. The hook is a test seam +/// for the only meaningful crash boundary: after durable temp contents but +/// before rename. Any pre-rename failure removes the temp and leaves the old +/// valid destination byte-for-byte intact. +fn atomic_replace_with_hook(path: &Path, contents: &[u8], before_rename: F) -> io::Result<()> +where + F: FnOnce(&Path) -> io::Result<()>, +{ + create_private_parent_dir(path)?; + let (temp_path, mut temp_file) = create_private_temp(path)?; + + let result = (|| { + temp_file.write_all(contents)?; + temp_file.flush()?; + temp_file.sync_all()?; + drop(temp_file); + + before_rename(&temp_path)?; + std::fs::rename(&temp_path, path)?; + sync_parent_dir(path) + })(); + + if result.is_err() { + // Best effort: if rename already succeeded the temp path no longer + // exists. If anything failed before rename this prevents stale private + // temp files from accumulating beside the source of truth. + let _ = std::fs::remove_file(&temp_path); + } + result +} + +fn is_safe_handle_component(handle: &str) -> bool { + !handle.is_empty() + && handle.len() <= 255 + && handle != "." + && handle != ".." + && handle + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn sidecar_path(app_root: &Path, handle: &str) -> PathBuf { + app_root + .join("sandboxes") + .join(handle) + .join(SIDECAR_FILE_NAME) +} + +fn config_handle(cfg: &GitSandboxConfig) -> String { + let branch = cfg + .branch + .as_deref() + .filter(|branch| !branch.is_empty()) + .unwrap_or("main"); + SandboxManager::compute_handle(&cfg.virtual_mcp_id, branch) +} + +/// Best-effort write of `cfg` as `handle`'s resurrection sidecar. Called by +/// `SandboxManager::ensure` after a successful config apply. +pub(crate) fn write_sidecar(app_root: &Path, handle: &str, cfg: &GitSandboxConfig) { + if !is_safe_handle_component(handle) || config_handle(cfg) != handle { + tracing::warn!( + handle, + "sandbox: refusing to persist mismatched sidecar identity" + ); + return; + } + let path = sidecar_path(app_root, handle); + match serde_json::to_vec_pretty(cfg) { + Ok(bytes) => { + if let Err(err) = atomic_replace(&path, &bytes) { + tracing::warn!(handle, ?path, %err, "sandbox: failed to atomically write resurrection sidecar"); + } + } + Err(err) => { + tracing::warn!(handle, %err, "sandbox: failed to serialize resurrection sidecar"); + } + } +} + +/// Reads back `handle`'s sidecar, if any. `None` covers both "never written" +/// and "unreadable/corrupt" — either way there's nothing safe to resurrect +/// from, so the caller treats both the same as "genuinely unknown handle". +pub(crate) fn read_sidecar(app_root: &Path, handle: &str) -> Option { + if !is_safe_handle_component(handle) { + tracing::warn!( + handle, + "sandbox: refusing unsafe resurrection sidecar handle" + ); + return None; + } + let bytes = std::fs::read(sidecar_path(app_root, handle)).ok()?; + let cfg: GitSandboxConfig = serde_json::from_slice(&bytes).ok()?; + if config_handle(&cfg) != handle { + tracing::warn!( + handle, + "sandbox: resurrection sidecar identity does not match its path" + ); + return None; + } + Some(cfg) +} + +fn active_handle_path(app_root: &Path) -> PathBuf { + app_root.join(ACTIVE_HANDLE_RELATIVE_PATH) +} + +/// Best-effort write of the last-`set_active`-d handle. +pub(crate) fn write_active_handle(app_root: &Path, handle: &str) { + if !is_safe_handle_component(handle) { + tracing::warn!(handle, "sandbox: refusing to persist unsafe active handle"); + return; + } + if let Err(err) = write_active_handle_with_hook(app_root, handle, |_| Ok(())) { + let path = active_handle_path(app_root); + tracing::warn!(handle, ?path, %err, "sandbox: failed to atomically persist active handle"); + } +} + +fn write_active_handle_with_hook( + app_root: &Path, + handle: &str, + before_rename: F, +) -> io::Result<()> +where + F: FnOnce(&Path) -> io::Result<()>, +{ + if !is_safe_handle_component(handle) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "active handle is not a safe path component", + )); + } + // `set_active` is reachable from dispatch and preview focus concurrently. + // Serialize the complete temp-write + rename so disk always reflects one + // whole writer and hook/failure tests have deterministic critical-section + // semantics rather than merely relying on unique temp names. + let _guard = active_handle_write_lock(); + let path = active_handle_path(app_root); + atomic_replace_with_hook(&path, handle.as_bytes(), before_rename) +} + +/// Reads back the persisted active handle. `None` for a fresh `app_root` (or +/// an empty/unreadable file) — a genuinely fresh process, or one that only +/// ever used the plain non-git path. +pub(crate) fn read_active_handle(app_root: &Path) -> Option { + let raw = std::fs::read_to_string(active_handle_path(app_root)).ok()?; + if !is_safe_handle_component(&raw) { + return None; + } + Some(raw) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Barrier}; + + fn sample_cfg() -> GitSandboxConfig { + GitSandboxConfig { + org_slug: Some("acme".to_string()), + virtual_mcp_id: "vmcp-1".to_string(), + clone_url: "https://example.com/repo.git".to_string(), + branch: Some("main".to_string()), + runtime: Some("node".to_string()), + package_manager: Some("npm".to_string()), + package_manager_path: None, + git_user_name: None, + git_user_email: None, + } + } + + fn sample_handle() -> String { + config_handle(&sample_cfg()) + } + + pub(super) fn temp_files(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".tmp")) + }) + .collect() + } + + #[test] + fn sidecar_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let handle = sample_handle(); + assert!(read_sidecar(dir.path(), &handle).is_none()); + write_sidecar(dir.path(), &handle, &sample_cfg()); + let read_back = read_sidecar(dir.path(), &handle).expect("sidecar readable"); + assert_eq!(read_back.virtual_mcp_id, "vmcp-1"); + assert_eq!(read_back.clone_url, "https://example.com/repo.git"); + assert_eq!(read_back.branch.as_deref(), Some("main")); + assert_eq!(read_back.runtime.as_deref(), Some("node")); + assert_eq!(read_back.package_manager.as_deref(), Some("npm")); + } + + #[test] + fn missing_sidecar_is_none_not_an_error() { + let dir = tempfile::tempdir().unwrap(); + assert!(read_sidecar(dir.path(), "never-written").is_none()); + } + + #[test] + fn corrupt_sidecar_is_none_not_a_panic() { + let dir = tempfile::tempdir().unwrap(); + let handle = sample_handle(); + let path = sidecar_path(dir.path(), &handle); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"not json").unwrap(); + assert!(read_sidecar(dir.path(), &handle).is_none()); + } + + #[test] + fn two_handles_get_independent_sidecars() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg_b = sample_cfg(); + cfg_b.branch = Some("feature".to_string()); + let handle_a = sample_handle(); + let handle_b = config_handle(&cfg_b); + write_sidecar(dir.path(), &handle_a, &sample_cfg()); + write_sidecar(dir.path(), &handle_b, &cfg_b); + assert_eq!( + read_sidecar(dir.path(), &handle_a) + .unwrap() + .branch + .as_deref(), + Some("main") + ); + assert_eq!( + read_sidecar(dir.path(), &handle_b) + .unwrap() + .branch + .as_deref(), + Some("feature") + ); + } + + #[test] + fn valid_json_under_the_wrong_handle_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + let wrong_handle = SandboxManager::compute_handle("vmcp-other", "main"); + let path = sidecar_path(dir.path(), &wrong_handle); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, serde_json::to_vec(&sample_cfg()).unwrap()).unwrap(); + assert!(read_sidecar(dir.path(), &wrong_handle).is_none()); + } + + #[test] + fn unsafe_sidecar_handle_fails_closed_without_path_traversal() { + let dir = tempfile::tempdir().unwrap(); + assert!(read_sidecar(dir.path(), "../../outside").is_none()); + write_sidecar(dir.path(), "../../outside", &sample_cfg()); + assert!(!dir.path().join("outside").exists()); + } + + #[test] + fn pre_rename_failure_preserves_previous_valid_sidecar_and_cleans_temp() { + let dir = tempfile::tempdir().unwrap(); + let handle = sample_handle(); + let path = sidecar_path(dir.path(), &handle); + write_sidecar(dir.path(), &handle, &sample_cfg()); + + let mut replacement = sample_cfg(); + replacement.package_manager = Some("bun".to_string()); + let replacement = serde_json::to_vec_pretty(&replacement).unwrap(); + let error = atomic_replace_with_hook(&path, &replacement, |_| { + Err(io::Error::other("injected failure before rename")) + }) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::Other); + assert_eq!( + read_sidecar(dir.path(), &handle) + .unwrap() + .package_manager + .as_deref(), + Some("npm") + ); + assert!(temp_files(path.parent().unwrap()).is_empty()); + } + + #[test] + fn active_handle_round_trips() { + let dir = tempfile::tempdir().unwrap(); + assert!(read_active_handle(dir.path()).is_none()); + write_active_handle(dir.path(), "main-abc123"); + assert_eq!( + read_active_handle(dir.path()).as_deref(), + Some("main-abc123") + ); + } + + #[test] + fn active_handle_overwrites_the_previous_value() { + let dir = tempfile::tempdir().unwrap(); + write_active_handle(dir.path(), "first-handle"); + write_active_handle(dir.path(), "second-handle"); + assert_eq!( + read_active_handle(dir.path()).as_deref(), + Some("second-handle") + ); + } + + #[test] + fn pre_rename_failure_preserves_previous_active_handle() { + let dir = tempfile::tempdir().unwrap(); + write_active_handle(dir.path(), "stable-handle"); + + write_active_handle_with_hook(dir.path(), "replacement-handle", |_| { + Err(io::Error::other("injected failure before rename")) + }) + .unwrap_err(); + + assert_eq!( + read_active_handle(dir.path()).as_deref(), + Some("stable-handle") + ); + assert!(temp_files(active_handle_path(dir.path()).parent().unwrap()).is_empty()); + } + + #[test] + fn concurrent_active_handle_updates_are_serialized_and_never_torn() { + const WRITERS: usize = 16; + let dir = tempfile::tempdir().unwrap(); + let app_root = dir.path().to_path_buf(); + let barrier = Arc::new(Barrier::new(WRITERS)); + let in_hook = Arc::new(AtomicUsize::new(0)); + let max_in_hook = Arc::new(AtomicUsize::new(0)); + let handles: Vec = (0..WRITERS) + .map(|index| format!("branch-{index:02}-0123456789abcdef")) + .collect(); + + let threads: Vec<_> = handles + .iter() + .cloned() + .map(|handle| { + let app_root = app_root.clone(); + let barrier = Arc::clone(&barrier); + let in_hook = Arc::clone(&in_hook); + let max_in_hook = Arc::clone(&max_in_hook); + std::thread::spawn(move || { + barrier.wait(); + write_active_handle_with_hook(&app_root, &handle, |_| { + let now = in_hook.fetch_add(1, Ordering::SeqCst) + 1; + max_in_hook.fetch_max(now, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(2)); + in_hook.fetch_sub(1, Ordering::SeqCst); + Ok(()) + }) + .unwrap(); + }) + }) + .collect(); + for thread in threads { + thread.join().unwrap(); + } + + assert_eq!(max_in_hook.load(Ordering::SeqCst), 1); + let persisted = read_active_handle(&app_root).expect("one complete active handle"); + assert!(handles.contains(&persisted)); + assert!(temp_files(active_handle_path(&app_root).parent().unwrap()).is_empty()); + } + + #[test] + fn empty_active_handle_write_reads_back_as_none() { + let dir = tempfile::tempdir().unwrap(); + write_active_handle(dir.path(), ""); + assert!(read_active_handle(dir.path()).is_none()); + } + + #[cfg(unix)] + #[test] + fn persistent_files_are_private_from_creation() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let handle = sample_handle(); + write_sidecar(dir.path(), &handle, &sample_cfg()); + write_active_handle(dir.path(), &handle); + + for path in [ + sidecar_path(dir.path(), &handle), + active_handle_path(dir.path()), + ] { + let mode = std::fs::metadata(path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + } +} + +/// The dev-server child a sandbox last spawned — `pid == pgid` because every +/// task spawns via `build_command`'s `process_group(0)`. Persisted so a +/// later boot (or a reboot of the dev script) can REAP a still-running +/// child the previous process leaked: observed live 2026-07-22, a SIGKILLed +/// local-api left its Next.js dev server running, and Next 16's +/// single-instance lock then made every subsequent dev spawn for that repo +/// exit 1 ("Run `kill ` to stop it") — the sandbox could never boot +/// again until the orphan died. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub(crate) struct DevProcessIdentity { + pub pid: u32, + /// Kernel-observed process birth identity. On Unix this is the complete + /// `ps lstart` value paired with the executable name, not a wall-clock + /// timestamp sampled by local-api. + pub birth: String, + /// Executable name (`ps comm`), deliberately excluding argv so secrets + /// passed to a child process are never copied into the sidecar. + pub executable: String, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub(crate) struct DevProcessRecord { + pub pid: u32, + pub pgid: u32, + pub command: String, + pub started_at: u64, + /// Exact members observed in this process group while local-api owned it. + /// Older records deserialize empty and therefore cannot authorize a + /// signal; callers fail closed instead of trusting a naked recycled PGID. + #[serde(default)] + pub identities: Vec, +} + +fn dev_process_path(sandbox_root: &Path) -> std::path::PathBuf { + sandbox_root.join("dev-process.json") +} + +fn is_valid_dev_process_record(record: &DevProcessRecord) -> bool { + record.pid > 1 + && record.pgid == record.pid + && record.started_at > 0 + && !record.command.trim().is_empty() + && record.identities.iter().all(|identity| { + identity.pid > 1 + && !identity.birth.trim().is_empty() + && !identity.executable.trim().is_empty() + }) +} + +pub(crate) fn write_dev_process(sandbox_root: &Path, record: &DevProcessRecord) { + if !is_valid_dev_process_record(record) { + tracing::warn!( + pid = record.pid, + pgid = record.pgid, + "refusing to persist invalid dev-process record" + ); + return; + } + let path = dev_process_path(sandbox_root); + match serde_json::to_vec_pretty(record) { + Ok(bytes) => { + if let Err(err) = atomic_replace(&path, &bytes) { + tracing::warn!(path = %path.display(), error = %err, "failed to atomically persist dev-process record"); + } + } + Err(err) => tracing::warn!(error = %err, "failed to serialize dev-process record"), + } +} + +pub(crate) fn read_dev_process(sandbox_root: &Path) -> Option { + let bytes = std::fs::read(dev_process_path(sandbox_root)).ok()?; + match serde_json::from_slice(&bytes) { + Ok(rec) if is_valid_dev_process_record(&rec) => Some(rec), + Ok(rec) => { + tracing::warn!( + pid = rec.pid, + pgid = rec.pgid, + "invalid dev-process record; ignoring" + ); + None + } + Err(err) => { + tracing::warn!(error = %err, "unreadable dev-process record; ignoring"); + None + } + } +} + +pub(crate) fn clear_dev_process(sandbox_root: &Path) { + let path = dev_process_path(sandbox_root); + match std::fs::remove_file(&path) { + Ok(()) => { + if let Err(error) = sync_parent_dir(&path) { + tracing::warn!(path = %path.display(), %error, "failed to sync cleared dev-process record"); + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + tracing::warn!(path = %path.display(), %error, "failed to clear dev-process record"); + } + } +} + +#[cfg(test)] +mod dev_process_tests { + use super::*; + + #[test] + fn legacy_record_has_no_signal_authority() { + let record: DevProcessRecord = + serde_json::from_str(r#"{"pid":42,"pgid":42,"command":"bun dev","started_at":1}"#) + .unwrap(); + assert!(record.identities.is_empty()); + } + + #[test] + fn identities_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let record = DevProcessRecord { + pid: 42, + pgid: 42, + command: "bun dev".to_string(), + started_at: 1, + identities: vec![DevProcessIdentity { + pid: 43, + birth: "Wed Jul 22 11:00:00 2026".to_string(), + executable: "next-server".to_string(), + }], + }; + write_dev_process(dir.path(), &record); + assert_eq!(read_dev_process(dir.path()), Some(record)); + } + + #[test] + fn invalid_dev_process_record_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dev_process_path(dir.path()), + br#"{"pid":0,"pgid":0,"command":"bun dev","started_at":1}"#, + ) + .unwrap(); + assert!(read_dev_process(dir.path()).is_none()); + } + + #[test] + fn pre_rename_failure_preserves_previous_valid_dev_process_record() { + let dir = tempfile::tempdir().unwrap(); + let path = dev_process_path(dir.path()); + let original = DevProcessRecord { + pid: 42, + pgid: 42, + command: "bun dev".to_string(), + started_at: 1, + identities: vec![DevProcessIdentity { + pid: 42, + birth: "Wed Jul 22 11:00:00 2026".to_string(), + executable: "bun".to_string(), + }], + }; + write_dev_process(dir.path(), &original); + + let mut replacement = original.clone(); + replacement.identities[0].executable = "next-server".to_string(); + let replacement = serde_json::to_vec_pretty(&replacement).unwrap(); + atomic_replace_with_hook(&path, &replacement, |_| { + Err(io::Error::other("injected failure before rename")) + }) + .unwrap_err(); + + assert_eq!(read_dev_process(dir.path()), Some(original)); + assert!(super::tests::temp_files(dir.path()).is_empty()); + } + + #[cfg(unix)] + #[test] + fn dev_process_record_is_private() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let record = DevProcessRecord { + pid: 42, + pgid: 42, + command: "bun dev".to_string(), + started_at: 1, + identities: Vec::new(), + }; + write_dev_process(dir.path(), &record); + let mode = std::fs::metadata(dev_process_path(dir.path())) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } +} diff --git a/apps/native/crates/local-api/src/sandbox/registry.rs b/apps/native/crates/local-api/src/sandbox/registry.rs new file mode 100644 index 0000000000..2ab6baaec0 --- /dev/null +++ b/apps/native/crates/local-api/src/sandbox/registry.rs @@ -0,0 +1,1139 @@ +//! Durable control-plane index for native git sandboxes. +//! +//! Repository contents and retained terminal output live on the filesystem; +//! this SQLite database stores the small amount of meaning that cannot be +//! recovered from an opaque sandbox directory name: its config, paths, +//! desired/observed state, and which sandbox was last focused. The in-memory +//! `SandboxManager` map is deliberately only a cache of live Rust objects. + +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use rusqlite::{params, Connection, ErrorCode, OptionalExtension, TransactionBehavior}; + +use super::manager::{GitSandboxConfig, SandboxManager}; + +const DATABASE_FILE_NAME: &str = "sandboxes.sqlite"; +const CURRENT_SCHEMA_VERSION: i64 = 2; + +#[derive(Debug)] +enum RegistryOpenError { + Sqlite { + context: &'static str, + source: rusqlite::Error, + }, + Corrupt(String), + FutureVersion(i64), + UnsupportedVersion(i64), +} + +impl RegistryOpenError { + fn sqlite(context: &'static str, source: rusqlite::Error) -> Self { + Self::Sqlite { context, source } + } + + fn is_corruption(&self) -> bool { + match self { + Self::Corrupt(_) => true, + Self::Sqlite { source, .. } => matches!( + source.sqlite_error_code(), + Some(ErrorCode::DatabaseCorrupt | ErrorCode::NotADatabase) + ), + Self::FutureVersion(_) => false, + Self::UnsupportedVersion(_) => false, + } + } +} + +impl std::fmt::Display for RegistryOpenError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Sqlite { context, source } => write!(formatter, "{context}: {source}"), + Self::Corrupt(reason) => write!(formatter, "sandbox registry is corrupt: {reason}"), + Self::FutureVersion(version) => write!( + formatter, + "sandbox registry schema version {version} is newer than supported version {CURRENT_SCHEMA_VERSION}" + ), + Self::UnsupportedVersion(version) => { + write!(formatter, "unsupported sandbox registry schema version {version}") + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SandboxRecord { + pub handle: String, + pub config: GitSandboxConfig, + pub sandbox_path: PathBuf, + pub workdir_path: PathBuf, + pub desired_status: String, + pub observed_status: String, + /// Earliest safe setup step for the next process generation. + pub resume_step: String, + pub error: Option, + pub created_at: i64, + pub updated_at: i64, + pub last_seen_at: i64, +} + +pub(crate) struct SandboxRegistry { + app_root: PathBuf, + database_path: Option, + connection: Mutex, +} + +impl SandboxRegistry { + pub(crate) fn open(app_root: PathBuf) -> Result { + std::fs::create_dir_all(&app_root) + .map_err(|error| format!("failed to create native app root {app_root:?}: {error}"))?; + + // A handful of old unit helpers intentionally use the process-wide + // temp directory as an app root. Giving those managers isolated + // in-memory registries prevents otherwise unrelated parallel tests + // from sharing `/tmp/sandboxes.sqlite`; real app roots and unique + // tempdirs always exercise the durable file-backed database. + let database_path = app_root.join(DATABASE_FILE_NAME); + #[cfg(test)] + let use_memory_database = app_root == std::env::temp_dir(); + #[cfg(not(test))] + let use_memory_database = false; + + let (connection, persistent_path) = if use_memory_database { + let connection = open_configured_connection(None) + .map_err(|error| format!("failed to open native sandbox registry: {error}"))?; + (connection, None) + } else { + let connection = open_persistent_registry(&database_path)?; + (connection, Some(database_path)) + }; + + let registry = Self { + app_root, + database_path: persistent_path, + connection: Mutex::new(connection), + }; + registry.secure_database_files()?; + // The in-memory branch exists only for old unit fixtures that share + // the process temp root; importing arbitrary real `/tmp/sandboxes` + // sidecars would couple those otherwise isolated tests together. + if registry.database_path.is_some() { + registry.import_legacy_sidecars()?; + } + registry.reconcile_after_process_start()?; + Ok(registry) + } + + pub(crate) fn upsert_config( + &self, + handle: &str, + config: &GitSandboxConfig, + sandbox_path: &Path, + workdir_path: &Path, + ) -> Result<(), String> { + validate_identity(handle, config)?; + let now = now_unix_seconds(); + let config_json = serde_json::to_string(config) + .map_err(|error| format!("failed to serialize sandbox config: {error}"))?; + self.connection() + .execute( + r#" + INSERT INTO sandboxes ( + handle, virtual_mcp_id, clone_url, branch, config_json, + sandbox_path, workdir_path, desired_status, + observed_status, resume_step, error, + created_at, updated_at, last_seen_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'running', + 'provisioning', 'clone', NULL, ?8, ?8, ?8) + ON CONFLICT(handle) DO UPDATE SET + virtual_mcp_id = excluded.virtual_mcp_id, + clone_url = excluded.clone_url, + branch = excluded.branch, + config_json = excluded.config_json, + sandbox_path = excluded.sandbox_path, + workdir_path = excluded.workdir_path, + desired_status = 'running', + observed_status = 'provisioning', + error = NULL, + updated_at = excluded.updated_at, + last_seen_at = excluded.last_seen_at + "#, + params![ + handle, + config.virtual_mcp_id, + config.clone_url, + normalized_branch(config), + config_json, + sandbox_path.to_string_lossy(), + workdir_path.to_string_lossy(), + now, + ], + ) + .map_err(|error| format!("failed to persist sandbox {handle}: {error}"))?; + self.secure_database_files()?; + Ok(()) + } + + pub(crate) fn record(&self, handle: &str) -> Result, String> { + let raw = self + .connection() + .query_row( + r#" + SELECT handle, config_json, sandbox_path, workdir_path, + desired_status, observed_status, resume_step, + error, created_at, updated_at, last_seen_at + FROM sandboxes WHERE handle = ?1 + "#, + [handle], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, Option>(7)?, + row.get::<_, i64>(8)?, + row.get::<_, i64>(9)?, + row.get::<_, i64>(10)?, + )) + }, + ) + .optional() + .map_err(|error| format!("failed to read sandbox {handle}: {error}"))?; + let Some(( + handle, + config_json, + sandbox_path, + workdir_path, + desired_status, + observed_status, + resume_step, + error, + created_at, + updated_at, + last_seen_at, + )) = raw + else { + return Ok(None); + }; + let config = serde_json::from_str(&config_json) + .map_err(|error| format!("sandbox {handle} has an invalid stored config: {error}"))?; + Ok(Some(SandboxRecord { + handle, + config, + sandbox_path: PathBuf::from(sandbox_path), + workdir_path: PathBuf::from(workdir_path), + desired_status, + observed_status, + resume_step, + error, + created_at, + updated_at, + last_seen_at, + })) + } + + pub(crate) fn contains(&self, handle: &str) -> Result { + self.connection() + .query_row( + "SELECT EXISTS(SELECT 1 FROM sandboxes WHERE handle = ?1)", + [handle], + |row| row.get(0), + ) + .map_err(|error| format!("failed to look up sandbox {handle}: {error}")) + } + + pub(crate) fn set_active(&self, handle: &str) -> Result<(), String> { + if !self.contains(handle)? { + return Err(format!("unknown sandbox handle: {handle}")); + } + let now = now_unix_seconds(); + let mut connection = self.connection(); + // IMMEDIATE, not the DEFERRED default: this transaction reads and then + // writes, and a deferred transaction that upgrades to a write lock is + // returned SQLITE_BUSY *without* the connection's busy_timeout being + // applied — so a concurrent writer surfaces as a hard "database is + // locked" instead of waiting. Taking the write lock up front is what + // makes the timeout do its job. + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| format!("failed to begin active-sandbox update: {error}"))?; + transaction + .execute( + "INSERT INTO sandbox_metadata (key, value) VALUES ('active_handle', ?1) \ + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + [handle], + ) + .map_err(|error| format!("failed to persist active sandbox: {error}"))?; + transaction + .execute( + "UPDATE sandboxes SET last_seen_at = ?2, updated_at = ?2 WHERE handle = ?1", + params![handle, now], + ) + .map_err(|error| format!("failed to touch active sandbox: {error}"))?; + transaction + .commit() + .map_err(|error| format!("failed to commit active sandbox: {error}"))?; + self.secure_database_files() + } + + pub(crate) fn active_handle(&self) -> Result, String> { + self.connection() + .query_row( + "SELECT value FROM sandbox_metadata WHERE key = 'active_handle'", + [], + |row| row.get(0), + ) + .optional() + .map_err(|error| format!("failed to read active sandbox: {error}")) + } + + pub(crate) fn mark_state( + &self, + handle: &str, + desired_status: &str, + observed_status: &str, + error: Option<&str>, + ) -> Result<(), String> { + let changed = self + .connection() + .execute( + "UPDATE sandboxes SET desired_status = ?2, observed_status = ?3, \ + error = ?4, updated_at = ?5, last_seen_at = ?5 WHERE handle = ?1", + params![ + handle, + desired_status, + observed_status, + error, + now_unix_seconds() + ], + ) + .map_err(|db_error| format!("failed to update sandbox {handle} state: {db_error}"))?; + if changed == 0 { + return Err(format!("unknown sandbox handle: {handle}")); + } + self.secure_database_files() + } + + pub(crate) fn mark_observed( + &self, + handle: &str, + observed_status: &str, + error: Option<&str>, + resume_step: Option<&str>, + ) -> Result<(), String> { + let changed = self + .connection() + .execute( + "UPDATE sandboxes SET observed_status = ?2, error = ?3, \ + resume_step = COALESCE(?4, resume_step), updated_at = ?5, \ + last_seen_at = ?5 WHERE handle = ?1", + params![ + handle, + observed_status, + error, + resume_step, + now_unix_seconds() + ], + ) + .map_err(|db_error| { + format!("failed to update sandbox {handle} observation: {db_error}") + })?; + if changed == 0 { + return Err(format!("unknown sandbox handle: {handle}")); + } + self.secure_database_files() + } + + fn import_legacy_sidecars(&self) -> Result<(), String> { + let sandboxes_root = self.app_root.join("sandboxes"); + let Ok(entries) = std::fs::read_dir(&sandboxes_root) else { + return Ok(()); + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let Some(handle) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if self.contains(&handle)? { + continue; + } + let Some(config) = super::persist::read_sidecar(&self.app_root, &handle) else { + continue; + }; + let sandbox_path = entry.path(); + let workdir_path = sandbox_path.join("repo"); + self.upsert_config(&handle, &config, &sandbox_path, &workdir_path)?; + if is_valid_git_worktree(&workdir_path) { + self.mark_observed(&handle, "stopped", None, Some("install"))?; + } + self.mark_state(&handle, "running", "stopped", None)?; + } + + if self.active_handle()?.is_none() { + if let Some(handle) = super::persist::read_active_handle(&self.app_root) { + if self.contains(&handle)? { + self.set_active(&handle)?; + } + } + } + Ok(()) + } + + /// A new backend process owns none of the prior process handles. Retained + /// worktrees are still valid, but any formerly transitional/running state + /// is observed as stopped until `ensure` re-adopts and starts it. + fn reconcile_after_process_start(&self) -> Result<(), String> { + let root = self.app_root.join("sandboxes"); + let mut connection = self.connection(); + // IMMEDIATE for the same reason as `set_active` above — this one + // reads every row and then writes, and runs at EVERY registry open, + // so it is the one most likely to meet a concurrent writer. + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| format!("failed to begin sandbox reconciliation: {error}"))?; + { + let mut statement = transaction + .prepare("SELECT handle, sandbox_path, workdir_path FROM sandboxes") + .map_err(|error| format!("failed to prepare sandbox reconciliation: {error}"))?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + PathBuf::from(row.get::<_, String>(1)?), + PathBuf::from(row.get::<_, String>(2)?), + )) + }) + .map_err(|error| format!("failed to enumerate sandboxes: {error}"))?; + for row in rows { + let (handle, sandbox_path, workdir_path) = + row.map_err(|error| format!("failed to read sandbox row: {error}"))?; + let expected = root.join(&handle); + let valid = sandbox_path == expected + && workdir_path == expected.join("repo") + && sandbox_path.is_dir() + && is_valid_git_worktree(&workdir_path); + let (observed, error): (&str, Option<&str>) = if valid { + ("stopped", None) + } else { + ( + "absent", + Some( + "sandbox workdir is missing, not a valid git worktree, or outside the managed root", + ), + ) + }; + transaction + .execute( + "UPDATE sandboxes SET observed_status = ?2, error = ?3, updated_at = ?4 \ + WHERE handle = ?1", + params![handle, observed, error, now_unix_seconds()], + ) + .map_err(|db_error| { + format!("failed to reconcile sandbox {handle}: {db_error}") + })?; + } + } + transaction + .execute( + "DELETE FROM sandbox_metadata WHERE key = 'active_handle' AND NOT EXISTS (\ + SELECT 1 FROM sandboxes WHERE handle = sandbox_metadata.value \ + AND observed_status != 'absent')", + [], + ) + .map_err(|error| format!("failed to reconcile active sandbox: {error}"))?; + transaction + .commit() + .map_err(|error| format!("failed to commit sandbox reconciliation: {error}"))?; + self.secure_database_files() + } + + fn connection(&self) -> MutexGuard<'_, Connection> { + self.connection + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn secure_database_files(&self) -> Result<(), String> { + let Some(path) = &self.database_path else { + return Ok(()); + }; + set_private_permissions(path)?; + set_private_permissions(&PathBuf::from(format!("{}-wal", path.display())))?; + set_private_permissions(&PathBuf::from(format!("{}-shm", path.display()))) + } +} + +fn open_persistent_registry(path: &Path) -> Result { + // Permission and filesystem failures happen before SQLite sees the file. + // They are operational errors, not evidence that user data is corrupt. + prepare_private_database_file(path)?; + + match open_configured_connection(Some(path)) { + Ok(connection) => Ok(connection), + Err(error) if error.is_corruption() => { + let reason = error.to_string(); + let quarantined = quarantine_database_files(path)?; + tracing::warn!( + database = ?path, + ?quarantined, + %reason, + "sandbox: quarantined corrupt registry and rebuilding from sidecars" + ); + prepare_private_database_file(path)?; + open_configured_connection(Some(path)).map_err(|rebuild_error| { + format!( + "failed to rebuild native sandbox registry after quarantining corruption ({reason}): {rebuild_error}" + ) + }) + } + Err(error) => Err(format!("failed to open native sandbox registry: {error}")), + } +} + +fn open_configured_connection(path: Option<&Path>) -> Result { + let mut connection = match path { + Some(path) => Connection::open(path), + None => Connection::open_in_memory(), + } + .map_err(|error| RegistryOpenError::sqlite("failed to open database", error))?; + + connection + .busy_timeout(std::time::Duration::from_secs(5)) + .map_err(|error| RegistryOpenError::sqlite("failed to configure busy timeout", error))?; + + // Read the version before enabling WAL or running any DDL. In particular, + // opening a DB from a newer app build must be a read-only rejection rather + // than silently rewriting its header or creating journal companions. + let schema_version: i64 = connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .map_err(|error| RegistryOpenError::sqlite("failed to read schema version", error))?; + if schema_version > CURRENT_SCHEMA_VERSION { + return Err(RegistryOpenError::FutureVersion(schema_version)); + } + if schema_version < 0 { + return Err(RegistryOpenError::UnsupportedVersion(schema_version)); + } + + if path.is_some() { + connection + .pragma_update(None, "journal_mode", "WAL") + .map_err(|error| RegistryOpenError::sqlite("failed to enable WAL", error))?; + } + connection + .pragma_update(None, "foreign_keys", "ON") + .map_err(|error| RegistryOpenError::sqlite("failed to enable foreign keys", error))?; + + migrate_schema(&mut connection, schema_version)?; + + let quick_check: String = connection + .query_row("PRAGMA quick_check(1)", [], |row| row.get(0)) + .map_err(|error| RegistryOpenError::sqlite("failed to check database integrity", error))?; + if !quick_check.eq_ignore_ascii_case("ok") { + return Err(RegistryOpenError::Corrupt(quick_check)); + } + + Ok(connection) +} + +fn migrate_schema( + connection: &mut Connection, + schema_version: i64, +) -> Result<(), RegistryOpenError> { + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(|error| RegistryOpenError::sqlite("failed to begin schema migration", error))?; + + match schema_version { + 0 => { + transaction + .execute_batch( + r#" + CREATE TABLE IF NOT EXISTS sandboxes ( + handle TEXT PRIMARY KEY NOT NULL, + virtual_mcp_id TEXT NOT NULL, + clone_url TEXT NOT NULL, + branch TEXT NOT NULL, + config_json TEXT NOT NULL, + sandbox_path TEXT NOT NULL, + workdir_path TEXT NOT NULL, + desired_status TEXT NOT NULL, + observed_status TEXT NOT NULL, + resume_step TEXT NOT NULL DEFAULT 'clone', + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS sandbox_metadata ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ); + "#, + ) + .map_err(|error| RegistryOpenError::sqlite("failed to create schema", error))?; + + // A version-zero file can predate version tracking while already + // containing the v1 table. CREATE IF NOT EXISTS cannot add the v2 + // column, so make that upgrade explicit inside the same transaction. + add_resume_step_if_missing(&transaction)?; + } + 1 => add_resume_step_if_missing(&transaction)?, + CURRENT_SCHEMA_VERSION => {} + version => return Err(RegistryOpenError::UnsupportedVersion(version)), + } + + validate_schema(&transaction)?; + transaction + .pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION) + .map_err(|error| RegistryOpenError::sqlite("failed to record schema version", error))?; + transaction + .commit() + .map_err(|error| RegistryOpenError::sqlite("failed to commit schema migration", error)) +} + +fn add_resume_step_if_missing(connection: &Connection) -> Result<(), RegistryOpenError> { + let has_resume_step: bool = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM pragma_table_info('sandboxes') WHERE name = 'resume_step')", + [], + |row| row.get(0), + ) + .map_err(|error| RegistryOpenError::sqlite("failed to inspect v1 schema", error))?; + if !has_resume_step { + connection + .execute( + "ALTER TABLE sandboxes ADD COLUMN resume_step TEXT NOT NULL DEFAULT 'clone'", + [], + ) + .map_err(|error| RegistryOpenError::sqlite("failed to migrate schema to v2", error))?; + } + Ok(()) +} + +fn validate_schema(connection: &Connection) -> Result<(), RegistryOpenError> { + connection + .prepare( + r#" + SELECT handle, virtual_mcp_id, clone_url, branch, config_json, + sandbox_path, workdir_path, desired_status, observed_status, + resume_step, error, created_at, updated_at, last_seen_at + FROM sandboxes LIMIT 0 + "#, + ) + .map_err(|error| RegistryOpenError::sqlite("sandbox table does not match v2", error))?; + connection + .prepare("SELECT key, value FROM sandbox_metadata LIMIT 0") + .map_err(|error| RegistryOpenError::sqlite("metadata table does not match v2", error))?; + Ok(()) +} + +fn sqlite_companion_path(path: &Path, suffix: &str) -> PathBuf { + let mut value = path.as_os_str().to_os_string(); + value.push(suffix); + PathBuf::from(value) +} + +/// Moves a corrupt SQLite database and its live journal companions aside +/// without deleting anything. Each rename is atomic; if a later companion +/// fails to move, earlier moves are rolled back before startup returns an +/// error, so callers never intentionally proceed with a split set. +fn quarantine_database_files(path: &Path) -> Result, String> { + let sources = [ + path.to_path_buf(), + sqlite_companion_path(path, "-wal"), + sqlite_companion_path(path, "-shm"), + ]; + let tag = format!( + "corrupt-{}-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + std::process::id() + ); + let mut moves = Vec::new(); + for source in sources { + match std::fs::symlink_metadata(&source) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(format!( + "failed to inspect corrupt sandbox registry file {source:?}: {error}" + )); + } + } + set_private_permissions(&source)?; + let mut destination = source.as_os_str().to_os_string(); + destination.push(format!(".{tag}")); + let destination = PathBuf::from(destination); + if destination.exists() { + return Err(format!( + "refusing to overwrite existing sandbox registry quarantine {destination:?}" + )); + } + moves.push((source, destination)); + } + + let mut moved = Vec::new(); + for (source, destination) in &moves { + if let Err(error) = std::fs::rename(source, destination) { + let mut rollback_errors = Vec::new(); + for (prior_source, prior_destination) in moved.iter().rev() { + if let Err(rollback_error) = std::fs::rename(prior_destination, prior_source) { + rollback_errors.push(format!( + "{prior_destination:?} -> {prior_source:?}: {rollback_error}" + )); + } + } + let rollback = if rollback_errors.is_empty() { + String::new() + } else { + format!("; rollback also failed: {}", rollback_errors.join(", ")) + }; + return Err(format!( + "failed to quarantine corrupt sandbox registry {source:?}: {error}{rollback}" + )); + } + moved.push((source.clone(), destination.clone())); + } + + sync_database_directory(path)?; + Ok(moved + .into_iter() + .map(|(_, destination)| destination) + .collect()) +} + +#[cfg(unix)] +fn sync_database_directory(path: &Path) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("sandbox registry path has no parent: {path:?}"))?; + std::fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("failed to sync sandbox registry directory {parent:?}: {error}")) +} + +#[cfg(not(unix))] +fn sync_database_directory(_path: &Path) -> Result<(), String> { + Ok(()) +} + +fn prepare_private_database_file(path: &Path) -> Result<(), String> { + let mut options = std::fs::OpenOptions::new(); + options.create(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options + .open(path) + .map_err(|error| format!("failed to create private sandbox registry {path:?}: {error}"))?; + set_private_permissions(path) +} + +fn set_private_permissions(path: &Path) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err( + |error| format!("failed to make sandbox registry file private {path:?}: {error}"), + )?; + } + Ok(()) +} + +fn normalized_branch(config: &GitSandboxConfig) -> &str { + config + .branch + .as_deref() + .filter(|branch| !branch.is_empty()) + .unwrap_or("main") +} + +/// Validates the on-disk shape Git itself uses for a checkout without running +/// a blocking subprocess during async application startup. A normal clone has +/// a `.git` directory; a linked worktree has a small `.git` file pointing at +/// the canonical repository's `.git/worktrees/` administrative dir. +fn is_valid_git_worktree(workdir: &Path) -> bool { + if !workdir.is_dir() { + return false; + } + let dot_git = workdir.join(".git"); + let Ok(metadata) = std::fs::metadata(&dot_git) else { + return false; + }; + if metadata.is_dir() { + return dot_git.join("HEAD").is_file(); + } + if !metadata.is_file() || metadata.len() > 16 * 1024 { + return false; + } + + let Ok(contents) = std::fs::read_to_string(&dot_git) else { + return false; + }; + let Some(gitdir) = contents.trim().strip_prefix("gitdir:") else { + return false; + }; + let gitdir = gitdir.trim(); + if gitdir.is_empty() || gitdir.lines().count() != 1 { + return false; + } + let gitdir = PathBuf::from(gitdir); + let gitdir = if gitdir.is_absolute() { + gitdir + } else { + workdir.join(gitdir) + }; + gitdir.is_dir() && gitdir.join("HEAD").is_file() +} + +fn validate_identity(handle: &str, config: &GitSandboxConfig) -> Result<(), String> { + if config.virtual_mcp_id.trim().is_empty() { + return Err("virtualMcpId is required".to_string()); + } + if config.clone_url.trim().is_empty() { + return Err("repo.cloneUrl is required".to_string()); + } + let expected = + SandboxManager::compute_handle(&config.virtual_mcp_id, normalized_branch(config)); + if handle != expected { + return Err(format!( + "sandbox handle/config mismatch: expected {expected}, got {handle}" + )); + } + Ok(()) +} + +fn now_unix_seconds() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> GitSandboxConfig { + GitSandboxConfig { + org_slug: Some("acme".to_string()), + virtual_mcp_id: "vmcp-registry".to_string(), + clone_url: "https://example.com/acme/repo.git".to_string(), + branch: Some("feature/registry".to_string()), + runtime: Some("bun".to_string()), + package_manager: Some("bun".to_string()), + package_manager_path: Some("packages/app".to_string()), + git_user_name: Some("Test User".to_string()), + git_user_email: Some("test@example.com".to_string()), + } + } + + fn create_git_checkout(workdir: &Path) { + std::fs::create_dir_all(workdir.join(".git")).unwrap(); + std::fs::write(workdir.join(".git/HEAD"), b"ref: refs/heads/main\n").unwrap(); + } + + fn create_v1_schema(connection: &Connection) { + connection + .execute_batch( + r#" + CREATE TABLE sandboxes ( + handle TEXT PRIMARY KEY NOT NULL, + virtual_mcp_id TEXT NOT NULL, + clone_url TEXT NOT NULL, + branch TEXT NOT NULL, + config_json TEXT NOT NULL, + sandbox_path TEXT NOT NULL, + workdir_path TEXT NOT NULL, + desired_status TEXT NOT NULL, + observed_status TEXT NOT NULL, + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL + ); + CREATE TABLE sandbox_metadata ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ); + PRAGMA user_version = 1; + "#, + ) + .unwrap(); + } + + #[test] + fn registry_is_wal_and_survives_reopen() { + let root = tempfile::tempdir().unwrap(); + let cfg = config(); + let handle = + SandboxManager::compute_handle(&cfg.virtual_mcp_id, cfg.branch.as_deref().unwrap()); + let sandbox_path = root.path().join("sandboxes").join(&handle); + let workdir_path = sandbox_path.join("repo"); + create_git_checkout(&workdir_path); + + let registry = SandboxRegistry::open(root.path().to_path_buf()).unwrap(); + registry + .upsert_config(&handle, &cfg, &sandbox_path, &workdir_path) + .unwrap(); + registry.set_active(&handle).unwrap(); + registry + .mark_state(&handle, "running", "running", None) + .unwrap(); + drop(registry); + + let reopened = SandboxRegistry::open(root.path().to_path_buf()).unwrap(); + let record = reopened.record(&handle).unwrap().unwrap(); + assert_eq!(record.config, cfg); + assert_eq!(record.desired_status, "running"); + assert_eq!(record.observed_status, "stopped"); + assert_eq!( + reopened.active_handle().unwrap().as_deref(), + Some(handle.as_str()) + ); + + let connection = Connection::open(root.path().join(DATABASE_FILE_NAME)).unwrap(); + let journal_mode: String = connection + .pragma_query_value(None, "journal_mode", |row| row.get(0)) + .unwrap(); + assert_eq!(journal_mode.to_ascii_lowercase(), "wal"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(root.path().join(DATABASE_FILE_NAME)) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + } + + #[test] + fn imports_sidecar_and_active_pointer_once() { + let root = tempfile::tempdir().unwrap(); + let cfg = config(); + let handle = + SandboxManager::compute_handle(&cfg.virtual_mcp_id, cfg.branch.as_deref().unwrap()); + create_git_checkout(&root.path().join("sandboxes").join(&handle).join("repo")); + super::super::persist::write_sidecar(root.path(), &handle, &cfg); + super::super::persist::write_active_handle(root.path(), &handle); + + let registry = SandboxRegistry::open(root.path().to_path_buf()).unwrap(); + assert_eq!(registry.record(&handle).unwrap().unwrap().config, cfg); + assert_eq!( + registry.active_handle().unwrap().as_deref(), + Some(handle.as_str()) + ); + } + + #[test] + fn refuses_to_activate_an_unknown_handle() { + let root = tempfile::tempdir().unwrap(); + let registry = SandboxRegistry::open(root.path().to_path_buf()).unwrap(); + assert_eq!( + registry.set_active("phantom").unwrap_err(), + "unknown sandbox handle: phantom" + ); + } + + #[test] + fn reconciliation_clears_active_when_its_workdir_is_missing() { + let root = tempfile::tempdir().unwrap(); + let cfg = config(); + let handle = + SandboxManager::compute_handle(&cfg.virtual_mcp_id, cfg.branch.as_deref().unwrap()); + let sandbox_path = root.path().join("sandboxes").join(&handle); + let workdir_path = sandbox_path.join("repo"); + create_git_checkout(&workdir_path); + let registry = SandboxRegistry::open(root.path().to_path_buf()).unwrap(); + registry + .upsert_config(&handle, &cfg, &sandbox_path, &workdir_path) + .unwrap(); + registry.set_active(&handle).unwrap(); + drop(registry); + std::fs::remove_dir_all(&sandbox_path).unwrap(); + + let reopened = SandboxRegistry::open(root.path().to_path_buf()).unwrap(); + assert!(reopened.active_handle().unwrap().is_none()); + assert_eq!( + reopened.record(&handle).unwrap().unwrap().observed_status, + "absent" + ); + } + + #[test] + fn migrates_v1_to_v2_transactionally_without_losing_records() { + let root = tempfile::tempdir().unwrap(); + let cfg = config(); + let handle = + SandboxManager::compute_handle(&cfg.virtual_mcp_id, cfg.branch.as_deref().unwrap()); + let sandbox_path = root.path().join("sandboxes").join(&handle); + let workdir_path = sandbox_path.join("repo"); + create_git_checkout(&workdir_path); + + let database_path = root.path().join(DATABASE_FILE_NAME); + let connection = Connection::open(&database_path).unwrap(); + create_v1_schema(&connection); + connection + .execute( + r#" + INSERT INTO sandboxes ( + handle, virtual_mcp_id, clone_url, branch, config_json, + sandbox_path, workdir_path, desired_status, observed_status, + error, created_at, updated_at, last_seen_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'running', 'running', + NULL, 1, 2, 3) + "#, + params![ + handle, + cfg.virtual_mcp_id, + cfg.clone_url, + normalized_branch(&cfg), + serde_json::to_string(&cfg).unwrap(), + sandbox_path.to_string_lossy(), + workdir_path.to_string_lossy(), + ], + ) + .unwrap(); + drop(connection); + + let registry = SandboxRegistry::open(root.path().to_path_buf()).unwrap(); + let record = registry.record(&handle).unwrap().unwrap(); + assert_eq!(record.config, cfg); + assert_eq!(record.resume_step, "clone"); + assert_eq!(record.observed_status, "stopped"); + + let connection = registry.connection(); + let version: i64 = connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, CURRENT_SCHEMA_VERSION); + let has_resume_step: bool = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM pragma_table_info('sandboxes') WHERE name = 'resume_step')", + [], + |row| row.get(0), + ) + .unwrap(); + assert!(has_resume_step); + } + + #[test] + fn rejects_future_schema_without_quarantining_or_mutating_it() { + let root = tempfile::tempdir().unwrap(); + let database_path = root.path().join(DATABASE_FILE_NAME); + let connection = Connection::open(&database_path).unwrap(); + connection.pragma_update(None, "user_version", 3).unwrap(); + drop(connection); + + let error = SandboxRegistry::open(root.path().to_path_buf()) + .err() + .expect("future schema must fail closed"); + assert!(error.contains("newer than supported"), "{error}"); + + let connection = Connection::open(&database_path).unwrap(); + let version: i64 = connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, 3); + let has_quarantine = std::fs::read_dir(root.path()).unwrap().any(|entry| { + entry + .ok() + .and_then(|entry| entry.file_name().into_string().ok()) + .is_some_and(|name| name.contains(".corrupt-")) + }); + assert!(!has_quarantine); + } + + #[test] + fn quarantines_corruption_and_rebuilds_from_valid_sidecars() { + let root = tempfile::tempdir().unwrap(); + let cfg = config(); + let handle = + SandboxManager::compute_handle(&cfg.virtual_mcp_id, cfg.branch.as_deref().unwrap()); + let workdir_path = root.path().join("sandboxes").join(&handle).join("repo"); + create_git_checkout(&workdir_path); + super::super::persist::write_sidecar(root.path(), &handle, &cfg); + super::super::persist::write_active_handle(root.path(), &handle); + + let database_path = root.path().join(DATABASE_FILE_NAME); + let corrupt_bytes = b"this is not a sqlite database"; + std::fs::write(&database_path, corrupt_bytes).unwrap(); + + let registry = SandboxRegistry::open(root.path().to_path_buf()).unwrap(); + assert_eq!(registry.record(&handle).unwrap().unwrap().config, cfg); + assert_eq!( + registry.active_handle().unwrap().as_deref(), + Some(handle.as_str()) + ); + + let quarantine = std::fs::read_dir(root.path()) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .find(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("sandboxes.sqlite.corrupt-")) + }) + .expect("corrupt database must be retained beside its replacement"); + assert_eq!(std::fs::read(quarantine).unwrap(), corrupt_bytes); + + let connection = registry.connection(); + let version: i64 = connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, CURRENT_SCHEMA_VERSION); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&database_path) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + } + + #[test] + fn recognizes_normal_checkouts_and_linked_worktrees() { + let root = tempfile::tempdir().unwrap(); + let checkout = root.path().join("checkout"); + create_git_checkout(&checkout); + assert!(is_valid_git_worktree(&checkout)); + + let linked = root.path().join("linked"); + std::fs::create_dir_all(&linked).unwrap(); + let admin = root.path().join("canonical.git/worktrees/linked"); + std::fs::create_dir_all(&admin).unwrap(); + std::fs::write(admin.join("HEAD"), b"ref: refs/heads/thread\n").unwrap(); + std::fs::write( + linked.join(".git"), + format!("gitdir: {}\n", admin.display()), + ) + .unwrap(); + assert!(is_valid_git_worktree(&linked)); + + std::fs::remove_file(admin.join("HEAD")).unwrap(); + assert!(!is_valid_git_worktree(&linked)); + } +} diff --git a/apps/native/crates/local-api/src/sandbox/repo_store.rs b/apps/native/crates/local-api/src/sandbox/repo_store.rs new file mode 100644 index 0000000000..5182120227 --- /dev/null +++ b/apps/native/crates/local-api/src/sandbox/repo_store.rs @@ -0,0 +1,438 @@ +//! The shared canonical repo store that backs per-sandbox git worktrees. +//! +//! Every sandbox used to be its own full `git clone`, so N threads on one +//! repository paid N copies of the object store. Instead there is now ONE bare +//! clone per upstream repository under `/repos/…`, and each sandbox's +//! `/sandboxes//repo` is a `git worktree` of it. +//! +//! ## Why the key is the clone URL, not the Studio org +//! +//! [`canonical_repo_dir`] keys on the normalized remote (host + owner + repo), +//! deliberately NOT on the org that reached it: the same repository opened from +//! two orgs must not clone twice — which is the entire point of the store — and +//! an org rename must not orphan it. This is the one place that decision lives; +//! changing the layout means changing this function and nothing else. + +use std::path::{Path, PathBuf}; + +/// `/repos///` for a remote URL, or `None` when +/// the URL isn't one we can key safely. +/// +/// Accepts the forms git itself does — `https://host/owner/repo(.git)`, +/// `ssh://git@host/owner/repo`, and the scp-style `git@host:owner/repo` — and +/// normalizes them to the SAME directory, so two sandboxes that name one +/// repository differently still share objects. Case is folded on the host +/// (DNS is case-insensitive) but preserved on the path, which is not +/// universally so. +/// +/// Returns `None` rather than guessing for anything that would escape the store +/// or collapse two repositories onto one path: an empty owner or repo, or a +/// segment that is `.`/`..` or contains a separator. +pub fn canonical_repo_dir(app_root: &Path, clone_url: &str) -> Option { + let (host, path) = split_remote(clone_url.trim())?; + let mut segments = path + .trim_matches('/') + .split('/') + .filter(|segment| !segment.is_empty()); + + let owner = segments.next()?; + // Everything before the final segment is the owner namespace — GitLab-style + // subgroups (`group/subgroup/repo`) must not collapse onto one directory. + let rest: Vec<&str> = segments.collect(); + let (repo, middle) = rest.split_last()?; + let repo = repo.strip_suffix(".git").unwrap_or(repo); + + let mut dir = app_root.join("repos").join(sanitize(&host.to_lowercase())?); + dir.push(sanitize(owner)?); + for segment in middle { + dir.push(sanitize(segment)?); + } + dir.push(sanitize(repo)?); + Some(dir) +} + +/// `(host, path)` for the remote spellings git accepts. +fn split_remote(url: &str) -> Option<(String, String)> { + if let Some((scheme, rest)) = url.split_once("://") { + // Strip any `user[:password]@` prefix before the host. + let rest = rest.rsplit_once('@').map_or(rest, |(_, after)| after); + let (host, path) = rest.split_once('/')?; + if host.is_empty() { + // `file:///abs/path` has no host. Namespace it under a reserved + // pseudo-host and keep the WHOLE path, so two local repos sharing + // a directory name can't alias onto one canonical store. + if scheme.eq_ignore_ascii_case("file") { + return Some(("local".to_string(), path.to_string())); + } + return None; + } + return Some((host.to_string(), path.to_string())); + } + // scp-style: `git@host:owner/repo`. The colon separates host from path, so + // a `:` that is really a port (`host:22/owner/repo`) is not this form. + let (before, path) = url.split_once(':')?; + if path.starts_with('/') || path.chars().next()?.is_ascii_digit() { + return None; + } + let host = before.rsplit_once('@').map_or(before, |(_, after)| after); + Some((host.to_string(), path.to_string())) +} + +/// Drop a canonical repo's registrations for worktrees whose directories are +/// gone. +/// +/// Removing a sandbox's workdir with `remove_dir_all` leaves the canonical repo +/// still listing it in `git worktree list`, and git then refuses to re-add a +/// worktree at that same path ("already registered"). Pruning after any such +/// removal is what keeps re-provisioning a handle working. +/// +/// Best-effort by design: a canonical repo that doesn't exist yet, or a `git` +/// that fails, is not a reason to fail the caller's operation — the worst case +/// is a stale entry that the next prune clears. +pub async fn prune_worktrees(app_root: &Path, clone_url: &str) { + let Some(canonical) = canonical_repo_dir(app_root, clone_url) else { + return; + }; + prune_repo(&canonical).await; +} + +/// Prune every canonical repo in the store. +/// +/// Runs once at boot because a sandbox directory can vanish between runs — a +/// user clearing Application Support, an interrupted move — leaving git still +/// listing it and refusing to re-add a worktree at that path, which strands +/// the handle until something prunes. +/// +/// This cannot lose work: `git worktree prune` only drops registrations whose +/// directory is already gone, and never touches a worktree still on disk. +pub async fn prune_all(app_root: &Path) { + let root = app_root.join("repos"); + // Repos nest at `repos//[/…]/`, so the depth + // is not fixed; descend until a directory looks like the bare repo itself. + let mut pending = vec![root]; + while let Some(dir) = pending.pop() { + let Ok(mut entries) = tokio::fs::read_dir(&dir).await else { + continue; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if !entry.file_type().await.is_ok_and(|kind| kind.is_dir()) { + continue; + } + if is_bare_repo(&path).await { + prune_repo(&path).await; + } else { + pending.push(path); + } + } + } +} + +/// `git worktree prune` one canonical repo, best-effort. +/// +/// A repo that doesn't exist yet, or a `git` that fails, is never a reason to +/// fail the caller's operation — the worst case is a stale entry the next +/// prune clears. +async fn prune_repo(canonical: &Path) { + if !is_bare_repo(canonical).await { + return; + } + let canonical_str = canonical.to_string_lossy().into_owned(); + let result = tokio::process::Command::new("git") + .args(["-C", &canonical_str, "worktree", "prune"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true) + .status() + .await; + if let Err(error) = result { + tracing::warn!(%error, repo = %canonical_str, "git worktree prune failed"); + } +} + +/// Whether a directory is one of the store's bare clones. +/// +/// A bare repo keeps `HEAD` at its root, where a namespace directory +/// (`repos/github.com`) has only subdirectories — that is the whole +/// distinction the walk needs. +async fn is_bare_repo(dir: &Path) -> bool { + tokio::fs::metadata(dir.join("HEAD")) + .await + .is_ok_and(|meta| meta.is_file()) +} + +/// A path segment that cannot escape the store or name a parent. +fn sanitize(segment: &str) -> Option<&str> { + if segment.is_empty() + || segment == "." + || segment == ".." + || segment.contains('/') + || segment.contains('\\') + { + return None; + } + Some(segment) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dir(url: &str) -> Option { + canonical_repo_dir(Path::new("/app"), url).map(|p| p.to_string_lossy().into_owned()) + } + + #[test] + fn every_spelling_of_one_repo_maps_to_the_same_directory() { + // The whole point of the store: name it differently, share objects. + let expected = Some("/app/repos/github.com/acme/site".to_string()); + for url in [ + "https://github.com/acme/site.git", + "https://github.com/acme/site", + "https://GitHub.com/acme/site.git", + "https://user:token@github.com/acme/site.git", + "ssh://git@github.com/acme/site.git", + "git@github.com:acme/site.git", + " https://github.com/acme/site.git ", + ] { + assert_eq!(dir(url), expected, "{url}"); + } + } + + #[test] + fn distinct_repos_never_collapse_onto_one_directory() { + assert_ne!( + dir("https://github.com/acme/site"), + dir("https://github.com/other/site") + ); + assert_ne!( + dir("https://github.com/acme/site"), + dir("https://gitlab.com/acme/site") + ); + // Case is preserved on the path: these are two repos on most forges. + assert_ne!( + dir("https://github.com/acme/Site"), + dir("https://github.com/acme/site") + ); + } + + #[test] + fn subgroups_keep_their_namespace() { + // Collapsing `group/sub/repo` to `group/repo` would alias two repos. + assert_eq!( + dir("https://gitlab.com/group/sub/repo.git"), + Some("/app/repos/gitlab.com/group/sub/repo".to_string()) + ); + } + + #[test] + fn local_file_remotes_keep_their_whole_path() { + // `file://` has no host, but local clones must still share a canonical + // store — and two repos whose directories are both named `site` must + // not collapse onto one. + assert_eq!( + dir("file:///Users/dev/work/site"), + Some("/app/repos/local/Users/dev/work/site".to_string()) + ); + assert_ne!( + dir("file:///Users/dev/a/site"), + dir("file:///Users/dev/b/site") + ); + } + + #[test] + fn refuses_urls_that_would_escape_or_alias_the_store() { + for url in [ + "", + "not a url", + "https://github.com/acme", // no repo segment + "https://github.com//site.git", // empty owner + "https://github.com/../../etc/passwd", + "git@github.com:../evil.git", + "https://github.com/acme/.git", // repo name empties out + ] { + assert_eq!(dir(url), None, "{url}"); + } + } + + /// The regression `prune_worktrees` exists for: git keeps listing a + /// worktree whose directory was deleted, and refuses to re-add one at that + /// path — which would strand a sandbox whose workdir was cleared as + /// invalid. + #[tokio::test] + async fn prune_lets_a_removed_worktree_path_be_reused() { + use std::process::Command; + + let root = tempfile::tempdir().expect("tempdir"); + let app_root = root.path(); + let origin = app_root.join("origin"); + let clone_url = format!("file://{}", origin.display()); + + // A real repository with one commit to branch from. + std::fs::create_dir_all(&origin).unwrap(); + let git = |args: &[&str], cwd: &Path| { + let ok = Command::new("git") + .args(args) + .current_dir(cwd) + .env("GIT_AUTHOR_NAME", "t") + .env("GIT_AUTHOR_EMAIL", "t@e") + .env("GIT_COMMITTER_NAME", "t") + .env("GIT_COMMITTER_EMAIL", "t@e") + .output() + .expect("git runs") + .status + .success(); + assert!(ok, "git {args:?} failed"); + }; + git(&["init", "-q", "-b", "main"], &origin); + std::fs::write(origin.join("f.txt"), "hi").unwrap(); + git(&["add", "."], &origin); + git(&["commit", "-qm", "init"], &origin); + + let canonical = canonical_repo_dir(app_root, &clone_url).expect("keyable url"); + std::fs::create_dir_all(canonical.parent().unwrap()).unwrap(); + git( + &[ + "clone", + "--bare", + "-q", + &clone_url, + canonical.to_str().unwrap(), + ], + app_root, + ); + + let wt = app_root.join("sandboxes/h1/repo"); + std::fs::create_dir_all(wt.parent().unwrap()).unwrap(); + let add = |dest: &Path| { + Command::new("git") + .args([ + "-C", + canonical.to_str().unwrap(), + "worktree", + "add", + "--force", + "-B", + "thread", + dest.to_str().unwrap(), + "HEAD", + ]) + .output() + .expect("git runs") + .status + .success() + }; + assert!(add(&wt), "first worktree add succeeds"); + + // Simulate the invalid-workdir clear. + std::fs::remove_dir_all(&wt).unwrap(); + assert!( + !add(&wt), + "git must refuse the still-registered path — otherwise this fn is pointless" + ); + + prune_worktrees(app_root, &clone_url).await; + assert!(add(&wt), "after prune the path is reusable again"); + } + + /// The boot walk has to reach repos at any nesting depth (subgroups) and + /// must not mistake a namespace directory for a repo. + #[tokio::test] + async fn prune_all_reaches_repos_at_every_depth() { + use std::process::Command; + + let root = tempfile::tempdir().expect("tempdir"); + let app_root = root.path(); + + // Two repos at different depths, plus a namespace dir holding neither. + let deep = app_root.join("repos/gitlab.com/group/sub/repo"); + let shallow = app_root.join("repos/github.com/acme/site"); + std::fs::create_dir_all(app_root.join("repos/empty.com/owner")).unwrap(); + // A bare repo has no worktree to commit in, so build a normal origin + // first and bare-clone it into each canonical path. + let origin = root.path().join("origin"); + std::fs::create_dir_all(&origin).unwrap(); + let git = |args: Vec<&str>, cwd: &Path| { + let ok = Command::new("git") + .args(&args) + .current_dir(cwd) + .env("GIT_AUTHOR_NAME", "t") + .env("GIT_AUTHOR_EMAIL", "t@e") + .env("GIT_COMMITTER_NAME", "t") + .env("GIT_COMMITTER_EMAIL", "t@e") + .output() + .expect("git runs") + .status + .success(); + assert!(ok, "git {args:?} failed"); + }; + git(vec!["init", "-q", "-b", "main"], &origin); + std::fs::write(origin.join("f.txt"), "hi").unwrap(); + git(vec!["add", "."], &origin); + git(vec!["commit", "-qm", "init"], &origin); + + let mut removed = Vec::new(); + for canonical in [&deep, &shallow] { + std::fs::create_dir_all(canonical.parent().unwrap()).unwrap(); + let path = canonical.to_str().unwrap(); + git( + vec!["clone", "--bare", "-q", origin.to_str().unwrap(), path], + app_root, + ); + let wt = app_root.join("sandboxes").join(format!( + "h-{}", + canonical.file_name().unwrap().to_string_lossy() + )); + std::fs::create_dir_all(wt.parent().unwrap()).unwrap(); + assert!( + Command::new("git") + .args([ + "-C", + path, + "worktree", + "add", + "--force", + "-B", + "thread", + wt.to_str().unwrap(), + "HEAD", + ]) + .output() + .expect("git runs") + .status + .success(), + "worktree add in {path}" + ); + std::fs::remove_dir_all(&wt).unwrap(); + removed.push(wt); + } + + prune_all(app_root).await; + + // Both registrations are gone, at both depths, so each path is + // reusable — the reason the walk exists. + for (canonical, wt) in [&deep, &shallow].into_iter().zip(&removed) { + let list = Command::new("git") + .args(["-C", canonical.to_str().unwrap(), "worktree", "list"]) + .output() + .expect("git runs"); + let listed = String::from_utf8_lossy(&list.stdout).into_owned(); + assert!( + !listed.contains(wt.to_str().unwrap()), + "stale worktree still registered in {canonical:?}: {listed}" + ); + } + } + + #[test] + fn a_port_is_not_read_as_an_scp_path() { + // `host:22/owner/repo` is a URL with a port, not scp-style — treating + // the port as the owner would key every such repo under "22". + assert_eq!(dir("github.com:22/acme/site.git"), None); + assert_eq!( + dir("ssh://git@github.com:22/acme/site.git"), + Some("/app/repos/github.com:22/acme/site".to_string()), + ); + } +} diff --git a/apps/native/crates/local-api/src/sandbox/target.rs b/apps/native/crates/local-api/src/sandbox/target.rs new file mode 100644 index 0000000000..d014255eae --- /dev/null +++ b/apps/native/crates/local-api/src/sandbox/target.rs @@ -0,0 +1,243 @@ +//! [`SandboxTarget`] + [`AppState::resolve_sandbox_target`] — the single +//! helper the observability/control routes (`routes/events.rs`, +//! `routes/tasks.rs`, `routes/scripts.rs`, `routes/setup.rs`) use to pick +//! WHICH orchestrator quadruple a request operates on: a specific per-handle +//! [`crate::sandbox::manager::Sandbox`] or the process-global +//! `AppState.{setup,tasks,broadcaster,config,repo_dir}`. +//! +//! Lives here (a `sandbox`-family file) rather than in `state.rs` so the +//! SHARED `AppState` struct is never edited — the module-ownership rule (see +//! the native module-ownership contract). `impl AppState` in a downstream +//! module is allowed; adding a FIELD to the struct is not. +//! +//! Mirrors `routes/proxy.rs::dev_port`'s handle -> `get(handle)` -> +//! `active()` -> global resolution shape, with ONE deliberate divergence: +//! for observability an *unknown explicit handle* falls back to the GLOBAL +//! target (so an SSE stream / task list is never dead), whereas `dev_port` +//! returns `None` for an unknown handle on purpose (never preview the wrong +//! branch — pinned by `proxy.rs`'s +//! `dev_port_returns_none_for_an_unrecognized_handle_header` test). Because of +//! that difference the two stay parallel encodings rather than one calling the +//! other. See the native sandbox-bridge contract D1/O3. + +use std::path::PathBuf; +use std::sync::Arc; + +use crate::config::ConfigStore; +use crate::events::Broadcaster; +use crate::setup::SetupOrchestrator; +use crate::state::AppState; +use crate::tasks::TaskRegistry; + +/// One resolved orchestrator quadruple (plus its workdir) — either a specific +/// per-handle [`crate::sandbox::manager::Sandbox`]'s Arcs, or the +/// process-global ones from [`AppState`]. Every field is a cheap `Arc` clone +/// (or an owned `PathBuf`), so producing a `SandboxTarget` per request is +/// negligible. +pub struct SandboxTarget { + pub setup: Arc, + pub tasks: Arc, + pub broadcaster: Arc, + pub config: Arc, + /// The sandbox workdir, or `state.repo_dir` for the global path. + pub repo_dir: PathBuf, +} + +impl SandboxTarget { + /// Builds a [`SandboxTarget`] from one already-resolved per-handle + /// [`crate::sandbox::manager::Sandbox`] — the four-Arcs-plus-workdir + /// mapping shared by [`AppState::resolve_sandbox_target`] below and + /// `routes/setup.rs`/`routes/repo_dir.rs`'s own STRICTER per-handle + /// resolvers (which, unlike this file's three-tier fallback, error + /// instead of silently falling through to the global path on an unknown + /// handle — see those files' doc comments for why). + pub fn from_sandbox(sandbox: &crate::sandbox::manager::Sandbox) -> SandboxTarget { + SandboxTarget { + setup: sandbox.setup.clone(), + tasks: sandbox.tasks.clone(), + broadcaster: sandbox.broadcaster.clone(), + config: sandbox.config.clone(), + repo_dir: sandbox.workdir.clone(), + } + } +} + +impl AppState { + /// Resolve the per-handle target for `handle`, mirroring + /// `proxy.rs::dev_port`'s handle -> `get(handle)` -> `active()` -> global + /// shape — but NEVER yields "nothing": an unknown handle falls back to the + /// global target so an SSE stream is never dead (see this module's doc, + /// plan D1/O3). An empty handle string is treated as absent. + pub fn resolve_sandbox_target(&self, handle: Option<&str>) -> SandboxTarget { + let handle = handle.filter(|s| !s.is_empty()); + let sandbox = match handle { + Some(h) => self.sandbox_manager.get(h), // known handle -> that sandbox + None => self.sandbox_manager.active(), // headerless -> active sandbox + }; + match sandbox { + Some(sb) => SandboxTarget::from_sandbox(&sb), + // Unknown handle OR no active sandbox -> the process-global path + // (unchanged plain-path behavior). + None => SandboxTarget { + setup: self.setup.clone(), + tasks: self.tasks.clone(), + broadcaster: self.broadcaster.clone(), + config: self.config.clone(), + repo_dir: self.repo_dir.clone(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sandbox::GitSandboxConfig; + use serde_json::json; + use std::path::Path; + use std::process::Command; + + fn git(dir: &Path, args: &[&str]) { + let out = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git failed to spawn"); + assert!(out.status.success(), "git {args:?} failed: {out:?}"); + } + + /// A bare "origin" with a single `main` commit, plus a fresh `AppState`, + /// reusing the same real-bare-repo `ensure()` fixture shape used in + /// `proxy.rs`/`manager.rs` tests. + fn fresh_state() -> AppState { + let config = Arc::new(ConfigStore::new()); + let app_root = std::env::temp_dir(); + let repo_dir = std::env::temp_dir(); + // The GLOBAL (non-sandbox) registry/logs below are never exercised + // by these tests (only `state.sandbox_manager.ensure(..)`, whose own + // per-handle `Sandbox` gets its OWN isolated logs dir under + // `app_root/sandboxes//logs` — see `manager.rs`), so sharing + // `std::env::temp_dir()` here is harmless. + let logs = Arc::new(crate::log_store::LogStore::new(app_root.join("logs"))); + let tasks = Arc::new(TaskRegistry::new(logs)); + let broadcaster = Arc::new(Broadcaster::new()); + let setup = SetupOrchestrator::new( + repo_dir.clone(), + repo_dir.clone(), + config.clone(), + tasks.clone(), + broadcaster.clone(), + ); + AppState { + token: "test-token".into(), + boot_id: "test-boot".into(), + sandbox_manager: crate::sandbox::SandboxManager::new(app_root.clone()), + app_root, + repo_dir, + mode: crate::state::ApiMode::Strict, + config, + tasks, + broadcaster, + shutdown: Arc::new(crate::shutdown::ShutdownCoordinator::new()), + setup, + } + } + + async fn ensure_one_sandbox( + state: &AppState, + vmcp: &str, + ) -> Arc { + let dir = tempfile::tempdir().unwrap(); + let bare_dir = dir.path().join("origin.git"); + let work_dir = dir.path().join("author"); + std::fs::create_dir_all(&bare_dir).unwrap(); + std::fs::create_dir_all(&work_dir).unwrap(); + git(&bare_dir, &["init", "--bare", "-q"]); + git(&work_dir, &["init", "-q", "-b", "main"]); + git(&work_dir, &["config", "user.name", "Test User"]); + git(&work_dir, &["config", "user.email", "test@example.com"]); + std::fs::write(work_dir.join("f.txt"), "x").unwrap(); + git(&work_dir, &["add", "."]); + git(&work_dir, &["commit", "-q", "-m", "initial"]); + let bare_str = bare_dir.to_str().unwrap(); + git(&work_dir, &["remote", "add", "origin", bare_str]); + git(&work_dir, &["push", "-q", "-u", "origin", "main"]); + git(&bare_dir, &["symbolic-ref", "HEAD", "refs/heads/main"]); + + // `ensure()` awaits the clone synchronously before returning, so the + // bare origin only needs to outlive this call — `dir` dropping at the + // end of the test is fine (the async install/start pipeline operates + // on the already-cloned workdir, never the origin again). + state + .sandbox_manager + .ensure(&GitSandboxConfig { + virtual_mcp_id: vmcp.to_string(), + clone_url: bare_str.to_string(), + branch: Some("main".to_string()), + ..Default::default() + }) + .await + .expect("ensure succeeds against a real one-commit bare repo") + } + + #[tokio::test] + async fn known_handle_resolves_to_that_sandboxs_arcs() { + let state = fresh_state(); + let sandbox = ensure_one_sandbox(&state, "vmcp-target-known").await; + + let target = state.resolve_sandbox_target(Some(&sandbox.handle)); + assert!(Arc::ptr_eq(&target.setup, &sandbox.setup)); + assert!(Arc::ptr_eq(&target.tasks, &sandbox.tasks)); + assert!(Arc::ptr_eq(&target.broadcaster, &sandbox.broadcaster)); + assert!(Arc::ptr_eq(&target.config, &sandbox.config)); + assert_eq!(target.repo_dir, sandbox.workdir); + } + + #[tokio::test] + async fn no_handle_with_active_resolves_to_the_active_sandbox() { + let state = fresh_state(); + // `ensure()` marks its handle active, so a headerless resolve follows + // the active sandbox — NOT the global orchestrator. + let sandbox = ensure_one_sandbox(&state, "vmcp-target-active").await; + + let target = state.resolve_sandbox_target(None); + assert!(Arc::ptr_eq(&target.setup, &sandbox.setup)); + assert!(Arc::ptr_eq(&target.tasks, &sandbox.tasks)); + assert!(Arc::ptr_eq(&target.broadcaster, &sandbox.broadcaster)); + } + + #[test] + fn no_handle_without_active_resolves_to_the_global_target() { + let state = fresh_state(); + let target = state.resolve_sandbox_target(None); + assert!(Arc::ptr_eq(&target.setup, &state.setup)); + assert!(Arc::ptr_eq(&target.tasks, &state.tasks)); + assert!(Arc::ptr_eq(&target.broadcaster, &state.broadcaster)); + assert!(Arc::ptr_eq(&target.config, &state.config)); + assert_eq!(target.repo_dir, state.repo_dir); + } + + #[tokio::test] + async fn unknown_handle_falls_back_to_the_global_target() { + let state = fresh_state(); + // Even with an active sandbox present, an EXPLICIT unknown handle + // resolves to global (the observability divergence from `dev_port`, + // which would return `None` here) — see this module's doc. + let _sandbox = ensure_one_sandbox(&state, "vmcp-target-unknown").await; + + let target = state.resolve_sandbox_target(Some("never-ensured-handle")); + assert!(Arc::ptr_eq(&target.setup, &state.setup)); + assert!(Arc::ptr_eq(&target.tasks, &state.tasks)); + assert!(Arc::ptr_eq(&target.broadcaster, &state.broadcaster)); + } + + #[test] + fn empty_handle_is_treated_as_absent() { + let state = fresh_state(); + // Empty string -> global (no active sandbox in this fixture). + let target = state.resolve_sandbox_target(Some("")); + assert!(Arc::ptr_eq(&target.setup, &state.setup)); + // sanity: the global setup is idle + assert_eq!(target.setup.lifecycle_snapshot(), json!({"phase": "idle"})); + } +} diff --git a/apps/native/crates/local-api/src/setup/clone.rs b/apps/native/crates/local-api/src/setup/clone.rs new file mode 100644 index 0000000000..e0b32cf416 --- /dev/null +++ b/apps/native/crates/local-api/src/setup/clone.rs @@ -0,0 +1,1138 @@ +//! Clone / branch-checkout step of the setup pipeline. Byte-parity target: +//! `daemon/setup/clone.ts` + `daemon/git/checkout-branch.ts` + +//! `daemon/setup/identity.ts`, exercised via `daemon.git.e2e.test.ts`'s +//! `describe("git (cloned repo)")` and `daemon.sse-shapes.e2e.test.ts`'s +//! lifecycle/branch/scripts + reload specs (both bootstrap via +//! `bootstrapRepo()` -> `POST /_sandbox/config`). +//! +//! Deliberately narrower than the TS source: no retry-on-transient-network- +//! error loop (`clone.ts`'s `CLONE_MAX_RETRIES`/`isTransient` — retries a +//! handful of specific curl/libgit2 network error strings), no deferred +//! base-branch fetch (`fetchBaseBranch` — purely a divergence-header nicety), +//! no `askpass` script materialization (uses the `true` utility directly, +//! the same convention `routes/git.rs::push_branch` already established for +//! this crate). `file://` clones (the only kind either e2e suite exercises) +//! don't hit any of those paths in the TS source either — they're +//! network-only concerns. +//! +//! ## Streaming (fixes the clone-output regression) +//! +//! Every `git` subprocess this file spawns is now piped and STREAMED — +//! chunks flow into [`crate::log_store::LogStore`] (the `"setup"` source, same +//! combined transcript `setup/install.rs` writes into) and a live +//! `"log"` broadcaster frame, exactly like `install.rs`'s own streaming +//! helper (`run_install_cmd`) — via `TaskRegistry::append_log`, so both +//! files share ONE code path rather than two. Previously `run_git` used +//! `Command::output()`, which buffers the WHOLE process's stdout/stderr in +//! memory and hands it back only after exit — no `"log"` frame was ever +//! emitted and no task was ever registered, so a clone in progress was +//! invisible to `/_sandbox/events` and `/_sandbox/tasks` alike (the +//! confirmed regression this file's rewrite fixes). +//! +//! `clone_fresh`/`checkout_existing` each register ONE `TaskRegistry` entry +//! (`log_name: "setup"`) spanning every `git` subcommand they run — mirrors +//! `install.rs`'s "one task per logical step" convention rather than one +//! per subprocess, so `/_sandbox/tasks` shows one legible "clone"/"checkout" +//! entry instead of a handful of sub-second ones. Marker lines (`"$ git +//! ..."`, `"[worktree] ..."`) are +//! interleaved into the SAME `"setup"` transcript alongside real subprocess +//! output — byte-parity in spirit with the old daemon's +//! `setup/orchestrator.ts`, which funnels clone + install + its own control +//! messages into one `"setup"` replay-buffer key (see that file's `chunk`/ +//! `rawChunk` helpers). A genuinely FRESH clone (`clone_fresh`, not a +//! re-checkout on an already-cloned repo) truncates the `"setup"` transcript +//! first — byte-parity in spirit with `unlinkSync(cloneLogPath)` before +//! opening a new clone tee in the old source; see [`crate::log_store::LogStore::truncate`]. + +use std::path::Path; +use std::sync::Arc; + +use serde_json::{json, Value}; +use tokio::io::AsyncReadExt; + +use super::SetupOrchestrator; +use crate::log_store::app_key; +use crate::process_group::ProcessGroupChild; +use crate::routes::git::{ + current_branch, emit_branch_event, is_git_repo, is_valid_remote_branch_name, +}; +use crate::routes::scripts::{classify_status, exit_status_to_code}; +use crate::tasks::{ + now_ms, KillSignal, OutputStream, ProcessController, TaskEntry, TaskStatus, TaskSummary, +}; + +/// `-c safe.directory=* -c http.connectTimeout=10 -c http.lowSpeedLimit=1 -c +/// http.lowSpeedTime=10` — byte-parity with `setup/git-command.ts::gitBaseArgv` +/// EXCEPT for one deliberate omission: the TS source also sets `-c +/// credential.helper=` (disables the user's credential helper), because prod +/// authenticates via a cluster-minted token already embedded in `cloneUrl` +/// (`x-access-token:TOKEN@github.com/...`) and never wants a stale cached +/// credential to shadow it. Desktop has no cluster-minted token — per +/// the native Git-sandbox contract's "Desktop adaptation" section, +/// the user is on their OWN machine with their OWN git auth, so cloning +/// should use exactly what `git clone` would do run by hand: the user's +/// configured credential helper (macOS Keychain, `gh`, SSH agent, ...). +/// Clearing `credential.helper` here would silently break every private +/// repo a desktop user can otherwise already clone from a terminal. +/// `GIT_TERMINAL_PROMPT=0` (below, in [`run_git`]) still applies regardless, +/// so a repo the user's credentials genuinely can't reach fails fast rather +/// than hanging on an interactive prompt. +fn base_argv() -> Vec<&'static str> { + vec![ + "-c", + "safe.directory=*", + "-c", + "http.connectTimeout=10", + "-c", + "http.lowSpeedLimit=1", + "-c", + "http.lowSpeedTime=10", + ] +} + +/// Appends `text` to the `"setup"` transcript — this task's own per-task +/// file (`TaskRegistry::append_log`) when `task_id` is `Some` (the git +/// subcommand belongs to a registered, observable step), OR just the +/// combined `"app/setup"` file + a live broadcast when `task_id` is `None` +/// (a quick, untracked probe — `configure_git_identity`'s `git config` +/// calls have no natural "step" to attribute to). Either way the SAME +/// `"setup"` source/transcript is fed, so replay always reads one coherent +/// interleaved history regardless of which git calls were task-tracked. +async fn emit_chunk( + orch: &Arc, + task_id: Option<&str>, + stream: OutputStream, + text: &str, +) { + if text.is_empty() { + return; + } + match task_id { + Some(id) => { + orch.tasks + .append_log(id, "setup", stream, text, &orch.broadcaster) + .await; + } + None => { + orch.tasks.logs().append(&app_key("setup"), text).await; + orch.broadcaster + .emit("log", json!({ "source": "setup", "data": text })); + } + } +} + +/// `GIT_TERMINAL_PROMPT=0` + a real no-op askpass — byte-parity in spirit +/// with `setup/git-command.ts::gitStepEnv` (which materializes a script; +/// this crate reuses the `true` utility already on every POSIX `$PATH`, same +/// as `routes/git.rs::push_branch`'s `GIT_ASKPASS=true`). +/// +/// Streams stdout/stderr as they arrive (piped spawn, not +/// `Command::output()`) — chunks flow through [`emit_chunk`] as each read +/// resolves, so a caller subscribed to `/_sandbox/events` sees clone/checkout +/// progress live instead of only after the whole command exits. Still +/// returns the combined text (for a caller's failure-message construction) — +/// `Ok((exit_code, combined_stdout_and_stderr))`; `Err` only for a spawn +/// failure, never a nonzero exit. +async fn run_git( + orch: &Arc, + task_id: Option<&str>, + args: &[&str], + cwd: Option<&Path>, + controller: Option<&ProcessController>, +) -> Result<(i32, String), String> { + let mut full = base_argv(); + full.extend_from_slice(args); + + if let Some(signal) = controller.and_then(ProcessController::requested) { + return Ok((signal.exit_code(), "cancelled before spawn".to_string())); + } + + emit_chunk( + orch, + task_id, + OutputStream::Stdout, + &format!("$ git {}\r\n", full.join(" ")), + ) + .await; + + let mut cmd = tokio::process::Command::new("git"); + cmd.args(&full) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_ASKPASS", "true") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + if let Some(cwd) = cwd { + cmd.current_dir(cwd); + } + let mut child = ProcessGroupChild::spawn(&mut cmd, orch.tasks.child_lifetime_lock_path()) + .await + .map_err(|e| format!("git {}: {e}", full.join(" ")))?; + + let (Some(mut stdout_pipe), Some(mut stderr_pipe)) = (child.take_stdout(), child.take_stderr()) + else { + // Pipes requested above, so this shouldn't happen. Reap the whole + // process group rather than dropping a possibly still-running child. + child.signal(KillSignal::Kill).await; + let status = child + .wait() + .await + .map_err(|e| format!("git {}: {e}", full.join(" ")))?; + return Ok((exit_status_to_code(status), String::new())); + }; + + let mut combined = String::new(); + let mut stdout_open = true; + let mut stderr_open = true; + let mut exited = false; + let mut exit_status: Option = None; + let mut observed_signal = None; + let mut so_chunk = [0u8; 8192]; + let mut se_chunk = [0u8; 8192]; + + loop { + if exited && !stdout_open && !stderr_open { + break; + } + tokio::select! { + res = stdout_pipe.read(&mut so_chunk), if stdout_open => { + match res { + Ok(0) | Err(_) => stdout_open = false, + Ok(n) => { + let text = String::from_utf8_lossy(&so_chunk[..n]).into_owned(); + combined.push_str(&text); + emit_chunk(orch, task_id, OutputStream::Stdout, &text).await; + } + } + } + res = stderr_pipe.read(&mut se_chunk), if stderr_open => { + match res { + Ok(0) | Err(_) => stderr_open = false, + Ok(n) => { + let text = String::from_utf8_lossy(&se_chunk[..n]).into_owned(); + combined.push_str(&text); + emit_chunk(orch, task_id, OutputStream::Stderr, &text).await; + } + } + } + status = child.wait(), if !exited && !stdout_open && !stderr_open => { + exited = true; + exit_status = status.ok(); + } + signal = wait_for_signal(controller, observed_signal), if !exited => { + observed_signal = Some(signal); + child.signal(signal).await; + } + } + } + + let code = exit_status.map(exit_status_to_code).unwrap_or(-1); + Ok((code, combined)) +} + +async fn wait_for_signal( + controller: Option<&ProcessController>, + observed: Option, +) -> KillSignal { + match controller { + Some(controller) => controller.wait_for_change(observed).await, + None => std::future::pending().await, + } +} + +/// Sandbox isolation keys, not real git refs — byte-parity with +/// `constants.ts::isSyntheticBranch`. +fn is_synthetic_branch(branch: &str) -> bool { + branch == "ephemeral" || branch.starts_with("thread:") +} + +fn get_str<'a>(config: &'a Value, path: &[&str]) -> Option<&'a str> { + let mut cur = config; + for key in path { + cur = cur.get(key)?; + } + cur.as_str() +} + +/// `git ls-remote --exit-code --heads` return codes git itself defines: `2` +/// means "remote reachable, no matching ref" (a real failure is anything +/// else non-zero) — byte-parity with `clone.ts::LS_REMOTE_NO_MATCH`. +const LS_REMOTE_NO_MATCH: i32 = 2; + +/// Acquires source (clone if `.git` is absent, otherwise best-effort +/// checkout onto the configured branch) then runs the idempotent +/// post-acquisition steps (git identity, branch snapshot refresh). Returns +/// `true` on success OR "nothing to clone" (no `cloneUrl` configured — +/// forward progress, matches `stepClone`'s no-cloneUrl branch, not a +/// failure); `false` only on an actual clone/checkout failure, which also +/// transitions `lifecycle` to `clone-failed` before returning. +/// +/// `pub(crate)` (widened from `pub(super)`): `crate::sandbox::manager` +/// drives this directly (awaited, outside the normal +/// enqueue-onto-the-worker path) so `SandboxManager::ensure` can guarantee +/// its caller sees the right branch's files the instant it returns — see +/// that module's doc comment. +pub(crate) async fn run(orch: &Arc, config: &Value) -> bool { + if orch.is_closed() { + return false; + } + let Some(clone_url) = + get_str(config, &["git", "repository", "cloneUrl"]).filter(|s| !s.is_empty()) + else { + return true; + }; + + let branch = get_str(config, &["git", "repository", "branch"]) + .filter(|b| !b.is_empty() && !is_synthetic_branch(b)); + + if !is_git_repo(&orch.repo_dir).await { + if orch.is_closed() { + return false; + } + if !clone_fresh(orch, clone_url, branch).await { + return false; + } + } else if let Some(branch) = branch { + if let Err(message) = checkout_existing(orch, branch).await { + orch.transition_lifecycle(super::clone_failed(message)); + return false; + } + } + + if !configure_git_identity(orch, config).await || orch.is_closed() { + return false; + } + emit_branch_event(&orch.repo_dir, &orch.broadcaster).await; + !orch.is_closed() +} + +/// Cheap validity probe for an unchanged in-process sandbox. A configured +/// real branch needs one `rev-parse`; synthetic isolation branches only need +/// to prove the managed workdir is still a repository. +pub(crate) async fn checkout_is_current(orch: &Arc, config: &Value) -> bool { + if orch.is_closed() { + return false; + } + let Some(_clone_url) = + get_str(config, &["git", "repository", "cloneUrl"]).filter(|url| !url.is_empty()) + else { + return true; + }; + + match get_str(config, &["git", "repository", "branch"]) + .filter(|branch| !branch.is_empty() && !is_synthetic_branch(branch)) + { + Some(expected) => current_branch(&orch.repo_dir) + .await + .is_some_and(|current| current == expected), + None => is_git_repo(&orch.repo_dir).await, + } +} + +/// `.git` absent at `orch.repo_dir` (created empty at boot) — clone into it. +/// Registers ONE `"setup"` task spanning every git subcommand this +/// (potentially multi-step: ls-remote probe, clone, optional local fork) +/// acquisition runs — see this module's doc comment. +async fn clone_fresh(orch: &Arc, clone_url: &str, branch: Option<&str>) -> bool { + orch.transition_lifecycle(json!({ "phase": "cloning" })); + + // A FRESH clone starts a new "setup" lifecycle — reset the transcript so + // a retry's replay isn't polluted by a stale prior attempt. Byte-parity + // IN SPIRIT with `unlinkSync(cloneLogPath)` right before the old source + // opened a new clone tee (`setup/orchestrator.ts`); `checkout_existing` + // below deliberately does NOT reset it (neither did the old source's + // "repo already cloned" / re-checkout path). + orch.tasks.logs().truncate(&app_key("setup")).await; + + let task_id = orch.tasks.next_id(); + let controller = ProcessController::new(); + if !orch.register_task(TaskEntry::new( + TaskSummary { + id: task_id.clone(), + command: format!("git clone {clone_url}"), + status: TaskStatus::Running, + exit_code: None, + started_at: now_ms(), + finished_at: None, + timed_out: false, + truncated: false, + log_name: Some("setup".to_string()), + intentional: None, + }, + Some(controller.kill_handle()), + )) { + return false; + } + + match clone_fresh_body(orch, &task_id, clone_url, branch, &controller).await { + _ if controller.requested().is_some() => { + let signal = controller.requested().expect("checked above"); + orch.tasks + .finalize(&task_id, TaskStatus::Killed, signal.exit_code(), false); + false + } + Ok(()) => { + orch.tasks.finalize(&task_id, TaskStatus::Exited, 0, false); + true + } + Err(message) => { + // Surface the failure in the transcript BEFORE finalizing the + // task, not after — a chunk sent once a task is terminal could + // race a brand-new `/stream` subscriber that joins between the + // two (see `tasks/registry.rs`'s "never see a chunk after End" + // invariant). + emit_chunk( + orch, + Some(&task_id), + OutputStream::Stderr, + &format!("\r\n[worktree] could not create the worktree: {message}\r\n"), + ) + .await; + orch.tasks.finalize(&task_id, TaskStatus::Failed, -1, false); + orch.transition_lifecycle(super::clone_failed(message)); + false + } + } +} + +/// The actual clone/fork sequence, factored out of [`clone_fresh`] so every +/// failure path funnels through ONE `Result::Err` — the caller finalizes the +/// task and transitions `lifecycle` exactly once, at the end, instead of +/// each branch duplicating that bookkeeping. +async fn clone_fresh_body( + orch: &Arc, + task_id: &str, + clone_url: &str, + branch: Option<&str>, + controller: &ProcessController, +) -> Result<(), String> { + // Decide whether the requested branch exists on the remote before + // cloning — byte-parity with `spawnClone`'s deterministic + // `ls-remote --exit-code` probe (replacing a "try then silently + // fall through" approach): 0 -> clone it directly with `--branch`; 2 -> + // clone the default branch and fork the requested one locally; anything + // else is a real failure (auth/DNS/TLS) surfaced to the caller. + let (branch_on_remote, branch_to_fork): (Option<&str>, Option<&str>) = match branch { + None => (None, None), + Some(b) => { + if !is_valid_remote_branch_name(b) { + return Err(format!("invalid branch name: {b}")); + } + let (code, out) = run_git( + orch, + Some(task_id), + &["ls-remote", "--exit-code", "--heads", clone_url, b], + None, + Some(controller), + ) + .await?; + match code { + 0 => (Some(b), None), + LS_REMOTE_NO_MATCH => { + emit_chunk( + orch, + Some(task_id), + OutputStream::Stdout, + &format!( + "[worktree] branch '{b}' not on origin; creating it from HEAD in a new worktree\r\n" + ), + ) + .await; + (None, Some(b)) + } + other => { + return Err(format!("ls-remote failed (exit {other}): {}", out.trim())); + } + } + } + }; + + clear_clone_destination(&orch.repo_dir).await?; + + // ONE bare clone per upstream repository, shared by every sandbox that + // names it; this worktree only adds a working copy on top. Falls back to a + // direct clone when the URL isn't one we can key (see `canonical_repo_dir`). + let canonical = crate::sandbox::repo_store::canonical_repo_dir(&orch.app_root, clone_url); + let Some(canonical) = canonical else { + let repo_dir_str = orch.repo_dir.to_string_lossy().into_owned(); + let mut clone_args: Vec<&str> = vec!["clone", "--depth", "1"]; + if let Some(b) = branch_on_remote { + clone_args.push("--branch"); + clone_args.push(b); + } + clone_args.push(clone_url); + clone_args.push(&repo_dir_str); + let (code, out) = run_git(orch, Some(task_id), &clone_args, None, Some(controller)).await?; + if code != 0 { + return Err(format!("git clone exited {code}: {}", out.trim())); + } + return finish_fresh_checkout(orch, task_id, branch_to_fork, controller).await; + }; + + ensure_canonical_repo(orch, task_id, clone_url, &canonical, controller).await?; + + // Start point for the new worktree. A BARE clone keeps upstream heads at + // `refs/heads/*` and creates no remote-tracking refs at all, so the branch + // is named plainly — `origin/` does not resolve here the way it + // would in a normal clone. + let start_point = branch_on_remote.unwrap_or("HEAD").to_string(); + let target_branch = branch_on_remote.or(branch_to_fork); + + let canonical_str = canonical.to_string_lossy().into_owned(); + let repo_dir_str = orch.repo_dir.to_string_lossy().into_owned(); + let add = |branch: Option<&str>| { + let mut args: Vec = vec![ + "-C".into(), + canonical_str.clone(), + "worktree".into(), + "add".into(), + "--force".into(), + ]; + match branch { + // `-B` so re-provisioning a handle resets the branch instead of + // failing on "already exists". + Some(b) => { + args.push("-B".into()); + args.push(b.to_string()); + } + None => args.push("--detach".into()), + } + args.push(repo_dir_str.clone()); + args.push(start_point.clone()); + args + }; + + let args = add(target_branch); + let argv: Vec<&str> = args.iter().map(String::as_str).collect(); + let (code, out) = run_git(orch, Some(task_id), &argv, None, Some(controller)).await?; + if code != 0 { + // Git refuses to check a branch out in two worktrees at once. Two + // sandboxes CAN legitimately want one repo+branch (different agents on + // the same work), so fall back to a detached worktree at the same + // commit rather than failing the sandbox outright — it has the right + // files; only the branch pointer is missing. + let already_checked_out = out.contains("already used by worktree") + || out.contains("already checked out") + || out.contains("is already used"); + if !already_checked_out || target_branch.is_none() { + return Err(format!("git worktree add exited {code}: {}", out.trim())); + } + emit_chunk( + orch, + Some(task_id), + OutputStream::Stdout, + &format!( + "[worktree] branch '{}' already checked out elsewhere; detaching HEAD\r\n", + target_branch.unwrap_or("") + ), + ) + .await; + let args = add(None); + let argv: Vec<&str> = args.iter().map(String::as_str).collect(); + let (code, out) = run_git(orch, Some(task_id), &argv, None, Some(controller)).await?; + if code != 0 { + return Err(format!("git worktree add exited {code}: {}", out.trim())); + } + } + + finish_fresh_checkout(orch, task_id, None, controller).await +} + +/// The shared bare clone every worktree is cut from: created on first use, +/// refreshed on later ones so a new sandbox sees current refs. +/// +/// `--bare` deliberately: nobody edits this directory, and a bare repo cannot +/// hold a checked-out branch that would then be unavailable to worktrees. +async fn ensure_canonical_repo( + orch: &Arc, + task_id: &str, + clone_url: &str, + canonical: &Path, + controller: &ProcessController, +) -> Result<(), String> { + let canonical_str = canonical.to_string_lossy().into_owned(); + + if canonical.join("HEAD").is_file() { + // Already present — refresh refs so a newly requested branch resolves. + // A failed fetch is not fatal: the objects already on disk may well + // satisfy this checkout, and failing here would strand a sandbox that + // could otherwise start offline. + let (code, out) = run_git( + orch, + Some(task_id), + &["-C", &canonical_str, "fetch", "--prune", "origin"], + None, + Some(controller), + ) + .await?; + if code != 0 { + emit_chunk( + orch, + Some(task_id), + OutputStream::Stdout, + &format!( + "[worktree] fetch failed (exit {code}); reusing cached objects: {}\r\n", + out.trim() + ), + ) + .await; + } + return Ok(()); + } + + if let Some(parent) = canonical.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| format!("failed to create repo store {parent:?}: {error}"))?; + } + // Frame git's own `Cloning into bare repository '...'` before it appears. + // Without this the once-per-repository mirror reads as a per-sandbox + // clone, which is precisely how this transcript has been misread. + emit_chunk( + orch, + Some(task_id), + OutputStream::Stdout, + "[worktree] no local mirror yet; creating the shared bare clone (once per repository)\r\n", + ) + .await; + let (code, out) = run_git( + orch, + Some(task_id), + &["clone", "--bare", clone_url, &canonical_str], + None, + Some(controller), + ) + .await?; + if code != 0 { + return Err(format!("git clone --bare exited {code}: {}", out.trim())); + } + Ok(()) +} + +/// The post-acquisition local fork step, shared by the worktree and +/// direct-clone paths. +async fn finish_fresh_checkout( + orch: &Arc, + task_id: &str, + branch_to_fork: Option<&str>, + controller: &ProcessController, +) -> Result<(), String> { + if let Some(b) = branch_to_fork { + let (code, out) = run_git( + orch, + Some(task_id), + &["checkout", "-B", b], + Some(&orch.repo_dir), + Some(controller), + ) + .await?; + if code != 0 { + return Err(format!("git checkout -B {b} exited {code}: {}", out.trim())); + } + } + Ok(()) +} + +/// A cancelled `git clone` may leave an invalid, non-empty `.git` tree. This +/// function is reached only after `run` has established that `repo_dir` is not +/// a usable repository and the remote probe above has succeeded, so the +/// managed destination must be empty before retrying the clone. +async fn clear_clone_destination(repo_dir: &Path) -> Result<(), String> { + let metadata = match tokio::fs::symlink_metadata(repo_dir).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "failed to inspect clone destination {repo_dir:?}: {error}" + )) + } + }; + let result = if metadata.is_dir() { + tokio::fs::remove_dir_all(repo_dir).await + } else { + tokio::fs::remove_file(repo_dir).await + }; + result.map_err(|error| format!("failed to clear clone destination {repo_dir:?}: {error}")) +} + +/// Already cloned and a (possibly different) branch is configured. Narrower +/// than `spawnCheckoutBranch`: neither e2e oracle exercises a re-checkout on +/// an already-cloned repo (both always start from a fresh temp workdir), so +/// only the "already on the right branch" fast path and a plain +/// `git checkout ` are implemented here — a remote-vs-local-fork +/// decision on an EXISTING clone is a known, flagged gap, not a silently +/// masked one. The task is registered before even the fast-path `rev-parse` +/// probe so shutdown owns every child spawned by this logical checkout step. +async fn checkout_existing(orch: &Arc, branch: &str) -> Result<(), String> { + let task_id = orch.tasks.next_id(); + let controller = ProcessController::new(); + if !orch.register_task(TaskEntry::new( + TaskSummary { + id: task_id.clone(), + command: format!("git checkout {branch}"), + status: TaskStatus::Running, + exit_code: None, + started_at: now_ms(), + finished_at: None, + timed_out: false, + truncated: false, + log_name: Some("setup".to_string()), + intentional: None, + }, + Some(controller.kill_handle()), + )) { + return Err("checkout task registration rejected".to_string()); + } + + let current = match run_git( + orch, + Some(&task_id), + &["rev-parse", "--abbrev-ref", "HEAD"], + Some(&orch.repo_dir), + Some(&controller), + ) + .await + { + Ok((0, current)) => current, + Ok((code, output)) => { + let (status, code) = match controller.requested() { + Some(signal) => (TaskStatus::Killed, signal.exit_code()), + None => (classify_status(false, code), code), + }; + orch.tasks.finalize(&task_id, status, code, false); + return Err(format!( + "git rev-parse before checkout exited {code}: {}", + output.trim() + )); + } + Err(error) => { + let (status, code) = match controller.requested() { + Some(signal) => (TaskStatus::Killed, signal.exit_code()), + None => (TaskStatus::Failed, -1), + }; + orch.tasks.finalize(&task_id, status, code, false); + return Err(error); + } + }; + if let Some(signal) = controller.requested() { + orch.tasks + .finalize(&task_id, TaskStatus::Killed, signal.exit_code(), false); + return Err(format!("git checkout {branch} cancelled")); + } + if current.trim() == branch { + orch.tasks.finalize(&task_id, TaskStatus::Exited, 0, false); + return Ok(()); + } + orch.transition_lifecycle(json!({ "phase": "checking-out", "to": branch })); + + let checkout = run_git( + orch, + Some(&task_id), + &["checkout", branch], + Some(&orch.repo_dir), + Some(&controller), + ) + .await; + if let Some(signal) = controller.requested() { + orch.tasks + .finalize(&task_id, TaskStatus::Killed, signal.exit_code(), false); + return Err(format!("git checkout {branch} cancelled")); + } + + match checkout { + Ok((0, _)) => { + orch.tasks.finalize(&task_id, TaskStatus::Exited, 0, false); + Ok(()) + } + Ok((code, output)) => { + orch.tasks + .finalize(&task_id, classify_status(false, code), code, false); + Err(format!( + "git checkout {branch} exited {code}: {}", + output.trim() + )) + } + Err(error) => { + orch.tasks.finalize(&task_id, TaskStatus::Failed, -1, false); + Err(error) + } + } +} + +/// Byte-parity with `setup/identity.ts::configureGitIdentity` — sets local +/// (repo-scoped, not global) `user.name`/`user.email` so `git commit` never +/// depends on the machine having a global identity configured. Required for +/// `routes/git.rs::publish`'s commits to succeed in a hermetic test HOME. +/// The two quick commands share one registered task so shutdown cannot race a +/// new `git config` child into existence after setup admission closes. +async fn configure_git_identity(orch: &Arc, config: &Value) -> bool { + let name = get_str(config, &["git", "identity", "userName"]).filter(|s| !s.is_empty()); + let email = get_str(config, &["git", "identity", "userEmail"]).filter(|s| !s.is_empty()); + let (Some(name), Some(email)) = (name, email) else { + return true; + }; + + let task_id = orch.tasks.next_id(); + let controller = ProcessController::new(); + if !orch.register_task(TaskEntry::new( + TaskSummary { + id: task_id.clone(), + command: "git config local identity".to_string(), + status: TaskStatus::Running, + exit_code: None, + started_at: now_ms(), + finished_at: None, + timed_out: false, + truncated: false, + log_name: Some("setup".to_string()), + intentional: None, + }, + Some(controller.kill_handle()), + )) { + return false; + } + + let name_result = run_git( + orch, + Some(&task_id), + &["config", "user.name", name], + Some(&orch.repo_dir), + Some(&controller), + ) + .await; + let email_result = run_git( + orch, + Some(&task_id), + &["config", "user.email", email], + Some(&orch.repo_dir), + Some(&controller), + ) + .await; + + if let Some(signal) = controller.requested() { + orch.tasks + .finalize(&task_id, TaskStatus::Killed, signal.exit_code(), false); + return false; + } + let succeeded = matches!(name_result, Ok((0, _))) && matches!(email_result, Ok((0, _))); + orch.tasks.finalize( + &task_id, + if succeeded { + TaskStatus::Exited + } else { + TaskStatus::Failed + }, + if succeeded { 0 } else { -1 }, + false, + ); + // Identity configuration was historically best-effort: a machine-level + // git policy may reject the local write without making the acquired + // worktree unusable. Preserve that behavior; only admission close or an + // actual shutdown cancellation stops the cascade. + true +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + use crate::config::ConfigStore; + use crate::events::Broadcaster; + use crate::log_store::{LogStore, DEFAULT_TAIL_BYTES}; + use crate::tasks::TaskRegistry; + + fn git(dir: &Path, args: &[&str]) { + let out = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git failed to spawn"); + assert!( + out.status.success(), + "git {args:?} failed in {dir:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + + /// A bare "origin" with a single `main` commit — returns the owning + /// tempdir (keep it alive for the fixture's lifetime) and the bare + /// repo's path (used directly as a `file://`-style `cloneUrl`). + fn bare_repo_with_one_commit() -> (tempfile::TempDir, String) { + let root = tempfile::tempdir().unwrap(); + let bare_dir = root.path().join("origin.git"); + let work_dir = root.path().join("author"); + std::fs::create_dir_all(&bare_dir).unwrap(); + std::fs::create_dir_all(&work_dir).unwrap(); + git(&bare_dir, &["init", "--bare", "-q"]); + git(&work_dir, &["init", "-q", "-b", "main"]); + git(&work_dir, &["config", "user.name", "Test User"]); + git(&work_dir, &["config", "user.email", "test@example.com"]); + std::fs::write(work_dir.join("f.txt"), "x").unwrap(); + git(&work_dir, &["add", "."]); + git(&work_dir, &["commit", "-q", "-m", "initial"]); + let bare_str = bare_dir.to_str().unwrap().to_string(); + git(&work_dir, &["remote", "add", "origin", &bare_str]); + git(&work_dir, &["push", "-q", "-u", "origin", "main"]); + git(&bare_dir, &["symbolic-ref", "HEAD", "refs/heads/main"]); + (root, bare_str) + } + + /// A fresh `SetupOrchestrator` rooted at `repo_dir`, with its OWN + /// isolated `LogStore` (sibling `logs` dir) — mirrors + /// `sandbox/manager.rs`'s per-sandbox wiring at a smaller scale. + fn fresh_orch(repo_dir: std::path::PathBuf) -> Arc { + // `app_root` must be the PARENT of the workdir, never the workdir + // itself: the shared repo store lives at `/repos/…`, and + // nesting that inside the destination would leave `git worktree add` + // a non-empty directory to fail on. Mirrors the real per-sandbox + // shape, `/sandboxes//repo`. + let app_root = repo_dir.parent().unwrap().to_path_buf(); + let logs = Arc::new(LogStore::new(app_root.join("logs"))); + SetupOrchestrator::new( + repo_dir, + app_root, + Arc::new(ConfigStore::new()), + Arc::new(TaskRegistry::new(logs)), + Arc::new(Broadcaster::new()), + ) + } + + fn empty_repo_dir() -> (tempfile::TempDir, std::path::PathBuf) { + let workdir = tempfile::tempdir().unwrap(); + let repo_dir = workdir.path().join("repo"); + std::fs::create_dir_all(&repo_dir).unwrap(); + (workdir, repo_dir) + } + + /// Acquiring a branch that EXISTS on the remote, through the shared-store + /// path — the combination that reached the running app broken. + /// + /// The other tests in this file hand `clone_fresh` a bare filesystem path, + /// which `canonical_repo_dir` declines to key, so they all exercise the + /// direct-clone fallback and never touch a worktree. A `file://` URL is + /// keyable, so this one goes through `ensure_canonical_repo` + + /// `git worktree add` for real. + /// + /// The specific regression: a bare clone keeps upstream heads at + /// `refs/heads/*` and creates NO remote-tracking refs, so starting the + /// worktree at `origin/` failed with "invalid reference". + #[tokio::test] + async fn worktree_path_checks_out_a_branch_that_exists_on_the_remote() { + let (origin_root, bare_str) = bare_repo_with_one_commit(); + let (workdir, repo_dir) = empty_repo_dir(); + let orch = fresh_orch(repo_dir.clone()); + let clone_url = format!("file://{bare_str}"); + + assert!( + clone_fresh(&orch, &clone_url, Some("main")).await, + "acquiring an existing remote branch must succeed: {:?}", + orch.tasks + .logs() + .tail_read(&app_key("setup"), DEFAULT_TAIL_BYTES) + .await + ); + + // A worktree, not a clone: `.git` is a FILE pointing into the shared + // canonical repo, and that canonical repo exists under `repos/`. + assert!( + repo_dir.join(".git").is_file(), + "expected a worktree marker file, found a directory (plain clone)" + ); + let canonical = + crate::sandbox::repo_store::canonical_repo_dir(&orch.app_root, &clone_url).unwrap(); + assert!( + canonical.join("HEAD").is_file(), + "bare canonical repo exists" + ); + + // And it is genuinely on the requested branch with the remote's commit. + assert_eq!(current_branch(&repo_dir).await.as_deref(), Some("main")); + assert!( + repo_dir.join("f.txt").is_file(), + "remote content checked out" + ); + + let transcript = orch + .tasks + .logs() + .tail_read(&app_key("setup"), DEFAULT_TAIL_BYTES) + .await; + assert!( + transcript.contains("worktree add"), + "the shared-store path should be what ran: {transcript:?}" + ); + drop((origin_root, workdir)); + } + + #[tokio::test] + async fn fresh_clone_streams_output_to_the_setup_log_and_registers_a_task() { + let (_origin, clone_url) = bare_repo_with_one_commit(); + let (_workdir, repo_dir) = empty_repo_dir(); + let orch = fresh_orch(repo_dir.clone()); + + let config = json!({ + "git": { "repository": { "cloneUrl": clone_url, "branch": "main" } } + }); + assert!(run(&orch, &config).await, "clone must succeed"); + assert!(repo_dir.join(".git").exists()); + + // The regression this file fixes: BEFORE, `run_git` used + // `Command::output()` — no task, no live/replayed log frame. + let tasks = orch.tasks.list(None); + let setup_task = tasks + .iter() + .find(|t| t.log_name.as_deref() == Some("setup")) + .expect("clone must register a \"setup\" task"); + assert_eq!(setup_task.status, TaskStatus::Exited); + assert_eq!(setup_task.exit_code, Some(0)); + + let transcript = orch + .tasks + .logs() + .tail_read(&app_key("setup"), DEFAULT_TAIL_BYTES) + .await; + assert!( + transcript.contains("$ git") && transcript.contains("clone"), + "transcript missing the streamed clone command: {transcript:?}" + ); + } + + #[tokio::test] + async fn fresh_clone_replaces_an_interrupted_nonempty_destination() { + let (_origin, clone_url) = bare_repo_with_one_commit(); + let (_workdir, repo_dir) = empty_repo_dir(); + std::fs::create_dir_all(repo_dir.join(".git/objects")).unwrap(); + std::fs::write(repo_dir.join(".git/objects/partial"), "incomplete clone").unwrap(); + std::fs::write(repo_dir.join("partial-worktree-file"), "incomplete clone").unwrap(); + let orch = fresh_orch(repo_dir.clone()); + + let config = json!({ + "git": { "repository": { "cloneUrl": clone_url, "branch": "main" } } + }); + assert!( + run(&orch, &config).await, + "a retry must replace the interrupted clone" + ); + assert!(is_git_repo(&repo_dir).await); + assert!(!repo_dir.join("partial-worktree-file").exists()); + assert_eq!(current_branch(&repo_dir).await.as_deref(), Some("main")); + } + + #[tokio::test] + async fn closed_orchestrator_rejects_clone_before_any_task_or_process() { + let (_origin, clone_url) = bare_repo_with_one_commit(); + let (_workdir, repo_dir) = empty_repo_dir(); + let orch = fresh_orch(repo_dir.clone()); + orch.close(); + + let config = json!({ + "git": { "repository": { "cloneUrl": clone_url, "branch": "main" } } + }); + assert!(!run(&orch, &config).await); + assert!(orch.tasks.list(None).is_empty()); + assert!(!repo_dir.join(".git").exists()); + } + + #[tokio::test] + async fn fresh_clone_of_a_missing_remote_branch_forks_locally_and_logs_it() { + let (_origin, clone_url) = bare_repo_with_one_commit(); + let (_workdir, repo_dir) = empty_repo_dir(); + let orch = fresh_orch(repo_dir.clone()); + + let config = json!({ + "git": { "repository": { "cloneUrl": clone_url, "branch": "does-not-exist-on-remote" } } + }); + assert!( + run(&orch, &config).await, + "clone must still succeed (local fork)" + ); + + let transcript = orch + .tasks + .logs() + .tail_read(&app_key("setup"), DEFAULT_TAIL_BYTES) + .await; + assert!( + transcript.contains("not on origin; creating it from HEAD in a new worktree"), + "transcript: {transcript:?}" + ); + + let out = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(&repo_dir) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&out.stdout).trim(), + "does-not-exist-on-remote" + ); + } + + #[tokio::test] + async fn clone_failure_finalizes_the_task_as_failed_and_transitions_lifecycle() { + let (_workdir, repo_dir) = empty_repo_dir(); + let orch = fresh_orch(repo_dir.clone()); + + // A path that doesn't exist — `git clone` fails fast, no network. + let bogus = repo_dir.parent().unwrap().join("nope.git"); + let config = json!({ + "git": { "repository": { "cloneUrl": bogus.to_str().unwrap() } } + }); + assert!(!run(&orch, &config).await, "clone must fail"); + + assert_eq!(orch.lifecycle_snapshot()["phase"], "clone-failed"); + + let tasks = orch.tasks.list(None); + let setup_task = tasks + .iter() + .find(|t| t.log_name.as_deref() == Some("setup")) + .expect("clone must register a \"setup\" task even on failure"); + assert_eq!(setup_task.status, TaskStatus::Failed); + + let transcript = orch + .tasks + .logs() + .tail_read(&app_key("setup"), DEFAULT_TAIL_BYTES) + .await; + assert!( + transcript.contains("[worktree] could not create the worktree"), + "transcript: {transcript:?}" + ); + } + + #[tokio::test] + async fn a_second_fresh_clone_truncates_the_prior_setup_transcript() { + let (_origin, clone_url) = bare_repo_with_one_commit(); + let (_workdir, repo_dir) = empty_repo_dir(); + let orch = fresh_orch(repo_dir.clone()); + + assert!(clone_fresh(&orch, &clone_url, Some("main")).await); + let first = orch + .tasks + .logs() + .tail_read(&app_key("setup"), DEFAULT_TAIL_BYTES) + .await; + assert_eq!(first.matches("clone --depth 1").count(), 1); + + // A second "fresh" clone run against the SAME orchestrator (mirrors + // a real re-bootstrap onto an emptied workdir). + std::fs::remove_dir_all(&repo_dir).unwrap(); + std::fs::create_dir_all(&repo_dir).unwrap(); + assert!(clone_fresh(&orch, &clone_url, Some("main")).await); + let second = orch + .tasks + .logs() + .tail_read(&app_key("setup"), DEFAULT_TAIL_BYTES) + .await; + + // Truncated, not appended: the second transcript describes ONLY the + // second clone, not both concatenated. + assert_eq!( + second.matches("clone --depth 1").count(), + 1, + "second transcript should describe exactly one clone, not two concatenated: {second:?}" + ); + } +} diff --git a/apps/native/crates/local-api/src/setup/dev.rs b/apps/native/crates/local-api/src/setup/dev.rs new file mode 100644 index 0000000000..f4db3d8515 --- /dev/null +++ b/apps/native/crates/local-api/src/setup/dev.rs @@ -0,0 +1,1026 @@ +//! Start step — spawns the discovered `dev`/`start` script, sniffs its bind +//! announcement from stdout, confirms with one HTTP probe, then transitions +//! `lifecycle` to `running` and emits `reload:{}`. Byte-parity in spirit +//! with `daemon/entry.ts`'s port-sniffer + probe wiring +//! (`process/port-sniffer.ts` + `probe.ts`), collapsed into a single-shot +//! "spawn, sniff, confirm-once, done" sequence rather than a standing +//! supervisor — see the `setup` module doc's "Scope narrower than the TS +//! daemon" section for what that drops (continuous crash detection, +//! dirty-baseline arming). +//! +//! The spawned process is registered in the SAME `state.tasks` registry the +//! bash/tasks/scripts families use (`TaskRegistry::next_id`/`insert`/ +//! `append_output`/`finalize`), so it shows up in `GET /_sandbox/tasks` and +//! streams via `/_sandbox/tasks/:id/stream` like any other task — reuses +//! `routes/scripts.rs`'s `build_command`/`kill_group`/`exit_status_to_code`/ +//! `classify_status`/`run_prefix_for` rather than re-deriving the same +//! process-spawn plumbing a third time in this crate. + +use std::future::Future; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use futures_util::FutureExt; +use regex::Regex; +use serde_json::{json, Value}; +use tokio::io::AsyncReadExt; + +use super::install::{manifest_present, pm_root}; +use super::SetupOrchestrator; +use crate::process_group::ProcessGroupChild; +use crate::routes::scripts::{ + build_command, classify_status, discover_scripts, exit_status_to_code, kill_group, + run_prefix_for, +}; +use crate::sandbox::persist::{DevProcessIdentity, DevProcessRecord}; +use crate::tasks::{now_ms, OutputStream, ProcessController, TaskEntry, TaskStatus, TaskSummary}; + +const WELL_KNOWN_STARTERS: [&str; 2] = ["dev", "start"]; +/// 40 attempts * 250ms = 10s — generous enough for a freshly `npm install`-ed +/// dev server to finish booting after it's already announced its bind port +/// (the announcement itself means the listener is up, so this is mostly +/// slack for a loaded CI box), bounded so a script that lies about being +/// ready can't hang the pipeline forever. +const PROBE_ATTEMPTS: u32 = 40; +const PROBE_INTERVAL: Duration = Duration::from_millis(250); +const IDENTITY_CAPTURE_ATTEMPTS: u32 = 20; +const IDENTITY_CAPTURE_INTERVAL: Duration = Duration::from_millis(25); +const IDENTITY_REFRESH_INTERVAL: Duration = Duration::from_secs(1); +const STALE_TERM_GRACE: Duration = Duration::from_millis(800); +const STALE_KILL_GRACE: Duration = Duration::from_secs(1); + +#[derive(Debug, PartialEq, Eq)] +enum GroupIdentityMatch { + Gone, + Verified(Vec), + Unverifiable, +} + +fn match_group_identity( + recorded: &[DevProcessIdentity], + current: Vec, +) -> GroupIdentityMatch { + if current.is_empty() { + return GroupIdentityMatch::Gone; + } + if recorded.is_empty() { + return GroupIdentityMatch::Unverifiable; + } + if current.iter().any(|candidate| recorded.contains(candidate)) { + GroupIdentityMatch::Verified(current) + } else { + GroupIdentityMatch::Unverifiable + } +} + +fn port_pattern() -> &'static Regex { + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + RE.get_or_init(|| { + // Byte-parity target: `process/port-sniffer.ts::URL_PATTERN`. + Regex::new( + r"(?:Local:|Listening on)[^\n]*?https?://(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)", + ) + .expect("static port-sniff regex is valid") + }) +} + +fn get_str<'a>(config: &'a Value, path: &[&str]) -> Option<&'a str> { + let mut cur = config; + for key in path { + cur = cur.get(key)?; + } + cur.as_str() +} + +/// Discovers a starter script and spawns it; on failure to find one, +/// transitions `lifecycle` to `start-failed` with a diagnostic message +/// (byte-parity with `diagnoseNoStartCommand`) and returns without spawning +/// anything. +pub(super) async fn run(orch: &Arc, config: &Value) { + let Some(pm) = + get_str(config, &["application", "packageManager", "name"]).filter(|s| !s.is_empty()) + else { + orch.transition_lifecycle(super::start_failed( + "no package manager configured — update the VM config to enable a dev server", + )); + return; + }; + let Some(run_prefix) = run_prefix_for(pm) else { + orch.transition_lifecycle(super::start_failed(format!( + "unknown package manager: {pm}" + ))); + return; + }; + + let root = match pm_root(config, &orch.repo_dir).await { + Ok(root) => root, + Err(error) => { + orch.transition_lifecycle(super::start_failed(error)); + return; + } + }; + let scripts = discover_and_mark_scripts(orch, &root, pm); + let Some(starter) = WELL_KNOWN_STARTERS + .iter() + .find(|s| scripts.iter().any(|x| x == *s)) + else { + orch.transition_lifecycle(super::start_failed(diagnose_no_start_command( + pm, &root, &scripts, + ))); + return; + }; + + let id = orch.tasks.next_id(); + orch.claim_dev_task(&id); + orch.transition_lifecycle(json!({ "phase": "starting" })); + + // Reap any dev child a PREVIOUS spawn left behind — either an orphan + // from a killed local-api process or the current live child when this is + // a user-driven reboot. A numeric PGID is not identity: after every old + // member exits, the kernel may reuse it for an unrelated group. Signals + // are authorized only by an exact member birth identity persisted while + // local-api owned the group. Legacy/unreadable observations fail closed. + let sandbox_root = orch + .repo_dir + .parent() + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|| orch.repo_dir.clone()); + if let Some(record) = crate::sandbox::persist::read_dev_process(&sandbox_root) { + if let Err(error) = reap_persisted_dev_group(&record).await { + tracing::warn!( + pid = record.pid, + pgid = record.pgid, + %error, + "refusing to signal an unverified persisted dev process group" + ); + orch.transition_lifecycle(super::start_failed(format!( + "cannot safely identify the previous dev server: {error}" + ))); + orch.finish_dev_task(&id); + return; + } + crate::sandbox::persist::clear_dev_process(&sandbox_root); + } + + let command = format!("{run_prefix} {starter}"); + let mut env = std::collections::HashMap::new(); + // Byte-parity with `constants.ts::buildDevEnv`: HOST/HOSTNAME always; + // PORT only when `application.port` is configured (most dev servers + // that DO honor PORT will bind there; the ones that don't are exactly + // why the port-sniffer below exists). + env.insert("HOST".to_string(), "0.0.0.0".to_string()); + env.insert("HOSTNAME".to_string(), "0.0.0.0".to_string()); + if let Some(port) = config + .get("application") + .and_then(|a| a.get("port")) + .and_then(Value::as_u64) + { + env.insert("PORT".to_string(), port.to_string()); + } + + let controller = ProcessController::new(); + let summary = TaskSummary { + id: id.clone(), + command: command.clone(), + status: TaskStatus::Running, + exit_code: None, + started_at: now_ms(), + finished_at: None, + timed_out: false, + truncated: false, + log_name: Some((*starter).to_string()), + intentional: None, + }; + if !orch.register_task(TaskEntry::new(summary, Some(controller.kill_handle()))) { + orch.finish_dev_task(&id); + return; + } + if let Some(signal) = controller.requested() { + orch.tasks + .finalize(&id, TaskStatus::Killed, signal.exit_code(), false); + emit_tasks_event(orch); + orch.finish_dev_task(&id); + return; + } + + let mut cmd = build_command(&command, &root, &env); + cmd.kill_on_drop(true); + let mut child = + match ProcessGroupChild::spawn(&mut cmd, orch.tasks.child_lifetime_lock_path()).await { + Ok(child) => child, + Err(e) => { + orch.tasks.finalize(&id, TaskStatus::Failed, -1, false); + emit_tasks_event(orch); + if orch.finish_dev_task(&id) { + orch.transition_lifecycle(super::start_failed(format!("spawn error: {e}"))); + } + return; + } + }; + let pid = child.id(); + let (Some(stdout_pipe), Some(stderr_pipe)) = (child.take_stdout(), child.take_stderr()) else { + child.signal(crate::tasks::KillSignal::Kill).await; + let _ = child.wait().await; + orch.tasks.finalize(&id, TaskStatus::Failed, -1, false); + emit_tasks_event(orch); + if orch.finish_dev_task(&id) { + orch.transition_lifecycle(super::start_failed("spawn error: missing stdio pipe")); + } + return; + }; + emit_tasks_event(orch); + let Some(pid) = pid else { + child + .kill_and_reap(STALE_KILL_GRACE, "dev process missing pid cleanup") + .await; + orch.tasks.finalize(&id, TaskStatus::Failed, -1, false); + emit_tasks_event(orch); + if orch.finish_dev_task(&id) { + orch.transition_lifecycle(super::start_failed( + "cannot safely capture dev process identity: spawned process has no pid", + )); + } + return; + }; + let record_started_at = now_ms() as u64; + let identities = match capture_process_group_identity( + || observe_process_group(pid), + IDENTITY_CAPTURE_ATTEMPTS, + IDENTITY_CAPTURE_INTERVAL, + ) + .await + { + Ok(identities) => identities, + Err(error) => { + tracing::error!(pid, %error, "could not capture initial dev process identity"); + child + .kill_and_reap(STALE_KILL_GRACE, "unidentifiable dev process cleanup") + .await; + orch.tasks.finalize(&id, TaskStatus::Failed, -1, false); + emit_tasks_event(orch); + if orch.finish_dev_task(&id) { + orch.transition_lifecycle(super::start_failed(format!( + "cannot safely capture dev process identity: {error}" + ))); + } + return; + } + }; + crate::sandbox::persist::write_dev_process( + &sandbox_root, + &DevProcessRecord { + pid, + pgid: pid, + command: command.clone(), + started_at: record_started_at, + identities, + }, + ); + + let watch_orch = orch.clone(); + let panic_orch = orch.clone(); + let panic_id = id.clone(); + let starter_owned = (*starter).to_string(); + tokio::spawn(async move { + let watched = std::panic::AssertUnwindSafe(drain_and_watch( + watch_orch, + id, + &mut child, + stdout_pipe, + stderr_pipe, + Some(pid), + starter_owned, + controller, + sandbox_root, + Some(record_started_at), + )) + .catch_unwind() + .await; + if watched.is_err() { + child + .kill_and_reap(STALE_KILL_GRACE, "dev process-owner panic cleanup") + .await; + panic_orch + .tasks + .finalize(&panic_id, TaskStatus::Failed, -1, false); + emit_tasks_event(&panic_orch); + if panic_orch.finish_dev_task(&panic_id) && !panic_orch.is_closed() { + panic_orch.transition_lifecycle(super::start_failed("dev process owner panicked")); + } + } + }); +} + +fn discover_and_mark_scripts( + orch: &SetupOrchestrator, + root: &Path, + package_manager: &str, +) -> Vec { + let scripts = discover_scripts(root, Some(package_manager)); + orch.mark_discovered_scripts(scripts.clone()); + scripts +} + +/// `pub(super)`: shared diagnostic text builder — byte-parity in spirit with +/// `orchestrator.ts::diagnoseNoStartCommand` (exact wording isn't pinned by +/// either e2e oracle, only `typeof lifecycle.state.error === "string"`). +fn diagnose_no_start_command(pm: &str, root: &Path, scripts: &[String]) -> String { + if scripts.is_empty() { + if !manifest_present(pm, root) { + let manifest = if pm == "deno" { + "deno.json or deno.jsonc" + } else { + "package.json" + }; + return format!( + "no package manifest ({manifest}) found at {} — update the VM config if a dev server should run", + root.display() + ); + } + return format!( + "no scripts defined in {}/package.json — update the VM config if a dev server should run", + root.display() + ); + } + format!( + "no 'dev' or 'start' script found (available: {}) — update the VM config to set the correct start script", + scripts.join(", ") + ) +} + +#[allow(clippy::too_many_arguments)] +async fn drain_and_watch( + orch: Arc, + id: String, + child: &mut ProcessGroupChild, + mut stdout_pipe: tokio::process::ChildStdout, + mut stderr_pipe: tokio::process::ChildStderr, + pid: Option, + starter: String, + controller: ProcessController, + sandbox_root: std::path::PathBuf, + record_started_at: Option, +) { + let mut stdout_open = true; + let mut stderr_open = true; + let mut exited = false; + let mut exit_status: Option = None; + let mut sniffed = false; + let mut observed_signal = None; + + let mut so_chunk = [0u8; 8192]; + let mut se_chunk = [0u8; 8192]; + let mut identity_refresh = tokio::time::interval(IDENTITY_REFRESH_INTERVAL); + identity_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // `interval`'s first tick is immediate. The initial identity was already + // captured synchronously after spawn, so consume it before the select. + identity_refresh.tick().await; + + loop { + if exited && !stdout_open && !stderr_open { + break; + } + tokio::select! { + res = stdout_pipe.read(&mut so_chunk), if stdout_open => { + match res { + Ok(0) | Err(_) => stdout_open = false, + Ok(n) => { + let text = String::from_utf8_lossy(&so_chunk[..n]).into_owned(); + // Both this task's own file AND the stable + // `"app/"` transcript (`routes/events.rs` + // replays the latter), then a live `log` SSE frame so + // the dev server's stdout renders in the preview + // terminal's `dev`/`start` tab. RAW text — the + // frontend normalizes `\r\n`. + orch.tasks + .append_log(&id, &starter, OutputStream::Stdout, &text, &orch.broadcaster) + .await; + if !sniffed { + if let Some(port) = sniff_port(&text) { + sniffed = true; + let probe_orch = orch.clone(); + let probe_task_id = id.clone(); + tokio::spawn(async move { + confirm_running(probe_orch, probe_task_id, port).await; + }); + } + } + } + } + } + res = stderr_pipe.read(&mut se_chunk), if stderr_open => { + match res { + Ok(0) | Err(_) => stderr_open = false, + Ok(n) => { + let text = String::from_utf8_lossy(&se_chunk[..n]).into_owned(); + orch.tasks + .append_log(&id, &starter, OutputStream::Stderr, &text, &orch.broadcaster) + .await; + } + } + } + // Keep the leader unreaped while descendants hold inherited + // output pipes. That pins the PGID, so shutdown signals cannot + // ever target a recycled group and double-forked dev children + // remain controllable until their streams close. + status = child.wait(), if !exited && !stdout_open && !stderr_open => { + exited = true; + exit_status = status.ok(); + } + sig = controller.wait_for_change(observed_signal), if !exited => { + observed_signal = Some(sig); + child.signal(sig).await; + } + _ = identity_refresh.tick() => { + if let (Some(pid), Some(started_at)) = (pid, record_started_at) { + refresh_persisted_group_identities( + &sandbox_root, + pid, + started_at, + ).await; + } + } + } + } + + let raw_exit = exit_status.map(exit_status_to_code).unwrap_or(-1); + let status = classify_status(false, raw_exit); + let intentional = orch + .tasks + .get(&id) + .and_then(|s| s.intentional) + .unwrap_or(false); + let was_current = orch.finish_dev_task(&id); + orch.tasks.finalize(&id, status, raw_exit, false); + emit_tasks_event(&orch); + + // Byte-parity with `SetupOrchestrator`'s `taskManager.onTaskExit` hook: + // an unexpected (non-intentional, non-zero) exit of the dev script + // surfaces a terminal `start-failed` so the UI shows a retry affordance + // instead of sitting on the boot animation forever. + if was_current && !orch.is_closed() && !intentional && raw_exit != 0 { + orch.transition_lifecycle(super::start_failed(format!( + "{starter} script exited with code {raw_exit}" + ))); + } +} + +fn sniff_port(text: &str) -> Option { + let caps = port_pattern().captures(text)?; + caps.get(1)?.as_str().parse::().ok() +} + +/// One-shot confirm: HEAD/GET the sniffed port until it answers (or attempts +/// run out), then transition `lifecycle` to `running` and emit `reload:{}` +/// if the pipeline wasn't already there — byte-parity in spirit with +/// `probe.ts`'s booting -> online transition + `entry.ts`'s +/// `if (wasDown) broadcaster.emit("reload", {})`. +async fn confirm_running(orch: Arc, task_id: String, port: u16) { + if orch.is_closed() || !orch.is_current_dev_task(&task_id) { + return; + } + let Ok(client) = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + else { + return; + }; + for _ in 0..PROBE_ATTEMPTS { + if orch.is_closed() || !orch.is_current_dev_task(&task_id) { + return; + } + match client.get(format!("http://127.0.0.1:{port}/")).send().await { + Ok(res) => { + if orch.is_closed() || !orch.is_current_dev_task(&task_id) { + return; + } + let html_support = res + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_ascii_lowercase().contains("text/html")) + .unwrap_or(false); + let was_down = orch.lifecycle_snapshot()["phase"].as_str() != Some("running"); + if !orch.transition_dev_lifecycle( + &task_id, + json!({ + "phase": "running", + "port": port, + "htmlSupport": html_support, + }), + ) { + return; + } + if was_down && orch.is_current_dev_task(&task_id) { + orch.broadcaster.emit("reload", json!({})); + } + return; + } + Err(_) => { + if orch.is_closed() || !orch.is_current_dev_task(&task_id) { + return; + } + tokio::time::sleep(PROBE_INTERVAL).await; + } + } + } +} + +/// `{"active": [{id, command, logName?}]}` — same shape +/// `routes/scripts.rs`'s own copy broadcasts (see that file's module doc on +/// why this is duplicated per family rather than shared). +fn emit_tasks_event(orch: &SetupOrchestrator) { + let active: Vec = orch + .tasks + .list(Some(&[TaskStatus::Running])) + .into_iter() + .map(|t| { + let mut obj = json!({"id": t.id, "command": t.command}); + if let Some(name) = t.log_name { + obj["logName"] = json!(name); + } + obj + }) + .collect(); + orch.broadcaster.emit("tasks", json!({"active": active})); +} + +async fn reap_persisted_dev_group(record: &DevProcessRecord) -> Result<(), String> { + let current = observe_process_group(record.pgid).await?; + let authorized = match match_group_identity(&record.identities, current) { + GroupIdentityMatch::Gone => return Ok(()), + GroupIdentityMatch::Verified(current) => current, + GroupIdentityMatch::Unverifiable => { + return Err("persisted process identities do not match the current group".to_string()) + } + }; + + tracing::info!( + pid = record.pid, + pgid = record.pgid, + "stopping previous dev server before start" + ); + kill_group(record.pgid, "-TERM").await; + tokio::time::sleep(STALE_TERM_GRACE).await; + + let after_term = observe_process_group(record.pgid).await?; + match match_group_identity(&authorized, after_term) { + GroupIdentityMatch::Gone => Ok(()), + GroupIdentityMatch::Unverifiable => { + Err("process group identity changed after TERM; refusing KILL".to_string()) + } + GroupIdentityMatch::Verified(survivors) => { + kill_group(record.pgid, "-KILL").await; + let deadline = tokio::time::Instant::now() + STALE_KILL_GRACE; + loop { + tokio::time::sleep(Duration::from_millis(50)).await; + let after_kill = observe_process_group(record.pgid).await?; + match match_group_identity(&survivors, after_kill) { + GroupIdentityMatch::Gone => return Ok(()), + GroupIdentityMatch::Unverifiable => { + return Err( + "process group identity changed after KILL; refusing another signal" + .to_string(), + ) + } + GroupIdentityMatch::Verified(_) => { + if tokio::time::Instant::now() >= deadline { + return Err( + "verified previous dev process group survived KILL".to_string() + ); + } + } + } + } + } + } +} + +async fn capture_process_group_identity( + mut observe: F, + attempts: u32, + interval: Duration, +) -> Result, String> +where + F: FnMut() -> Fut, + Fut: Future, String>>, +{ + let attempts = attempts.max(1); + let mut last_error = "process group has no observable members".to_string(); + for attempt in 0..attempts { + match observe().await { + Ok(identities) if !identities.is_empty() => return Ok(identities), + Ok(_) => { + last_error = "process group has no observable members".to_string(); + } + Err(error) => last_error = error, + } + if attempt + 1 < attempts { + tokio::time::sleep(interval).await; + } + } + Err(format!( + "{last_error} after {attempts} observation attempt(s)" + )) +} + +async fn refresh_persisted_group_identities(sandbox_root: &Path, pid: u32, started_at: u64) { + let identities = match observe_process_group(pid).await { + Ok(identities) if !identities.is_empty() => identities, + Ok(_) => return, + Err(error) => { + tracing::debug!(pid, %error, "could not refresh dev process identities"); + return; + } + }; + + // Re-read AFTER the asynchronous observation. A user-driven restart may + // have replaced the record while `ps` was running; an old watcher must + // never overwrite the new process's identity metadata. + let Some(mut record) = crate::sandbox::persist::read_dev_process(sandbox_root) else { + return; + }; + if record.pid != pid || record.pgid != pid || record.started_at != started_at { + return; + } + record.identities = identities; + crate::sandbox::persist::write_dev_process(sandbox_root, &record); +} + +#[cfg(unix)] +async fn observe_process_group(pgid: u32) -> Result, String> { + let group = tokio::process::Command::new("pgrep") + .args(["-g", &pgid.to_string()]) + .output() + .await + .map_err(|error| format!("cannot enumerate process group {pgid}: {error}"))?; + if !group.status.success() { + if group.status.code() == Some(1) { + return Ok(Vec::new()); + } + return Err(format!( + "cannot enumerate process group {pgid}: pgrep exited {:?}", + group.status.code() + )); + } + + let pids = String::from_utf8(group.stdout) + .map_err(|error| format!("pgrep returned non-UTF-8 output: {error}"))?; + let mut identities = Vec::new(); + for raw_pid in pids.lines().filter(|line| !line.trim().is_empty()) { + let pid = raw_pid + .trim() + .parse::() + .map_err(|error| format!("pgrep returned invalid pid {raw_pid:?}: {error}"))?; + if let Some(identity) = observe_process_identity(pid, pgid).await? { + identities.push(identity); + } + } + identities.sort_by_key(|identity| identity.pid); + Ok(identities) +} + +#[cfg(unix)] +async fn observe_process_identity( + pid: u32, + expected_pgid: u32, +) -> Result, String> { + let process = tokio::process::Command::new("ps") + .args([ + "-ww", + "-p", + &pid.to_string(), + "-o", + "pid=", + "-o", + "pgid=", + "-o", + "state=", + "-o", + "lstart=", + "-o", + "comm=", + ]) + .output() + .await + .map_err(|error| format!("cannot inspect process {pid}: {error}"))?; + if !process.status.success() { + if process.status.code() == Some(1) { + return Ok(None); + } + return Err(format!( + "cannot inspect process {pid}: ps exited {:?}", + process.status.code() + )); + } + let output = String::from_utf8(process.stdout) + .map_err(|error| format!("ps returned non-UTF-8 output for {pid}: {error}"))?; + let fields: Vec<&str> = output.split_whitespace().collect(); + if fields.len() < 9 { + return Err(format!("cannot parse process identity for {pid}")); + } + let observed_pid = fields[0] + .parse::() + .map_err(|error| format!("cannot parse observed pid for {pid}: {error}"))?; + let observed_pgid = fields[1] + .parse::() + .map_err(|error| format!("cannot parse observed pgid for {pid}: {error}"))?; + if observed_pid != pid || observed_pgid != expected_pgid { + return Err(format!( + "process {pid} no longer belongs to group {expected_pgid}" + )); + } + if fields[2].starts_with('Z') { + return Ok(None); + } + + Ok(Some(DevProcessIdentity { + pid, + birth: fields[3..8].join(" "), + executable: fields[8..].join(" "), + })) +} + +#[cfg(not(unix))] +async fn observe_process_group(_pgid: u32) -> Result, String> { + Err("durable process-group identity is unavailable on this platform".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn identity(pid: u32, birth: &str, executable: &str) -> DevProcessIdentity { + DevProcessIdentity { + pid, + birth: birth.to_string(), + executable: executable.to_string(), + } + } + + #[test] + fn sniff_port_matches_local_announcement() { + let text = "Local: http://localhost:5174/\n"; + assert_eq!(sniff_port(text), Some(5174)); + } + + #[test] + fn sniff_port_matches_listening_on_announcement() { + let text = "Listening on http://0.0.0.0:3000\n"; + assert_eq!(sniff_port(text), Some(3000)); + } + + #[test] + fn sniff_port_ignores_unrelated_urls() { + let text = "fetched https://example.com:9999/ ok\n"; + assert_eq!(sniff_port(text), None); + } + + #[test] + fn diagnose_reports_missing_manifest() { + let dir = tempfile::tempdir().unwrap(); + let msg = diagnose_no_start_command("npm", dir.path(), &[]); + assert!(msg.contains("package.json")); + } + + #[test] + fn diagnose_reports_missing_starter_script() { + let msg = diagnose_no_start_command( + "npm", + Path::new("/tmp"), + &["build".to_string(), "test".to_string()], + ); + assert!(msg.contains("build")); + assert!(msg.contains("test")); + } + + #[test] + fn start_only_discovery_restores_the_scripts_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let repo = dir.path().join("repo"); + std::fs::create_dir_all(&repo).unwrap(); + std::fs::write( + repo.join("package.json"), + r#"{"scripts":{"dev":"next dev","test":"bun test"}}"#, + ) + .unwrap(); + let logs = Arc::new(crate::log_store::LogStore::new(dir.path().join("logs"))); + let broadcaster = Arc::new(crate::events::Broadcaster::new()); + let mut events = broadcaster.subscribe(); + let orch = SetupOrchestrator::new( + repo.clone(), + repo.clone(), + Arc::new(crate::config::ConfigStore::new()), + Arc::new(crate::tasks::TaskRegistry::new(logs)), + broadcaster, + ); + + let scripts = discover_and_mark_scripts(&orch, &repo, "bun"); + + assert_eq!(scripts, vec!["dev", "test"]); + assert_eq!(orch.discovered_scripts(), Some(scripts.clone())); + let event = events.try_recv().unwrap(); + assert_eq!(event.name, "scripts"); + assert_eq!(event.data, json!({ "scripts": scripts })); + } + + #[test] + fn recycled_pid_and_pgid_do_not_authorize_a_signal() { + let recorded = vec![identity(42, "Wed Jul 22 11:00:00 2026", "next-server")]; + let reused = vec![identity(42, "Wed Jul 22 12:00:00 2026", "next-server")]; + assert_eq!( + match_group_identity(&recorded, reused), + GroupIdentityMatch::Unverifiable + ); + } + + #[test] + fn legacy_or_unverifiable_metadata_does_not_authorize_a_signal() { + let current = vec![identity(42, "Wed Jul 22 11:00:00 2026", "next-server")]; + assert_eq!( + match_group_identity(&[], current), + GroupIdentityMatch::Unverifiable + ); + } + + #[test] + fn recorded_surviving_child_keeps_orphan_cleanup_authorized() { + let child = identity(43, "Wed Jul 22 11:00:01 2026", "next-server"); + let recorded = vec![ + identity(42, "Wed Jul 22 11:00:00 2026", "sh -c bun run dev"), + child.clone(), + ]; + assert_eq!( + match_group_identity(&recorded, vec![child.clone()]), + GroupIdentityMatch::Verified(vec![child]) + ); + } + + #[test] + fn empty_observation_means_the_recorded_group_is_gone() { + let recorded = vec![identity(42, "Wed Jul 22 11:00:00 2026", "next-server")]; + assert_eq!( + match_group_identity(&recorded, Vec::new()), + GroupIdentityMatch::Gone + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn observes_a_real_spawned_process_group_identity() { + let dir = tempfile::tempdir().unwrap(); + let mut command = build_command("sleep 30", dir.path(), &std::collections::HashMap::new()); + // Production uses `ProcessGroupChild::spawn`, which supplies an + // independent anchored PGID. This test intentionally exercises the + // persisted-orphan observer without that owner, so create a dedicated + // group explicitly before dropping to the raw child handle. + command.process_group(0); + let mut child = command.spawn().unwrap(); + let pid = child.id().unwrap(); + let observed = capture_process_group_identity( + || observe_process_group(pid), + IDENTITY_CAPTURE_ATTEMPTS, + IDENTITY_CAPTURE_INTERVAL, + ) + .await; + kill_group(pid, "-KILL").await; + let _ = child.wait().await; + + let identities = observed.unwrap(); + assert!(identities.iter().any(|identity| identity.pid == pid)); + assert!(identities.iter().all(|identity| !identity.birth.is_empty())); + assert!(identities + .iter() + .all(|identity| !identity.executable.is_empty())); + } + + #[cfg(unix)] + #[tokio::test] + async fn reaps_a_verified_orphan_after_its_group_leader_exits() { + let dir = tempfile::tempdir().unwrap(); + let mut command = build_command( + "sleep 30 /dev/null 2>&1 &", + dir.path(), + &std::collections::HashMap::new(), + ); + command.process_group(0); + let mut leader = command.spawn().unwrap(); + let pgid = leader.id().unwrap(); + let _ = leader.wait().await; + + let identities = observe_process_group(pgid).await.unwrap(); + assert!( + !identities.is_empty(), + "the background child must survive its group leader" + ); + let record = DevProcessRecord { + pid: pgid, + pgid, + command: "sleep 30 &".to_string(), + started_at: 1, + identities, + }; + let reaped = reap_persisted_dev_group(&record).await; + if reaped.is_err() { + // Exact group created by this test; cleanup must not leak it even + // if the assertion below reports an observation regression. + kill_group(pgid, "-KILL").await; + } + + reaped.unwrap(); + assert!(observe_process_group(pgid).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn closed_orchestrator_rejects_late_probe_transition() { + let dir = tempfile::tempdir().unwrap(); + let logs = Arc::new(crate::log_store::LogStore::new(dir.path().join("logs"))); + let orch = SetupOrchestrator::new( + dir.path().join("repo"), + dir.path().join("repo"), + Arc::new(crate::config::ConfigStore::new()), + Arc::new(crate::tasks::TaskRegistry::new(logs)), + Arc::new(crate::events::Broadcaster::new()), + ); + orch.claim_dev_task("dev-task"); + orch.close(); + + confirm_running(orch.clone(), "dev-task".to_string(), 1).await; + + assert_eq!(orch.lifecycle_snapshot(), json!({ "phase": "idle" })); + } + + #[tokio::test] + async fn replaced_dev_task_rejects_late_probe_transition() { + let dir = tempfile::tempdir().unwrap(); + let logs = Arc::new(crate::log_store::LogStore::new(dir.path().join("logs"))); + let orch = SetupOrchestrator::new( + dir.path().join("repo"), + dir.path().join("repo"), + Arc::new(crate::config::ConfigStore::new()), + Arc::new(crate::tasks::TaskRegistry::new(logs)), + Arc::new(crate::events::Broadcaster::new()), + ); + orch.claim_dev_task("old-dev-task"); + orch.transition_lifecycle(json!({ "phase": "starting" })); + orch.claim_dev_task("replacement-dev-task"); + + confirm_running(orch.clone(), "old-dev-task".to_string(), 1).await; + + assert_eq!( + orch.lifecycle_snapshot(), + json!({ "phase": "starting" }), + "a probe owned by the replaced spawn must not publish running" + ); + assert!( + !orch.finish_dev_task("old-dev-task"), + "the replaced watcher must not clear its replacement's token" + ); + assert!(orch.is_current_dev_task("replacement-dev-task")); + } + + #[tokio::test] + async fn process_identity_capture_retries_empty_observations() { + let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed_attempts = attempts.clone(); + let captured = capture_process_group_identity( + move || { + let attempt = observed_attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + if attempt < 2 { + Ok(Vec::new()) + } else { + Ok(vec![identity(42, "Wed Jul 22 11:00:00 2026", "dev")]) + } + } + }, + 3, + Duration::ZERO, + ) + .await + .unwrap(); + + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 3); + assert_eq!(captured.len(), 1); + } + + #[tokio::test] + async fn process_identity_capture_fails_after_bounded_empty_observations() { + let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed_attempts = attempts.clone(); + let error = capture_process_group_identity( + move || { + observed_attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async { Ok(Vec::new()) } + }, + 3, + Duration::ZERO, + ) + .await + .unwrap_err(); + + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 3); + assert!(error.contains("after 3 observation attempt(s)")); + } +} diff --git a/apps/native/crates/local-api/src/setup/install.rs b/apps/native/crates/local-api/src/setup/install.rs new file mode 100644 index 0000000000..c63338eb11 --- /dev/null +++ b/apps/native/crates/local-api/src/setup/install.rs @@ -0,0 +1,408 @@ +//! Install step — byte-parity target: `daemon/setup/install.ts` (argv + +//! manifest-gating only; the deps-cache/golden-cache machinery is dropped, +//! see the `setup` module's doc comment). Runs the package manager's install +//! command when a manifest is present; broadcasts the discovered script set +//! exactly when the TS source does — ONLY when a package manager is +//! configured (byte-parity with `stepInstall`'s `if (!pm) { return true; }` +//! early-return-WITHOUT-broadcasting branch; the manifest-absent case still +//! broadcasts an empty `[]`, matching `spawnInstall` returning `null` -> +//! `markInstallSucceeded` still runs). + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::Value; +use tokio::io::AsyncReadExt; + +use super::SetupOrchestrator; +use crate::process_group::ProcessGroupChild; +use crate::routes::scripts::{classify_status, discover_scripts, exit_status_to_code}; +use crate::tasks::{now_ms, OutputStream, ProcessController, TaskEntry, TaskStatus, TaskSummary}; + +/// Resolves the package-manager cwd inside `repo_dir`. Config validation +/// rejects lexical escapes; canonicalization here also rejects symlinks that +/// point outside the repository before install/dev hands the path to a child. +/// `pub(super)`: shared with `setup/dev.rs` so both operations enforce the +/// same boundary. +pub(super) async fn pm_root(config: &Value, repo_dir: &Path) -> Result { + let Some(path) = config + .get("application") + .and_then(|a| a.get("packageManager")) + .and_then(|p| p.get("path")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + else { + return Ok(repo_dir.to_path_buf()); + }; + + crate::config::validate::validate_package_manager_path(path)?; + let canonical_repo = tokio::fs::canonicalize(repo_dir).await.map_err(|error| { + format!( + "cannot resolve repository root {}: {error}", + repo_dir.display() + ) + })?; + let candidate = repo_dir.join(path); + let canonical_candidate = tokio::fs::canonicalize(&candidate).await.map_err(|error| { + format!( + "packageManager.path does not resolve inside the repository ({}): {error}", + candidate.display() + ) + })?; + if !canonical_candidate.starts_with(&canonical_repo) { + return Err("packageManager.path resolves outside the repository".to_string()); + } + let metadata = tokio::fs::metadata(&canonical_candidate) + .await + .map_err(|error| format!("cannot inspect packageManager.path: {error}"))?; + if !metadata.is_dir() { + return Err("packageManager.path must resolve to a directory".to_string()); + } + Ok(canonical_candidate) +} + +/// Byte-parity with `PACKAGE_MANAGER_DAEMON_CONFIG[pm].manifests` gating in +/// `spawnInstall`. `pub(super)`: reused by `setup/dev.rs`'s +/// `diagnose_no_start_command`. +pub(super) fn manifest_present(pm: &str, root: &Path) -> bool { + match pm { + "deno" => ["deno.json", "deno.jsonc", "package.json"] + .iter() + .any(|f| root.join(f).exists()), + _ => root.join("package.json").exists(), + } +} + +/// Byte-parity with `PACKAGE_MANAGER_DAEMON_CONFIG[pm].installArgv`. `deno` +/// has none — it auto-fetches on first `deno task` (no separate install +/// step, matching the TS source's comment on why `deno` has no +/// `installArgv`). +fn install_argv(pm: &str) -> Option<&'static [&'static str]> { + match pm { + "npm" => Some(&["npm", "install"]), + "pnpm" => Some(&["pnpm", "install"]), + "yarn" => Some(&["yarn", "install"]), + "bun" => Some(&["bun", "install"]), + _ => None, + } +} + +/// Returns `true` on success (including "nothing configured"/"nothing to +/// install" — both forward progress); `false` only when the install command +/// itself exits non-zero, after transitioning `lifecycle` to +/// `install-failed`. +pub(super) async fn run(orch: &Arc, config: &Value) -> bool { + let Some(pm) = config + .get("application") + .and_then(|a| a.get("packageManager")) + .and_then(|p| p.get("name")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + else { + // No package manager configured — proceed to start, which will + // diagnose. Byte-parity: `stepInstall` returns `true` here WITHOUT + // calling `broadcastDiscoveredScripts`, so `discovered_scripts` + // stays `None` (the SSE handshake then omits the `"scripts"` frame + // entirely, matching the daemon's `if (scripts) ...` guard on a + // still-`null` `latestScripts`). + return true; + }; + + orch.transition_lifecycle(serde_json::json!({ "phase": "installing" })); + + let root = match pm_root(config, &orch.repo_dir).await { + Ok(root) => root, + Err(error) => { + orch.transition_lifecycle(super::install_failed(error)); + return false; + } + }; + if manifest_present(pm, &root) { + if let Some(argv) = install_argv(pm) { + match run_install_cmd(orch, argv, &root).await { + Ok(Some(true)) => {} + Ok(Some(false)) => { + orch.transition_lifecycle(super::install_failed("install command failed")); + return false; + } + Ok(None) => return false, + Err(e) => { + orch.transition_lifecycle(super::install_failed(format!( + "install command failed: {e}" + ))); + return false; + } + } + } + // `deno`: no install argv — auto-fetches lazily at `deno task` time. + } + // No manifest at all: skip the install command entirely (byte-parity — + // `spawnInstall` returns `null`, the caller treats that as success). + + let scripts = discover_scripts(&root, Some(pm)); + orch.mark_discovered_scripts(scripts); + true +} + +/// Spawns the install command, piping its stdout/stderr into `"log"` SSE +/// frames (`{source: "setup", data: }` — the drawer's default tab) +/// so the install phase is observable in the preview terminal, then waits for +/// exit. Registers a "setup" task (like `setup/dev.rs`) so the output is stored +/// for replay on a late SSE connect and shutdown can terminate its whole +/// process group. Emits RAW text; the frontend normalizes `\r\n`. +async fn run_install_cmd( + orch: &Arc, + argv: &[&str], + cwd: &Path, +) -> Result, String> { + let (cmd, args) = argv + .split_first() + .ok_or_else(|| "empty install argv".to_string())?; + // Register a "setup" task so the install output is STORED (file-backed — + // see `crate::log_store::LogStore` and `tasks/registry.rs`'s module doc) and + // can therefore be REPLAYED by `/_sandbox/events` on connect — a terminal + // opened after install ran would otherwise show a blank Setup tab. + // `log_name` = "setup" matches the drawer default tab and the + // `emit("log", {source:"setup"})` frames below. Register BEFORE spawn: + // shutdown can reject this task or durably signal it before a pid exists. + let task_id = orch.tasks.next_id(); + let controller = ProcessController::new(); + if !orch.register_task(TaskEntry::new( + TaskSummary { + id: task_id.clone(), + command: argv.join(" "), + status: TaskStatus::Running, + exit_code: None, + started_at: now_ms(), + finished_at: None, + timed_out: false, + truncated: false, + log_name: Some("setup".to_string()), + intentional: None, + }, + Some(controller.kill_handle()), + )) { + return Ok(None); + } + if let Some(signal) = controller.requested() { + orch.tasks + .finalize(&task_id, TaskStatus::Killed, signal.exit_code(), false); + return Ok(None); + } + + let mut command = tokio::process::Command::new(cmd); + command + .args(args) + .current_dir(cwd) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + let mut child = + match ProcessGroupChild::spawn(&mut command, orch.tasks.child_lifetime_lock_path()).await { + Ok(child) => child, + Err(error) => { + orch.tasks.finalize(&task_id, TaskStatus::Failed, -1, false); + return Err(format!("{cmd}: {error}")); + } + }; + let (Some(mut stdout_pipe), Some(mut stderr_pipe)) = (child.take_stdout(), child.take_stderr()) + else { + child.signal(crate::tasks::KillSignal::Kill).await; + let _ = child.wait().await; + orch.tasks.finalize(&task_id, TaskStatus::Failed, -1, false); + return Err(format!("{cmd}: missing stdio pipe")); + }; + + let mut stdout_open = true; + let mut stderr_open = true; + let mut exited = false; + let mut exit_status: Option = None; + let mut so_chunk = [0u8; 8192]; + let mut se_chunk = [0u8; 8192]; + let mut observed_signal = None; + + loop { + if exited && !stdout_open && !stderr_open { + break; + } + tokio::select! { + res = stdout_pipe.read(&mut so_chunk), if stdout_open => { + match res { + Ok(0) | Err(_) => stdout_open = false, + Ok(n) => { + let text = String::from_utf8_lossy(&so_chunk[..n]).into_owned(); + orch.tasks + .append_log(&task_id, "setup", OutputStream::Stdout, &text, &orch.broadcaster) + .await; + } + } + } + res = stderr_pipe.read(&mut se_chunk), if stderr_open => { + match res { + Ok(0) | Err(_) => stderr_open = false, + Ok(n) => { + let text = String::from_utf8_lossy(&se_chunk[..n]).into_owned(); + orch.tasks + .append_log(&task_id, "setup", OutputStream::Stderr, &text, &orch.broadcaster) + .await; + } + } + } + status = child.wait(), if !exited && !stdout_open && !stderr_open => { + exited = true; + exit_status = status.ok(); + } + signal = controller.wait_for_change(observed_signal), if !exited => { + observed_signal = Some(signal); + child.signal(signal).await; + } + } + } + + // Finalize the "setup" task so its stored output stops reading as a live + // Running process (the file-backed output is preserved for replay either + // way). A coordinated shutdown can classify it as killed; setup itself + // never applies a timeout, so `timed_out` is always false. + let raw_exit_code = exit_status.map(exit_status_to_code).unwrap_or(-1); + let status = classify_status(false, raw_exit_code); + orch.tasks.finalize(&task_id, status, raw_exit_code, false); + + if controller.requested().is_some() { + Ok(None) + } else { + Ok(Some(exit_status.map(|s| s.success()).unwrap_or(false))) + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use serde_json::json; + use std::time::Duration; + + use crate::config::ConfigStore; + use crate::events::Broadcaster; + use crate::log_store::LogStore; + use crate::tasks::TaskRegistry; + + fn test_orchestrator(repo_dir: PathBuf, root: &Path) -> Arc { + let logs = Arc::new(LogStore::new(root.join("logs"))); + SetupOrchestrator::new( + repo_dir.clone(), + repo_dir, + Arc::new(ConfigStore::new()), + Arc::new(TaskRegistry::new(logs)), + Arc::new(Broadcaster::new()), + ) + } + + #[tokio::test] + async fn package_manager_root_resolves_repo_relative_directory() { + let root = tempfile::tempdir().unwrap(); + let repo = root.path().join("repo"); + let app = repo.join("apps/web"); + std::fs::create_dir_all(&app).unwrap(); + let config = json!({ + "application": {"packageManager": {"name": "bun", "path": "apps/web"}} + }); + + assert_eq!( + pm_root(&config, &repo).await.unwrap(), + app.canonicalize().unwrap() + ); + } + + #[tokio::test] + async fn package_manager_root_rejects_symlink_escape() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let repo = root.path().join("repo"); + let outside = root.path().join("outside"); + std::fs::create_dir_all(&repo).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + symlink(&outside, repo.join("escaped")).unwrap(); + let config = json!({ + "application": {"packageManager": {"name": "bun", "path": "escaped"}} + }); + + let error = pm_root(&config, &repo).await.unwrap_err(); + assert!(error.contains("resolves outside the repository")); + } + + #[tokio::test] + async fn install_fails_before_spawn_when_package_manager_path_escapes_by_symlink() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let repo = root.path().join("repo"); + let outside = root.path().join("outside"); + std::fs::create_dir_all(&repo).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("package.json"), r#"{"scripts":{}}"#).unwrap(); + symlink(&outside, repo.join("escaped")).unwrap(); + let orch = test_orchestrator(repo, root.path()); + let config = json!({ + "application": {"packageManager": {"name": "bun", "path": "escaped"}} + }); + + assert!(!run(&orch, &config).await); + assert_eq!(orch.tasks.list(None).len(), 0); + assert_eq!(orch.lifecycle_snapshot()["phase"], json!("install-failed")); + } + + #[tokio::test] + async fn shutdown_escalates_and_reaps_a_term_resistant_install_group() { + let root = tempfile::tempdir().unwrap(); + let repo_dir = root.path().join("repo"); + std::fs::create_dir_all(&repo_dir).unwrap(); + let logs = Arc::new(LogStore::new(root.path().join("logs"))); + let orch = SetupOrchestrator::new( + repo_dir.clone(), + repo_dir.clone(), + Arc::new(ConfigStore::new()), + Arc::new(TaskRegistry::new(logs)), + Arc::new(Broadcaster::new()), + ); + let ready = root.path().join("install-child-ready"); + let ready_arg = ready.to_string_lossy().into_owned(); + + let owner = { + let orch = orch.clone(); + tokio::spawn(async move { + run_install_cmd( + &orch, + &[ + "sh", + "-c", + "trap '' TERM; : > \"$0\"; while :; do sleep 1; done", + &ready_arg, + ], + &repo_dir, + ) + .await + }) + }; + tokio::time::timeout(Duration::from_secs(1), async { + while !ready.exists() { + tokio::task::yield_now().await; + } + }) + .await + .expect("TERM-resistant install process reached its ready marker"); + + let result = orch + .shutdown(Duration::from_millis(50), Duration::from_secs(2)) + .await; + assert_eq!(result.tasks.initially_running, 1); + assert_eq!(result.tasks.term_signaled, 1); + assert_eq!(result.tasks.kill_signaled, 1); + assert!(result.tasks.remaining.is_empty()); + assert_eq!( + owner.await.unwrap().unwrap(), + None, + "shutdown cancellation is not reported as install failure" + ); + } +} diff --git a/apps/native/crates/local-api/src/setup/mod.rs b/apps/native/crates/local-api/src/setup/mod.rs new file mode 100644 index 0000000000..06a19cc27b --- /dev/null +++ b/apps/native/crates/local-api/src/setup/mod.rs @@ -0,0 +1,667 @@ +//! Setup orchestrator — the clone -> install -> start pipeline. +//! +//! STATUS CORRECTION (see the native parity contract and +//! the desktop migration contract §1.2's "setup: +//! clone|install|start enqueue acks" line): earlier Phase-1 docs +//! (the native local-API contract §C, the native module-ownership contract's "Deliberately out of +//! scope" section) classified the whole setup/clone family as DROPPED, +//! reasoning that desktop only ever opens an *already-checked-out* project +//! folder. That's true for `LOCAL_API_WORKDIR` in the steady state, but it +//! misses how a project FIRST lands on a machine and how its preview dev +//! server starts — both still go through this same clone -> install -> start +//! pipeline (mirroring `packages/sandbox/daemon/setup/orchestrator.ts`), just +//! triggered by a `POST /_sandbox/config` carrying `git.repository.cloneUrl` +//! instead of a cluster-side clone-then-hand-the-daemon-a-workdir flow. This +//! module is the correction: KEPT, not dropped. See the doc updates in the +//! same change for the corrected KEPT/DROPPED classification. +//! +//! ## Scope narrower than the TS daemon (deliberate, documented — not a gap) +//! +//! - No golden-cache fast-path (`setup/golden-cache.ts`) and no deps-cache +//! env plumbing (`setup/install.ts`'s `depsCacheEnv`/`denoCacheEnv`) — both +//! are pod/cluster warm-pool optimizations with no desktop-laptop +//! equivalent (the desktop migration contract: "Dropped in v1: … golden-cache"). +//! - No install-fingerprint skip (`install/install-state.ts`) — a desktop +//! `local-api` process boots once per app launch, not once per warm-pool +//! claim, so the "already installed for this exact config+HEAD" skip that +//! exists to avoid re-running `npm install` on every claim has much less +//! payoff here; every `clone`/`bootstrap` transition just runs install. +//! - No continuous crash-supervision loop (`probe.ts`'s standing +//! booting/online/offline state machine) — this module's dev-server +//! confirmation is a single-shot "spawn, sniff the bind announcement, +//! confirm with one HTTP probe, done" (see `setup/dev.rs`), not a +//! perpetual poller. A desktop dev server crashing after a confirmed +//! `running` transition is a real gap (no `crashed` phase is ever +//! reached), flagged here rather than silently accepted — no test in +//! either target suite exercises post-running crash recovery. +//! - No org-fs (`ensureGitExclude`) or protected-branch hook installation +//! during clone — org-fs mounting is a separately DROPPED family (still +//! correctly dropped, contract doc §C); the protected-branch hook is +//! belt-and-suspenders for `publish()`'s in-code guard (`routes/git.rs` +//! already enforces the same list without the hook, since `publish()` runs +//! `--no-verify` and would skip the hook anyway). +//! +//! ## Log retention is file-backed (`crate::log_store`) +//! +//! `clone`/`install`/`dev` all stream their subprocess output through +//! [`crate::tasks::TaskRegistry::append_log`] rather than buffering it in +//! RAM: each chunk lands in [`crate::log_store::LogStore`] (both a per-task +//! file AND the source's stable, combined `"app/"` transcript — see +//! that module's doc comment) and only THEN fans out live via the +//! broadcaster. Files are the source of truth for anything a late +//! `/_sandbox/events` connect or a `/_sandbox/tasks/:id` query needs to +//! replay; RAM (the broadcaster's subscriber set, a task's `chunks_tx`) +//! holds only the transient live fan-out for whoever is connected RIGHT +//! now. +//! +//! `setup/clone.rs` used to be the one exception: it shelled out via +//! `Command::output()`, which buffers the whole process in memory and +//! returns it only after exit — no `"log"` frame was ever emitted and no +//! task was ever registered, so an in-progress clone was invisible to both +//! `/_sandbox/events` and `/_sandbox/tasks`. That file now streams through +//! the same `append_log` path `install.rs`/`dev.rs` already used — see its +//! own module doc for the fix in detail. + +pub mod clone; +pub mod dev; +pub mod install; + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::sync::mpsc; + +use crate::config::ConfigStore; +use crate::events::Broadcaster; +use crate::tasks::{KillAllResult, TaskEntry, TaskRegistry}; + +/// The named entry points into the setup pipeline — byte-parity with the +/// daemon's `Step` union (`setup/orchestrator.ts`). A step always runs +/// forward: `Clone` also runs install + start, `Install` also runs start. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Step { + Clone, + Install, + Start, +} + +impl Step { + /// The wire value for `POST /_sandbox/setup/:step`'s `{"enqueued": ...}` + /// ack — byte-parity with `daemon/routes/setup.ts::makeSetupHandler`. + pub fn as_str(&self) -> &'static str { + match self { + Step::Clone => "clone", + Step::Install => "install", + Step::Start => "start", + } + } +} + +struct Inner { + /// Current `LifecycleState` phase object, e.g. `{"phase":"idle"}` or + /// `{"phase":"running","port":3000,"htmlSupport":false}` — byte-parity + /// shape with `daemon/events/types.ts::LifecycleState`. + lifecycle: Value, + /// `None` until the install step has run at least once WITH a package + /// manager configured (byte-parity with `orchestrator.getDiscoveredScripts()` + /// staying `null` until `broadcastDiscoveredScripts` first runs — see + /// `setup/install.rs`'s doc comment on when that happens). + discovered_scripts: Option>, +} + +/// Drives the setup pipeline in response to config transitions +/// (`handle_transition`) or explicit retry requests (`resume_from`, the +/// `POST /_sandbox/setup/{clone,install,start}` routes). A single background +/// worker task (spawned once in [`SetupOrchestrator::new`]) processes queued +/// steps strictly in order, so an in-flight clone can never race a +/// concurrently-enqueued install. +pub struct SetupOrchestrator { + pub(crate) repo_dir: PathBuf, + /// The managed root that owns BOTH `sandboxes//repo` (this + /// orchestrator's `repo_dir`) and the shared `repos///` + /// canonical git store the clone step adds worktrees from. Carried + /// explicitly rather than derived from `repo_dir`, whose depth differs + /// between the global orchestrator (`/repo`) and a per-handle + /// one (`/sandboxes//repo`). + pub(crate) app_root: PathBuf, + pub(crate) config: Arc, + pub(crate) tasks: Arc, + pub(crate) broadcaster: Arc, + running: AtomicBool, + /// Queue depth NOT yet started (decremented the moment a step is popped, + /// not when it finishes) — matches `GET /health`'s + /// `orchestrator.pending` / the daemon's `SetupOrchestrator.pendingCount()`. + pending: AtomicUsize, + inner: Mutex, + /// Linearizes `close` with task registration. If registration wins, the + /// task is visible before shutdown snapshots the registry; if close wins, + /// no child may be spawned for that task. + task_registration: Mutex<()>, + /// Identity of the newest dev-server spawn admitted by this orchestrator. + /// A port probe belongs to the task that discovered that port; retaining + /// the task id here prevents a slow probe from an older spawn from + /// publishing `running` after Restart has replaced it. + active_dev_task: Mutex>, + tx: Mutex>>, + worker: Mutex>>, + closed: AtomicBool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SetupShutdownResult { + pub worker_stopped: bool, + pub tasks: KillAllResult, +} + +impl SetupOrchestrator { + pub fn new( + repo_dir: PathBuf, + app_root: PathBuf, + config: Arc, + tasks: Arc, + broadcaster: Arc, + ) -> Arc { + let (tx, rx) = mpsc::unbounded_channel(); + let this = Arc::new(Self { + repo_dir, + app_root, + config, + tasks, + broadcaster, + running: AtomicBool::new(false), + pending: AtomicUsize::new(0), + inner: Mutex::new(Inner { + lifecycle: json!({ "phase": "idle" }), + discovered_scripts: None, + }), + task_registration: Mutex::new(()), + active_dev_task: Mutex::new(None), + tx: Mutex::new(Some(tx)), + worker: Mutex::new(None), + closed: AtomicBool::new(false), + }); + // Plain (non-async) unit tests across several families construct a + // full `AppState` — and therefore a `SetupOrchestrator` — purely as + // fixture plumbing, with no Tokio runtime available to spawn onto + // (`tokio::spawn` outside a runtime panics). Those fixtures never + // exercise the pipeline (no `resume_from`/`handle_transition` call + // that would actually enqueue a step), so skipping the worker spawn + // when no runtime is current is safe: `enqueue()`'s `tx.send()` + // would just find a dropped receiver and no-op, exactly like a + // caller racing a not-yet-started worker. Every real caller (the + // `#[tokio::main]` binary, `#[tokio::test]`s that actually drive the + // pipeline) runs inside a runtime and gets the worker as before. + if tokio::runtime::Handle::try_current().is_ok() { + let weak = Arc::downgrade(&this); + let worker = tokio::spawn(async move { worker_loop(weak, rx).await }); + *this.lock_worker() = Some(worker); + } + this + } + + /// Maps a [`crate::config::classify::Transition`] kind string (the same + /// strings `ConfigStore::patch`'s `ApplyOutcome.transition` already + /// returns) onto a pipeline step — byte-parity with + /// `setup/orchestrator.ts::transitionToStep`. `None`-mapped transitions + /// (`env-change`, `git-credential-refresh`, `identity-conflict`, `no-op`) + /// are silently ignored, matching the TS source. + pub fn handle_transition(&self, transition: &str) { + let step = match transition { + "bootstrap" | "branch-change" => Some(Step::Clone), + "runtime-change" | "pm-change" => Some(Step::Install), + "port-change" => Some(Step::Start), + _ => None, + }; + if let Some(step) = step { + self.enqueue(step); + } + } + + /// `POST /_sandbox/setup/:step` — enqueues regardless of current setup + /// state until shutdown closes admission. Returns `false` once closed (or + /// if the worker has ended), so callers never report accepted work that + /// cannot run. + pub fn resume_from(&self, step: Step) -> bool { + self.enqueue(step) + } + + fn enqueue(&self, step: Step) -> bool { + let tx = self.lock_tx(); + if self.is_closed() { + return false; + } + let Some(tx) = tx.as_ref() else { + return false; + }; + self.pending.fetch_add(1, Ordering::SeqCst); + if tx.send(step).is_err() { + decrement(&self.pending); + return false; + } + true + } + + /// Permanently rejects later setup work and closes the worker's queue. + /// Synchronous so a shutdown owner can close every orchestrator before it + /// awaits any one child. + pub fn close(&self) -> bool { + let _registration = self.lock_task_registration(); + let first = !self.closed.swap(true, Ordering::SeqCst); + self.lock_tx().take(); + first + } + + pub fn is_closed(&self) -> bool { + self.closed.load(Ordering::SeqCst) + } + + /// Registers a setup child under the same close fence used by shutdown. + /// Callers must do this before spawning; the attached kill controller's + /// durable signal state handles shutdown landing between registration and + /// the subsequent `Command::spawn`. + pub(crate) fn register_task(&self, entry: TaskEntry) -> bool { + let _registration = self.lock_task_registration(); + if self.is_closed() { + return false; + } + self.tasks.insert(entry); + true + } + + /// Makes `task_id` the only dev spawn allowed to publish lifecycle state. + /// Task ids are boot-unique, so this is also a generation token. + pub(crate) fn claim_dev_task(&self, task_id: &str) { + *self.lock_active_dev_task() = Some(task_id.to_string()); + } + + pub(crate) fn is_current_dev_task(&self, task_id: &str) -> bool { + self.lock_active_dev_task().as_deref() == Some(task_id) + } + + /// Publishes a dev lifecycle transition under the same generation fence + /// used by [`Self::claim_dev_task`] and [`Self::finish_dev_task`]. + pub(crate) fn transition_dev_lifecycle(&self, task_id: &str, next: Value) -> bool { + let active = self.lock_active_dev_task(); + if active.as_deref() != Some(task_id) || self.is_closed() { + return false; + } + self.transition_lifecycle(next); + true + } + + /// Invalidates a completed/failed dev task without letting an older task + /// clear the token belonging to its replacement. + pub(crate) fn finish_dev_task(&self, task_id: &str) -> bool { + let mut active = self.lock_active_dev_task(); + if active.as_deref() != Some(task_id) { + return false; + } + active.take(); + true + } + + /// Closes queue/task admission, reaps every registered child with one + /// concurrent TERM→KILL budget, then joins (or aborts and joins) the worker. + /// Idempotent: only the first caller owns the worker `JoinHandle`. + pub async fn shutdown( + &self, + term_grace: Duration, + kill_grace: Duration, + ) -> SetupShutdownResult { + self.close(); + let tasks = self.tasks.kill_all_and_wait(term_grace, kill_grace).await; + + let worker = self.lock_worker().take(); + let worker_stopped = match worker { + Some(mut worker) => match tokio::time::timeout(kill_grace, &mut worker).await { + Ok(_) => true, + Err(_) => { + worker.abort(); + let _ = worker.await; + false + } + }, + None => true, + }; + self.running.store(false, Ordering::SeqCst); + self.pending.store(0, Ordering::SeqCst); + SetupShutdownResult { + worker_stopped, + tasks, + } + } + + /// `true` while a step is actively being applied. Surfaced on + /// `GET /health`'s `orchestrator.running` (and the legacy `setup.running` + /// alias) — `waitForOrchestratorIdle()` in both e2e helper files polls + /// this until it's `false` with `pending_count() == 0`. + pub fn is_running(&self) -> bool { + self.running.load(Ordering::SeqCst) + } + + pub fn pending_count(&self) -> usize { + self.pending.load(Ordering::SeqCst) + } + + /// `{"running":bool,"pending":usize}` — the exact shape both + /// `GET /health` and `GET /_sandbox/config` embed under `orchestrator`. + pub fn orchestrator_json(&self) -> Value { + json!({ "running": self.is_running(), "pending": self.pending_count() }) + } + + /// Current `LifecycleState` phase object — the `/_sandbox/events` + /// connect-time snapshot (`routes/events.rs`) wraps this in + /// `{"state": ...}` to build the `"lifecycle"` SSE frame. + pub fn lifecycle_snapshot(&self) -> Value { + self.lock().lifecycle.clone() + } + + /// `None` until the install step has broadcast a discovered set at least + /// once — see [`Inner::discovered_scripts`]'s doc comment. + pub fn discovered_scripts(&self) -> Option> { + self.lock().discovered_scripts.clone() + } + + /// Idempotent — a same-shape transition doesn't re-broadcast (byte-parity + /// with `LifecycleManager::transition`'s `equal(this.state, next)` guard). + pub(crate) fn transition_lifecycle(&self, next: Value) { + let mut guard = self.lock(); + if guard.lifecycle == next { + return; + } + guard.lifecycle = next.clone(); + drop(guard); + self.broadcaster.emit("lifecycle", json!({ "state": next })); + } + + /// Byte-parity with `broadcastDiscoveredScripts`: unconditional emit + /// (not change-detected — unlike `routes/scripts.rs`'s own + /// `GET /_sandbox/scripts` dedup, which is a DIFFERENT call site with its + /// own "only on actual change" contract). + pub(crate) fn mark_discovered_scripts(&self, scripts: Vec) { + self.lock().discovered_scripts = Some(scripts.clone()); + self.broadcaster + .emit("scripts", json!({ "scripts": scripts })); + } + + /// The current merged `TenantConfig` view (git/operator/application — + /// same shape `ConfigStore::snapshot().config` exposes on the wire, + /// `env` values excluded). Sufficient for every field the pipeline reads + /// (`git.repository.{cloneUrl,branch}`, `git.identity`, + /// `application.packageManager.{name,path}`, `application.port`). + pub(crate) fn current_config(&self) -> Option { + self.config.snapshot().config + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { + match self.inner.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + } + } + + fn lock_task_registration(&self) -> std::sync::MutexGuard<'_, ()> { + match self.task_registration.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } + } + + fn lock_active_dev_task(&self) -> std::sync::MutexGuard<'_, Option> { + match self.active_dev_task.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } + } + + fn lock_tx(&self) -> std::sync::MutexGuard<'_, Option>> { + match self.tx.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } + } + + fn lock_worker(&self) -> std::sync::MutexGuard<'_, Option>> { + match self.worker.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } + } +} + +/// The single background consumer — one per `SetupOrchestrator`, spawned +/// once in `new`. Processes steps strictly serially: `run_cascade` is +/// `.await`ed to completion before the next `rx.recv()` resolves, so an +/// install can never observe a half-finished clone. +async fn worker_loop(orch: Weak, mut rx: mpsc::UnboundedReceiver) { + while let Some(step) = rx.recv().await { + let Some(orch) = orch.upgrade() else { + return; + }; + if orch.is_closed() { + orch.pending.store(0, Ordering::SeqCst); + return; + } + decrement(&orch.pending); + orch.running.store(true, Ordering::SeqCst); + run_cascade(&orch, step).await; + orch.running.store(false, Ordering::SeqCst); + if orch.is_closed() { + orch.pending.store(0, Ordering::SeqCst); + return; + } + } +} + +fn decrement(value: &AtomicUsize) { + let _ = value.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| { + Some(current.saturating_sub(1)) + }); +} + +/// Byte-parity with `SetupOrchestrator.runStep`: `Clone` cascades into +/// install then start; `Install` cascades into start; `Start` runs alone. A +/// step that fails (clone/install returning `false`) stops the cascade +/// short, same as the TS `if (!(await this.stepX())) return;` chain. +async fn run_cascade(orch: &Arc, step: Step) { + if orch.is_closed() { + return; + } + match step { + Step::Clone => { + let Some(config) = orch.current_config() else { + return; + }; + if !clone::run(orch, &config).await { + return; + } + if orch.is_closed() { + return; + } + let Some(config) = orch.current_config() else { + return; + }; + if !install::run(orch, &config).await { + return; + } + if orch.is_closed() { + return; + } + let Some(config) = orch.current_config() else { + return; + }; + dev::run(orch, &config).await; + } + Step::Install => { + let Some(config) = orch.current_config() else { + return; + }; + if !install::run(orch, &config).await { + return; + } + if orch.is_closed() { + return; + } + let Some(config) = orch.current_config() else { + return; + }; + dev::run(orch, &config).await; + } + Step::Start => { + let Some(config) = orch.current_config() else { + return; + }; + dev::run(orch, &config).await; + } + } +} + +/// `{"phase":"start-failed","error": ...}` — the terminal-failure shape the +/// `install`/`dev` steps construct on their own failure paths; centralized +/// to keep the wire shape consistent. +pub(crate) fn start_failed(error: impl Into) -> Value { + json!({ "phase": "start-failed", "error": error.into() }) +} + +/// `{"phase":"clone-failed","error": ...}` — the `clone` step's own +/// terminal-failure shape (byte-parity with `LifecycleState`'s +/// `clone-failed` variant, distinct from `install-failed`/`start-failed`). +pub(crate) fn clone_failed(error: impl Into) -> Value { + json!({ "phase": "clone-failed", "error": error.into() }) +} + +/// `{"phase":"install-failed","error": ...}` — the `install` step's own +/// terminal-failure shape. +pub(crate) fn install_failed(error: impl Into) -> Value { + json!({ "phase": "install-failed", "error": error.into() }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn orch() -> Arc { + // None of these tests set a config, so the pipeline never actually + // runs (every step no-ops on `current_config() == None`) — no + // `LogStore` I/O ever happens, so a `"."`-rooted store (never + // exercised) is safe here, unlike `clone.rs`'s own tests below. + let logs = Arc::new(crate::log_store::LogStore::new(PathBuf::from("."))); + SetupOrchestrator::new( + PathBuf::from("."), + PathBuf::from("."), + Arc::new(ConfigStore::new()), + Arc::new(TaskRegistry::new(logs)), + Arc::new(Broadcaster::new()), + ) + } + + #[test] + fn fresh_orchestrator_is_idle() { + let o = orch(); + assert!(!o.is_running()); + assert_eq!(o.pending_count(), 0); + assert_eq!(o.lifecycle_snapshot(), json!({"phase": "idle"})); + assert!(o.discovered_scripts().is_none()); + } + + #[tokio::test] + async fn resume_from_drains_to_idle_with_no_config() { + let o = orch(); + o.resume_from(Step::Clone); + // Give the worker a beat to pick the step up and finish (no config + // -> every step no-ops immediately). + for _ in 0..100 { + if !o.is_running() && o.pending_count() == 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(!o.is_running()); + assert_eq!(o.pending_count(), 0); + } + + #[test] + fn close_rejects_queued_work_and_child_registration() { + let o = orch(); + assert!(o.close()); + assert!(!o.close(), "close is idempotent"); + assert!(o.is_closed()); + assert!(!o.resume_from(Step::Clone)); + assert_eq!(o.pending_count(), 0); + + let id = o.tasks.next_id(); + assert!(!o.register_task(TaskEntry::new( + crate::tasks::TaskSummary { + id: id.clone(), + command: "never-spawned".to_string(), + status: crate::tasks::TaskStatus::Running, + exit_code: None, + started_at: 0, + finished_at: None, + timed_out: false, + truncated: false, + log_name: None, + intentional: None, + }, + None, + ))); + assert!(o.tasks.get(&id).is_none()); + } + + #[tokio::test] + async fn worker_holds_only_a_weak_orchestrator_reference() { + let o = orch(); + let weak = Arc::downgrade(&o); + drop(o); + tokio::task::yield_now().await; + assert!( + weak.upgrade().is_none(), + "an idle worker must not keep its orchestrator alive" + ); + } + + #[tokio::test] + async fn shutdown_closes_and_joins_the_worker() { + let o = orch(); + assert!(o.resume_from(Step::Clone)); + let result = o + .shutdown(Duration::from_millis(50), Duration::from_secs(1)) + .await; + assert!(result.worker_stopped); + assert_eq!(result.tasks.initially_running, 0); + assert!(o.is_closed()); + assert!(!o.is_running()); + assert_eq!(o.pending_count(), 0); + assert!(!o.resume_from(Step::Start)); + } + + #[test] + fn transition_lifecycle_is_idempotent() { + let o = orch(); + let mut rx = o.broadcaster.subscribe(); + o.transition_lifecycle(json!({"phase": "idle"})); + // Same shape as the initial state -> no broadcast. + assert!(rx.try_recv().is_err()); + o.transition_lifecycle(json!({"phase": "cloning"})); + let evt = rx.try_recv().expect("changed phase broadcasts"); + assert_eq!(evt.name, "lifecycle"); + assert_eq!(evt.data["state"]["phase"], "cloning"); + } + + #[test] + fn handle_transition_maps_kinds_to_steps() { + let o = orch(); + // bootstrap/branch-change/runtime-change/pm-change/port-change all + // enqueue; env-change/git-credential-refresh/identity-conflict/no-op + // don't. Exercise via pending_count() deltas (worker hasn't been + // given a chance to drain a no-config-noop yet in a sync test). + assert_eq!(o.pending_count(), 0); + o.handle_transition("no-op"); + assert_eq!(o.pending_count(), 0, "no-op must not enqueue"); + o.handle_transition("env-change"); + assert_eq!(o.pending_count(), 0, "env-change must not enqueue"); + } +} diff --git a/apps/native/crates/local-api/src/shutdown.rs b/apps/native/crates/local-api/src/shutdown.rs new file mode 100644 index 0000000000..0c382a8c3b --- /dev/null +++ b/apps/native/crates/local-api/src/shutdown.rs @@ -0,0 +1,252 @@ +//! Graceful-shutdown hook registry. +//! +//! The daemon's `entry.ts::shutdown()` runs a git publish ("sync all local +//! changes to remote") BEFORE unmounting org-fs, on SIGTERM, with the +//! reasoning that syncing the user's work is the only irrecoverable step — +//! see the comment above `shutdown()` in `packages/sandbox/daemon/entry.ts`. +//! Local-api needs the same shape (the git family's e2e asserts a +//! shutdown-triggered publish) but must not let the `git` module reach into +//! `main.rs` to register itself — that would make `main.rs` a shared file +//! every family edits. Instead, `main.rs` owns one `ShutdownCoordinator` in +//! `AppState` and calls `run()` after the SIGTERM signal fires; any family +//! that needs a shutdown-time action calls `state.shutdown.register(..)` +//! from its own route-registration code (e.g. `routes::git::register`, if +//! that family adds one) without ever touching `main.rs`. +//! +//! Hooks are NOT retried and NOT guaranteed to run under SIGKILL. The native +//! queue lifecycle black-box suite delivers a real SIGTERM to pin the graceful +//! path; crash-recovery tests use SIGKILL separately to pin the watchdog path. + +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use futures_util::FutureExt; +use tokio::sync::{RwLock, RwLockReadGuard}; +use tokio::time::Instant; + +use crate::mutation::MutationCoordinator; + +pub type ShutdownHook = Box Pin + Send>> + Send + Sync>; + +pub struct ShutdownCoordinator { + hooks: Mutex>, + accepting_work: AtomicBool, + admission: RwLock<()>, + mutations: Arc, +} + +impl ShutdownCoordinator { + pub fn new() -> Self { + Self { + hooks: Mutex::new(Vec::new()), + accepting_work: AtomicBool::new(true), + admission: RwLock::new(()), + mutations: Arc::new(MutationCoordinator::new()), + } + } + + /// Filesystem mutation lifecycle owned by the same top-level shutdown + /// coordinator. Kept behind this accessor so `AppState` test fixtures do + /// not need a second independently-constructed shutdown component. + pub(crate) fn mutations(&self) -> Arc { + self.mutations.clone() + } + + /// Enters a short critical section that must finish before shutdown takes + /// its process-local snapshot. The first atomic check avoids queueing new + /// readers behind a shutdown writer; the second check closes the race where + /// shutdown starts while this task is waiting for the read lock. + /// + /// Keep the returned guard alive through the durable enqueue and worker + /// launch. That makes the boundary linear: either the work is visible to + /// the shutdown hook's snapshot, or it is rejected before being accepted. + pub async fn admit_work(&self) -> Option> { + if !self.accepting_work.load(Ordering::SeqCst) { + return None; + } + let guard = self.admission.read().await; + if !self.accepting_work.load(Ordering::SeqCst) { + return None; + } + Some(guard) + } + + /// First, idempotent phase of process shutdown. Calling this method + /// synchronously flips the admission bit before it returns the barrier + /// future; awaiting that future then waits for short sections that already + /// won the race (spawn + durable registry insertion) to finish. This split + /// lets the top-level owner close setup queues before its first await. + pub fn begin_shutdown(&self) -> impl Future + '_ { + self.accepting_work.store(false, Ordering::SeqCst); + async move { + let barrier = self.admission.write().await; + drop(barrier); + } + } + + /// Register a hook to run once, in registration order, during graceful + /// shutdown. Hooks should still log their own expected errors, but this + /// coordinator isolates both a panic and a timeout so neither can suppress + /// later cleanup. + pub fn register(&self, hook: ShutdownHook) { + match self.hooks.lock() { + Ok(mut hooks) => hooks.push(hook), + Err(poisoned) => poisoned.into_inner().push(hook), + } + } + + /// Drain and run every registered hook in registration order. Panics are + /// isolated so one family cannot suppress later cleanup, and the complete + /// ordered pipeline has one wall-clock deadline rather than multiplying a + /// timeout by the number of hooks. + pub async fn run(&self) { + self.run_with_deadline(Duration::from_secs(20)).await; + } + + async fn run_with_deadline(&self, budget: Duration) { + self.begin_shutdown().await; + let hooks: Vec = { + let mut guard = match self.hooks.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + std::mem::take(&mut *guard) + }; + let deadline = Instant::now() + budget; + let hook_count = hooks.len(); + for (index, hook) in hooks.into_iter().enumerate() { + let remaining = deadline.saturating_duration_since(Instant::now()); + // Reserve an equal share of the remaining global budget for every + // later hook. A wedged early hook must not suppress git publish or + // child-process finalization; each hook is attempted in order while + // the sum of their slices remains bounded by `budget`. + let hooks_left = u32::try_from(hook_count - index).unwrap_or(u32::MAX); + let hook_budget = remaining / hooks_left; + let guarded = std::panic::AssertUnwindSafe(async move { hook().await }).catch_unwind(); + match tokio::time::timeout(hook_budget, guarded).await { + Ok(Ok(())) => {} + Ok(Err(_panic)) => { + tracing::error!(hook_index = index, "shutdown hook panicked; continuing") + } + Err(_) => { + tracing::error!( + hook_index = index, + ?hook_budget, + "shutdown hook exceeded its reserved deadline; continuing" + ); + } + } + } + } +} + +impl Default for ShutdownCoordinator { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[tokio::test] + async fn admission_linearizes_in_flight_work_before_shutdown_hooks() { + let coordinator = Arc::new(ShutdownCoordinator::new()); + let admitted = coordinator.admit_work().await.expect("admitted work"); + let order = Arc::new(Mutex::new(Vec::new())); + let hook_order = order.clone(); + coordinator.register(Box::new(move || { + let hook_order = hook_order.clone(); + Box::pin(async move { hook_order.lock().unwrap().push("hook") }) + })); + + let runner = { + let coordinator = coordinator.clone(); + tokio::spawn(async move { + coordinator.run_with_deadline(Duration::from_secs(1)).await; + }) + }; + while coordinator.accepting_work.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + assert!(order.lock().unwrap().is_empty()); + assert!(coordinator.admit_work().await.is_none()); + + order.lock().unwrap().push("admitted"); + drop(admitted); + runner.await.unwrap(); + assert_eq!(*order.lock().unwrap(), ["admitted", "hook"]); + } + + #[tokio::test] + async fn begin_shutdown_is_idempotent_and_permanently_closes_admission() { + let coordinator = ShutdownCoordinator::new(); + let first_barrier = coordinator.begin_shutdown(); + assert!( + coordinator.admit_work().await.is_none(), + "admission closes synchronously before the barrier is awaited" + ); + first_barrier.await; + coordinator.begin_shutdown().await; + assert!(coordinator.admit_work().await.is_none()); + } + + #[tokio::test] + async fn hook_panic_isolated_and_order_continues() { + let coordinator = ShutdownCoordinator::new(); + let order = Arc::new(Mutex::new(Vec::new())); + let first = order.clone(); + coordinator.register(Box::new(move || { + let first = first.clone(); + Box::pin(async move { + first.lock().unwrap().push(1); + panic!("synthetic shutdown panic"); + }) + })); + let second = order.clone(); + coordinator.register(Box::new(move || { + let second = second.clone(); + Box::pin(async move { second.lock().unwrap().push(2) }) + })); + + coordinator.run_with_deadline(Duration::from_secs(1)).await; + assert_eq!(*order.lock().unwrap(), [1, 2]); + } + + #[tokio::test] + async fn all_hooks_share_one_wall_clock_deadline() { + let coordinator = ShutdownCoordinator::new(); + coordinator.register(Box::new(|| { + Box::pin(async { std::future::pending::<()>().await }) + })); + let later_order = Arc::new(Mutex::new(Vec::new())); + let second_order = later_order.clone(); + coordinator.register(Box::new(move || { + let second_order = second_order.clone(); + Box::pin(async move { second_order.lock().unwrap().push(2) }) + })); + let third_order = later_order.clone(); + coordinator.register(Box::new(move || { + let third_order = third_order.clone(); + Box::pin(async move { third_order.lock().unwrap().push(3) }) + })); + let started = Instant::now(); + coordinator + .run_with_deadline(Duration::from_millis(30)) + .await; + assert!( + started.elapsed() < Duration::from_millis(100), + "deadline must be global, not multiplied per hook" + ); + assert_eq!( + *later_order.lock().unwrap(), + [2, 3], + "a hanging earlier hook must not suppress second or third cleanup" + ); + } +} diff --git a/apps/native/crates/local-api/src/state.rs b/apps/native/crates/local-api/src/state.rs new file mode 100644 index 0000000000..7416433db8 --- /dev/null +++ b/apps/native/crates/local-api/src/state.rs @@ -0,0 +1,78 @@ +//! `AppState` — the single struct injected into every axum handler via +//! `State`. This is a SHARED FILE (see +//! the native module-ownership contract): family implementers read its +//! fields freely but must not add/rename/remove fields here — file an +//! interface request instead so the bootstrap owner keeps this struct +//! coherent across all 8 families. + +use std::path::PathBuf; +use std::sync::Arc; + +use crate::config::ConfigStore; +use crate::events::Broadcaster; +use crate::sandbox::SandboxManager; +use crate::setup::SetupOrchestrator; +use crate::shutdown::ShutdownCoordinator; +use crate::tasks::TaskRegistry; + +/// `LOCAL_API_MODE` — see `cors.rs`'s module doc for exactly what this +/// changes (origin-allowlist breadth only, never auth, never a wildcard +/// CORS header). Defaults to `Strict`, the only mode the shipped app runs +/// in; `DaemonCompat` is an opt-in convenience for the parity-oracle CI +/// job. Not part of the pinned contract doc's env table — a bootstrap +/// addition, kept intentionally narrow in effect so it can't become a +/// security opt-out by accident. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApiMode { + Strict, + DaemonCompat, +} + +impl ApiMode { + pub fn from_env() -> Self { + match std::env::var("LOCAL_API_MODE").ok().as_deref() { + Some("daemon-compat") => ApiMode::DaemonCompat, + _ => ApiMode::Strict, + } + } +} + +// `app_root`/`repo_dir`/`tasks`/`broadcaster` are written at boot +// (`main.rs`) but not read by anything yet — every route handler that +// would read them (fs/bash/git clamp roots, task registration, event +// emission) is still a 501 stub. Reserved fields, not dead ones; remove +// this allow once the first family reads through `State`. +#[allow(dead_code)] +#[derive(Clone)] +pub struct AppState { + /// The per-launch bearer token (`LOCAL_API_TOKEN` / `DAEMON_TOKEN` + /// alias). `Arc`-ish so cloning `AppState` per-request never + /// re-allocates the token string. + pub token: Arc, + /// `LOCAL_API_BOOT_ID` / `DAEMON_BOOT_ID` — echoed on `/health`. + pub boot_id: Arc, + /// `LOCAL_API_WORKDIR` / `APP_ROOT` — the fs/bash/git clamp root. + pub app_root: PathBuf, + /// `/repo` — default cwd for bash/git/scripts, matching the + /// daemon's `repoDir` convention so relative paths in prompts resolve + /// the same way. + pub repo_dir: PathBuf, + pub mode: ApiMode, + pub config: Arc, + pub tasks: Arc, + pub broadcaster: Arc, + pub shutdown: Arc, + /// The clone -> install -> start pipeline (KEPT, not dropped — see + /// `crate::setup`'s module doc for the Phase-1-doc correction this + /// field is part of). + pub setup: Arc, + /// Per-handle (one full workdir per `(virtualMcpId, branch)`) git + /// sandboxes — see `crate::sandbox`'s module doc and + /// the native Git-sandbox contract. Orthogonal to + /// `repo_dir`/`setup` above: a git-backed thread's harness runs in + /// `SandboxManager::ensure(..).workdir` instead, and `routes/proxy.rs` + /// routes a preview request by handle to the matching `Sandbox`'s own + /// sniffed dev port. A non-git thread never touches this field — the + /// plain `repo_dir`/`setup` path above is unchanged. + pub sandbox_manager: Arc, +} diff --git a/apps/native/crates/local-api/src/tasks/mod.rs b/apps/native/crates/local-api/src/tasks/mod.rs new file mode 100644 index 0000000000..fe49cc5d01 --- /dev/null +++ b/apps/native/crates/local-api/src/tasks/mod.rs @@ -0,0 +1,16 @@ +pub mod registry; + +// Re-exported for family handlers to `use crate::tasks::{TaskEntry, ..}`. +// `routes/bash.rs`/`routes/tasks.rs` (bash+tasks family) use all of these; +// `routes/scripts.rs` (a different family, not yet implemented) is expected +// to reuse `TaskEntry`/`KillHandle`/`TaskRegistry` too, per +// the native module-ownership contract. +pub use registry::{ + KillAllResult, KillHandle, KillSignal, OutputStream, ProcessController, StreamEvent, TaskEntry, + TaskRegistry, TaskStatus, TaskSummary, +}; +// `RingBuffer`/`now_ms` are crate-internal helpers (not part of the +// documented cross-family contract above) that `routes/bash.rs` reuses for +// its own await-mode output capture, to avoid re-implementing the same +// cap/truncate logic twice. +pub(crate) use registry::{now_ms, RingBuffer}; diff --git a/apps/native/crates/local-api/src/tasks/registry.rs b/apps/native/crates/local-api/src/tasks/registry.rs new file mode 100644 index 0000000000..a9803900ab --- /dev/null +++ b/apps/native/crates/local-api/src/tasks/registry.rs @@ -0,0 +1,1283 @@ +//! `TaskRegistry` — background-task bookkeeping shared by the `bash` family +//! (`POST /_sandbox/bash` with `mode:"background"`), the `tasks` family +//! (`/_sandbox/tasks*`), and the `scripts`/exec family +//! (`POST /_sandbox/exec/:name`). Byte-parity target for the summary shape: +//! `TaskSummary` in `packages/sandbox/daemon/process/task-manager.ts`. +//! +//! Owned by the bash+tasks family (see +//! the native module-ownership contract) — extended past the Phase 1 +//! bootstrap stub with per-task output retention and a broadcast channel so +//! `/_sandbox/tasks/:id/stream` (owned by `routes/tasks.rs`) can replay +//! buffered output then follow live chunks through to a single `End` event. +//! +//! **Storage split** (see the `log_store` module doc): retained output — +//! `output()`'s `{stdout, stderr}` and a `/stream` connect's replay — lives +//! in [`crate::log_store::LogStore`], file-backed at +//! `"tasks//.{out,err}"`. Public ids retain the daemon's +//! `task` contract and therefore restart at `task1`; the private directory +//! namespace prevents a new process from appending to a prior boot's files. +//! ONLY the live fan-out (`chunks_tx`, for an already-connected +//! `/stream` subscriber) stays in RAM, and only for as long as the task is +//! being actively streamed — `append_output`/`output`/`remove` all await +//! file I/O; `finalize`/`kill`/`list`/`get` stay synchronous (pure +//! in-memory summary bookkeeping, unchanged). A subscriber can still never +//! observe a chunk after the terminal `End`: `finalize` sends `End` and +//! flips the summary's terminal status under the SAME synchronous lock; +//! an append re-checks that status after its asynchronous file write before +//! broadcasting and closes the writer if finalization won the race. +//! +//! [`TaskRegistry::append_log`] is the shared "chunk" primitive for every +//! family that spawns a process under a `log_name` — the setup pipeline's +//! clone/install/dev steps (`setup/{clone,install,dev}.rs`) AND the exec +//! family (`routes/scripts.rs`, a DIFFERENT family per +//! the native module-ownership contract, but the exact same "append to +//! this task's file + the source's combined transcript + broadcast" shape +//! — hoisted here rather than duplicated a 4th time, per that file's own +//! doc comment inviting this once a third caller showed up). It also feeds +//! `routes/events.rs`'s connect-time `"log"` replay; source discovery comes +//! from the stable files themselves so an empty post-restart registry does not +//! hide retained output. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::Serialize; +use serde_json::json; +use tokio::sync::{broadcast, Notify}; + +use crate::events::Broadcaster; +use crate::log_store::{app_key, LogStore, DEFAULT_TAIL_BYTES}; + +/// Generous enough that a burst of output between two `/stream` `poll()`s +/// doesn't lag-drop before a slow consumer catches up — see +/// `events/broadcaster.rs`'s identical reasoning for the same tradeoff. +const STREAM_CHANNEL_CAPACITY: usize = 1024; + +pub(crate) fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TaskStatus { + Running, + Exited, + Failed, + Killed, + Timeout, +} + +impl TaskStatus { + pub fn as_str(&self) -> &'static str { + match self { + TaskStatus::Running => "running", + TaskStatus::Exited => "exited", + TaskStatus::Failed => "failed", + TaskStatus::Killed => "killed", + TaskStatus::Timeout => "timeout", + } + } + + pub fn is_terminal(&self) -> bool { + !matches!(self, TaskStatus::Running) + } + + /// Parse a `?status=` query value; unknown tokens map to `None` so the + /// caller can filter them out (byte-parity with `tasks.ts`'s `VALID_STATUS` + /// set — an unrecognized status token is silently dropped, not an error). + pub fn parse(s: &str) -> Option { + match s { + "running" => Some(TaskStatus::Running), + "exited" => Some(TaskStatus::Exited), + "failed" => Some(TaskStatus::Failed), + "killed" => Some(TaskStatus::Killed), + "timeout" => Some(TaskStatus::Timeout), + _ => None, + } + } +} + +/// Byte-parity with `TaskSummary` in `process/task-manager.ts`. `started_at` +/// / `finished_at` are epoch milliseconds (matches `Date.now()` on the TS +/// side) so the wire shape needs no unit conversion. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskSummary { + pub id: String, + pub command: String, + pub status: TaskStatus, + pub exit_code: Option, + pub started_at: i64, + pub finished_at: Option, + pub timed_out: bool, + pub truncated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub log_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub intentional: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KillSignal { + Term, + Kill, +} + +impl KillSignal { + fn severity(self) -> u8 { + match self { + Self::Term => 1, + Self::Kill => 2, + } + } + + fn from_severity(value: u8) -> Option { + match value { + 1 => Some(Self::Term), + 2 => Some(Self::Kill), + _ => None, + } + } + + /// Shell-convention result for a process stopped by this signal. + pub fn exit_code(self) -> i32 { + match self { + Self::Term => 143, + Self::Kill => 137, + } + } + + /// Argument accepted by the shared `kill(1)` process-group helper. + pub fn flag(self) -> &'static str { + match self { + Self::Term => "-TERM", + Self::Kill => "-KILL", + } + } +} + +/// A live task's kill switch, owned by whichever family spawned the +/// process. Returns `true` if a running process was actually signaled, +/// `false` if the task was already terminal (byte-parity with the daemon's +/// "kill a running task -> ok; killing again -> 400 not running"). +pub type KillHandle = Arc bool + Send + Sync>; + +/// Cancellation bridge shared by setup subprocesses. The synchronous +/// [`KillHandle`] side records the strongest requested signal and wakes the +/// async child owner; the owner observes a request that arrived before spawn +/// just as reliably as one delivered while it is inside `select!`. +/// +/// Storing state in addition to a notification is deliberate: `Notify` is a +/// wake-up hint, not durable state. `wait_for_change` pins/enables its waiter +/// before reading the atomic, closing the notify-before-first-poll race. +#[derive(Clone)] +pub struct ProcessController { + inner: Arc, +} + +struct ProcessControllerInner { + requested: AtomicU8, + changed: Notify, +} + +impl ProcessController { + pub fn new() -> Self { + Self { + inner: Arc::new(ProcessControllerInner { + requested: AtomicU8::new(0), + changed: Notify::new(), + }), + } + } + + pub fn kill_handle(&self) -> KillHandle { + let controller = self.clone(); + Arc::new(move |signal| { + controller.signal(signal); + true + }) + } + + /// Synchronously records a signal request. Safe from `Drop`; the async + /// process owner observes it through [`Self::wait_for_change`]. + pub fn signal(&self, signal: KillSignal) { + self.request(signal); + } + + pub fn requested(&self) -> Option { + KillSignal::from_severity(self.inner.requested.load(Ordering::SeqCst)) + } + + pub async fn wait_for_change(&self, observed: Option) -> KillSignal { + loop { + let changed = self.inner.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if let Some(current) = self.requested() { + if observed != Some(current) { + return current; + } + } + changed.await; + } + } + + fn request(&self, signal: KillSignal) { + let requested = signal.severity(); + let mut current = self.inner.requested.load(Ordering::SeqCst); + while current < requested { + match self.inner.requested.compare_exchange( + current, + requested, + Ordering::SeqCst, + Ordering::SeqCst, + ) { + Ok(_) => { + self.inner.changed.notify_waiters(); + return; + } + Err(actual) => current = actual, + } + } + // Repeated delivery still wakes an owner that has not yet observed the + // durable atomic state (harmless for an already-observed signal). + self.inner.changed.notify_waiters(); + } +} + +impl Default for ProcessController { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KillAllResult { + pub initially_running: usize, + pub term_signaled: usize, + pub kill_signaled: usize, + pub remaining: Vec, +} + +/// One live chunk of a task's stdout/stderr. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputStream { + Stdout, + Stderr, +} + +impl OutputStream { + pub fn as_str(&self) -> &'static str { + match self { + OutputStream::Stdout => "stdout", + OutputStream::Stderr => "stderr", + } + } + + /// File-extension form used to build this task's `LogStore` key (see + /// [`task_key`]) — deliberately short, distinct from [`Self::as_str`] + /// (that one is a WIRE value, `/stream`'s SSE event name; this one is + /// only ever a filename suffix). + fn file_ext(&self) -> &'static str { + match self { + OutputStream::Stdout => "out", + OutputStream::Stderr => "err", + } + } +} + +/// Fan-out payload for `/_sandbox/tasks/:id/stream` subscribers — see +/// `TaskRegistry::subscribe_output`/`finalize`. `End` is sent exactly once, +/// atomically with the summary's terminal transition, so a subscriber can +/// never see a `Chunk` after an `End` nor miss the `End` entirely. +#[derive(Debug, Clone)] +pub enum StreamEvent { + Chunk { + stream: OutputStream, + data: String, + }, + End { + status: TaskStatus, + exit_code: Option, + timed_out: bool, + }, +} + +/// Simple capped string buffer — byte-parity target: `process/ring-buffer.ts`. +/// Appends are amortised constant-time; `read()` returns everything +/// currently held. Once `len() > capacity` the oldest bytes are dropped +/// (kept: the tail / most-recent bytes) and `truncated` latches `true`. +/// +/// Purely a REQUEST-scoped helper now (`routes/bash.rs`'s `mode:"await"` +/// capture, which is never persisted or replayed — the response IS the +/// output). `TaskRegistry` itself no longer uses this: retained per-task +/// output is file-backed (see this module's doc comment). +pub(crate) struct RingBuffer { + capacity: usize, + buf: String, + truncated: bool, +} + +impl RingBuffer { + pub(crate) fn new(capacity: usize) -> Self { + Self { + capacity, + buf: String::new(), + truncated: false, + } + } + + pub(crate) fn append(&mut self, data: &str) { + if data.is_empty() { + return; + } + self.buf.push_str(data); + if self.buf.len() > self.capacity { + self.truncated = true; + let overflow = self.buf.len() - self.capacity; + let mut cut = overflow.min(self.buf.len()); + // `overflow` is a byte offset that may land mid-codepoint; + // walk forward to the next char boundary so we never split a + // multi-byte UTF-8 sequence. + while cut < self.buf.len() && !self.buf.is_char_boundary(cut) { + cut += 1; + } + self.buf.drain(..cut); + } + } + + pub(crate) fn read(&self) -> (String, bool) { + (self.buf.clone(), self.truncated) + } +} + +/// Private storage key. The boot namespace stays off the wire while keeping +/// repeated public `task1` ids isolated across local-api process lifetimes. +fn task_key(namespace: &uuid::Uuid, id: &str, stream: OutputStream) -> String { + format!("tasks/{namespace}/{id}.{}", stream.file_ext()) +} + +pub struct TaskEntry { + pub summary: TaskSummary, + pub kill: Option, + exposed: bool, + chunks_tx: broadcast::Sender, +} + +impl TaskEntry { + /// Construct a fresh entry with its own `/stream` broadcast channel — + /// retained output itself lives in `TaskRegistry`'s `LogStore`, keyed + /// by `entry.summary.id` (see [`task_key`]), not on this struct. + pub fn new(summary: TaskSummary, kill: Option) -> Self { + let (chunks_tx, _rx) = broadcast::channel(STREAM_CHANNEL_CAPACITY); + Self { + summary, + kill, + exposed: true, + chunks_tx, + } + } + + /// Register process ownership for coordinated shutdown without exposing + /// the implementation detail through `GET /_sandbox/tasks`. Await-mode + /// request handlers use this while the request itself remains the sole + /// public representation of the subprocess. + pub fn new_internal(summary: TaskSummary, kill: Option) -> Self { + let mut entry = Self::new(summary, kill); + entry.exposed = false; + entry + } +} + +pub struct TaskRegistry { + inner: Mutex>, + id_counter: AtomicU64, + storage_namespace: uuid::Uuid, + logs: Arc, + child_lifetime_lock_path: PathBuf, + terminal_changed: Notify, +} + +impl TaskRegistry { + pub fn new(logs: Arc) -> Self { + let work_root = logs.root().parent().unwrap_or_else(|| logs.root()); + // Unit/fixture default only. Production constructors pass the one + // app-root-wide `.decocms/child-lifetime.lock` explicitly; keeping the + // fallback beside a fixture's log root avoids requiring test-only + // directory scaffolding before a process can spawn. + let child_lifetime_lock_path = work_root.join("child-lifetime.lock"); + Self::new_with_child_lifetime_lock(logs, child_lifetime_lock_path) + } + + /// Production constructor for both the process-global registry and each + /// sandbox registry. Every child family under one app root must share one + /// fence, even though their retained logs live in different directories. + pub fn new_with_child_lifetime_lock( + logs: Arc, + child_lifetime_lock_path: PathBuf, + ) -> Self { + Self { + inner: Mutex::new(HashMap::new()), + id_counter: AtomicU64::new(0), + storage_namespace: uuid::Uuid::new_v4(), + logs, + child_lifetime_lock_path, + terminal_changed: Notify::new(), + } + } + + pub(crate) fn child_lifetime_lock_path(&self) -> &Path { + &self.child_lifetime_lock_path + } + + /// The `LogStore` backing this registry's retained output — also used + /// directly by the setup pipeline (`setup/{clone,install,dev}.rs`) to + /// append into the SEPARATE `"app/"` namespace (the combined, + /// stable-path transcript `routes/events.rs` replays), and by + /// `routes/events.rs` itself to read it back. See the `log_store` module doc + /// for why both namespaces share one store. + pub fn logs(&self) -> Arc { + self.logs.clone() + } + + /// Daemon-compatible public task id generator shared by every family that + /// spawns into this registry. IDs restart at `task1` each process boot; + /// retained files remain collision-free through [`Self::storage_namespace`]. + pub fn next_id(&self) -> String { + let n = self.id_counter.fetch_add(1, Ordering::Relaxed) + 1; + format!("task{n}") + } + + /// Register a new (or replace an existing) task entry by + /// `entry.summary.id`. + pub fn insert(&self, entry: TaskEntry) { + let mut guard = self.lock(); + guard.insert(entry.summary.id.clone(), entry); + } + + /// Mutate a task's summary in place. Returns `false` if `id` is + /// unknown. Kept as a general-purpose escape hatch per the documented + /// contract (the native module-ownership contract) for families that + /// need a one-off field tweak outside the `finalize`/`kill` lifecycle + /// this file's own routes use — not yet called by bash/tasks itself, + /// whose transitions all go through the more specific methods below. + #[allow(dead_code)] + pub fn update(&self, id: &str, f: F) -> bool { + let mut guard = self.lock(); + match guard.get_mut(id) { + Some(entry) => { + f(&mut entry.summary); + true + } + None => false, + } + } + + pub fn get(&self, id: &str) -> Option { + self.lock().get(id).map(|e| e.summary.clone()) + } + + /// Public task-route view. Internal request-owned subprocesses remain + /// available to their owner and coordinated shutdown, but cannot be + /// discovered by guessing a monotonic task id. + pub fn get_exposed(&self, id: &str) -> Option { + self.lock() + .get(id) + .filter(|entry| entry.exposed) + .map(|entry| entry.summary.clone()) + } + + /// List task summaries, optionally filtered to a status set (matches + /// `GET /_sandbox/tasks?status=a,b`). `None` returns every task. + pub fn list(&self, status_filter: Option<&[TaskStatus]>) -> Vec { + let guard = self.lock(); + guard + .values() + .filter(|entry| entry.exposed) + .filter(|e| match status_filter { + Some(statuses) => statuses.contains(&e.summary.status), + None => true, + }) + .map(|e| e.summary.clone()) + .collect() + } + + /// Buffered stdout/stderr + truncated flag — `GET /_sandbox/tasks/:id` + /// and the stream route's replay-on-connect both read through this. + /// Reads the last [`DEFAULT_TAIL_BYTES`] of each of this task's + /// `LogStore` files (see this module's doc comment). + pub async fn output(&self, id: &str) -> Option<(String, String, bool)> { + let summary_truncated = self.get(id)?.truncated; + let out_key = task_key(&self.storage_namespace, id, OutputStream::Stdout); + let err_key = task_key(&self.storage_namespace, id, OutputStream::Stderr); + let stdout = self.logs.tail_read(&out_key, DEFAULT_TAIL_BYTES).await; + let stderr = self.logs.tail_read(&err_key, DEFAULT_TAIL_BYTES).await; + let truncated = summary_truncated + || self.logs.is_truncated(&out_key).await + || self.logs.is_truncated(&err_key).await; + Some((stdout, stderr, truncated)) + } + + pub async fn output_exposed(&self, id: &str) -> Option<(String, String, bool)> { + self.get_exposed(id)?; + self.output(id).await + } + + /// Append a live chunk to a task's `LogStore` file and fan it out to + /// any live `/_sandbox/tasks/:id/stream` subscriber. Keeps + /// `summary.truncated` in sync so `list`/`get` reflect it without a + /// separate recompute pass. A no-op for an unknown/already-removed + /// `id` or empty `data`. + /// + /// The file write completes (and is flushed) BEFORE the live + /// `StreamEvent::Chunk` send — matching `LogStore::append`'s own + /// durability contract, so a subscriber that joins between the two + /// always finds this chunk via replay (`output()`) even if it missed + /// the live send. + pub async fn append_output(&self, id: &str, stream: OutputStream, data: &str) -> bool { + if data.is_empty() + || !self + .lock() + .get(id) + .is_some_and(|entry| !entry.summary.status.is_terminal()) + { + return false; + } + let key = task_key(&self.storage_namespace, id, stream); + self.logs.append(&key, data).await; + let truncated_now = self.logs.is_truncated(&key).await; + + let mut guard = self.lock(); + let emitted = if let Some(entry) = guard + .get_mut(id) + .filter(|entry| !entry.summary.status.is_terminal()) + { + if truncated_now { + entry.summary.truncated = true; + } + let _ = entry.chunks_tx.send(StreamEvent::Chunk { + stream, + data: data.to_string(), + }); + true + } else { + false + }; + drop(guard); + if !emitted { + // `finalize` may have raced the asynchronous file write and + // failed its non-blocking close attempt. The append has flushed + // now, so close its idle writer and never publish a Chunk after + // the already-sent terminal End. + self.logs.close_writer_if_idle(&key); + } + emitted + } + + /// Appends `data` to BOTH this task's own per-task file + /// (`append_output`) AND `source`'s combined `"app/"` + /// transcript, then live-broadcasts a `"log"` SSE frame + /// (`{source, data}`) — see this module's doc comment for who calls + /// this and why it's centralized here. A no-op for empty `data` + /// (`append_output` already no-ops for an unknown `id`). + pub async fn append_log( + &self, + id: &str, + source: &str, + stream: OutputStream, + data: &str, + broadcaster: &Broadcaster, + ) { + if data.is_empty() { + return; + } + if !self.append_output(id, stream, data).await { + return; + } + let source_key = app_key(source); + self.logs.append(&source_key, data).await; + let guard = self.lock(); + let emitted = guard + .get(id) + .is_some_and(|entry| !entry.summary.status.is_terminal()); + if emitted { + // Keep this emission under the same summary lock as the terminal + // check: finalize either follows this log or wins first and makes + // us skip it; it can never interleave an End before this frame. + broadcaster.emit("log", json!({ "source": source, "data": data })); + } + drop(guard); + if !emitted { + self.logs.close_writer_if_idle(&source_key); + } + } + + /// Subscribe to this task's live output. `None` if `id` is unknown. + /// Only `routes/tasks.rs`'s stream handler should call this (mirrors + /// the single-subscriber-family convention documented on + /// `events/broadcaster.rs`). + pub fn subscribe_output(&self, id: &str) -> Option> { + let guard = self.lock(); + guard.get(id).map(|e| e.chunks_tx.subscribe()) + } + + pub fn subscribe_output_exposed(&self, id: &str) -> Option> { + let guard = self.lock(); + guard + .get(id) + .filter(|entry| entry.exposed) + .map(|entry| entry.chunks_tx.subscribe()) + } + + /// Atomically transition a task to a terminal status and notify any + /// live stream subscriber via a single `StreamEvent::End`, under the + /// same lock acquisition as the summary mutation — so a subscriber that + /// joins concurrently can never see a chunk after `End`, nor miss `End` + /// entirely. Returns `false` if `id` is unknown. + pub fn finalize(&self, id: &str, status: TaskStatus, exit_code: i32, timed_out: bool) -> bool { + let mut guard = self.lock(); + match guard.get_mut(id) { + Some(entry) if !entry.summary.status.is_terminal() => { + entry.summary.status = status; + entry.summary.exit_code = Some(exit_code); + entry.summary.finished_at = Some(now_ms()); + entry.summary.timed_out = timed_out; + let _ = entry.chunks_tx.send(StreamEvent::End { + status, + exit_code: Some(exit_code), + timed_out, + }); + let log_name = entry.summary.log_name.clone(); + self.terminal_changed.notify_waiters(); + self.logs.close_writer_if_idle(&task_key( + &self.storage_namespace, + id, + OutputStream::Stdout, + )); + self.logs.close_writer_if_idle(&task_key( + &self.storage_namespace, + id, + OutputStream::Stderr, + )); + if let Some(source) = log_name { + self.logs.close_writer_if_idle(&app_key(&source)); + } + true + } + Some(_) | None => false, + } + } + + /// Signal a task. `Some(true)` = signaled, `Some(false)` = found but + /// not running (or no kill handle registered), `None` = unknown id. On + /// an actual signal, flags `summary.intentional` — byte-parity with + /// `TaskManager.kill()`'s "orchestrator-driven stop" flag, set + /// synchronously at kill time rather than deferred to exit. + pub fn kill(&self, id: &str, signal: KillSignal) -> Option { + let mut guard = self.lock(); + let entry = guard.get_mut(id)?; + if entry.summary.status.is_terminal() { + return Some(false); + } + let signaled = match &entry.kill { + Some(k) => k(signal), + None => false, + }; + if signaled { + entry.summary.intentional = Some(true); + } + Some(signaled) + } + + pub fn kill_exposed(&self, id: &str, signal: KillSignal) -> Option { + let mut guard = self.lock(); + let entry = guard.get_mut(id)?; + if !entry.exposed || entry.summary.status.is_terminal() { + return Some(false); + } + let signaled = entry.kill.as_ref().is_some_and(|kill| kill(signal)); + if signaled { + entry.summary.intentional = Some(true); + } + Some(signaled) + } + + /// Kill every exposed currently-running task; returns the count actually + /// signaled (`POST /_sandbox/tasks/kill-all`). Internal request-owned + /// children are intentionally excluded here and included by + /// [`Self::kill_all_and_wait`] during process shutdown. + pub fn kill_all(&self, signal: KillSignal) -> usize { + let mut guard = self.lock(); + let mut count = 0; + for entry in guard.values_mut() { + if !entry.exposed || entry.summary.status.is_terminal() { + continue; + } + let signaled = match &entry.kill { + Some(k) => k(signal), + None => false, + }; + if signaled { + entry.summary.intentional = Some(true); + count += 1; + } + } + count + } + + /// Signals every task that was running at entry, waits concurrently for + /// their terminal transitions, then escalates the still-running subset. + /// Both waits are wall-clock bounded for the whole set (never one timeout + /// per child). Tasks admitted after this snapshot are intentionally outside + /// the fence; shutdown closes their owning admission source first. + pub async fn kill_all_and_wait( + &self, + term_grace: Duration, + kill_grace: Duration, + ) -> KillAllResult { + let ids = self.running_ids(); + let initially_running = ids.len(); + let term_signaled = self.kill_ids(&ids, KillSignal::Term); + + if tokio::time::timeout(term_grace, self.wait_ids_terminal(&ids)) + .await + .is_ok() + { + return KillAllResult { + initially_running, + term_signaled, + kill_signaled: 0, + remaining: Vec::new(), + }; + } + + let after_term = self.running_subset(&ids); + let kill_signaled = self.kill_ids(&after_term, KillSignal::Kill); + let _ = tokio::time::timeout(kill_grace, self.wait_ids_terminal(&after_term)).await; + let remaining = self.running_subset(&after_term); + KillAllResult { + initially_running, + term_signaled, + kill_signaled, + remaining, + } + } + + fn running_ids(&self) -> Vec { + self.lock() + .iter() + .filter(|(_, entry)| !entry.summary.status.is_terminal()) + .map(|(id, _)| id.clone()) + .collect() + } + + fn running_subset(&self, ids: &[String]) -> Vec { + let guard = self.lock(); + ids.iter() + .filter(|id| { + guard + .get(id.as_str()) + .is_some_and(|entry| !entry.summary.status.is_terminal()) + }) + .cloned() + .collect() + } + + fn kill_ids(&self, ids: &[String], signal: KillSignal) -> usize { + let mut guard = self.lock(); + let mut count = 0; + for id in ids { + let Some(entry) = guard.get_mut(id) else { + continue; + }; + if entry.summary.status.is_terminal() { + continue; + } + let signaled = entry.kill.as_ref().is_some_and(|kill| kill(signal)); + if signaled { + entry.summary.intentional = Some(true); + count += 1; + } + } + count + } + + async fn wait_ids_terminal(&self, ids: &[String]) { + loop { + let changed = self.terminal_changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if self.running_subset(ids).is_empty() { + return; + } + changed.await; + } + } + + /// Remove a finished task (`DELETE /_sandbox/tasks/:id`). Returns + /// `None` if unknown, `Some(true)` if removed, `Some(false)` if still + /// running (caller should surface 400). Cleans up this task's + /// `LogStore` files too — lifecycle completeness: no orphaned files + /// survive a task the caller explicitly removed. + pub async fn remove(&self, id: &str) -> Option { + self.remove_if(id, false).await + } + + pub async fn remove_exposed(&self, id: &str) -> Option { + self.remove_if(id, true).await + } + + async fn remove_if(&self, id: &str, require_exposed: bool) -> Option { + enum Outcome { + Unknown, + StillRunning, + Removed, + } + let outcome = { + let mut guard = self.lock(); + match guard.get(id) { + None => Outcome::Unknown, + Some(entry) if require_exposed && !entry.exposed => Outcome::Unknown, + Some(entry) if !entry.summary.status.is_terminal() => Outcome::StillRunning, + Some(_) => { + guard.remove(id); + Outcome::Removed + } + } + }; + match outcome { + Outcome::Unknown => None, + Outcome::StillRunning => Some(false), + Outcome::Removed => { + self.logs + .remove(&task_key(&self.storage_namespace, id, OutputStream::Stdout)) + .await; + self.logs + .remove(&task_key(&self.storage_namespace, id, OutputStream::Stderr)) + .await; + Some(true) + } + } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { + match self.inner.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn registry() -> (tempfile::TempDir, TaskRegistry) { + let dir = tempfile::tempdir().unwrap(); + let logs = Arc::new(LogStore::new(dir.path().to_path_buf())); + (dir, TaskRegistry::new(logs)) + } + + fn summary(id: &str, status: TaskStatus) -> TaskSummary { + TaskSummary { + id: id.to_string(), + command: "true".to_string(), + status, + exit_code: None, + started_at: 0, + finished_at: None, + timed_out: false, + truncated: false, + log_name: None, + intentional: None, + } + } + + #[test] + fn insert_get_list_roundtrip() { + let (_dir, reg) = registry(); + reg.insert(TaskEntry::new(summary("t1", TaskStatus::Running), None)); + assert_eq!(reg.get("t1").unwrap().status, TaskStatus::Running); + assert_eq!(reg.list(None).len(), 1); + assert_eq!( + reg.list(Some(&[TaskStatus::Exited])).len(), + 0, + "status filter excludes non-matching tasks" + ); + } + + #[tokio::test] + async fn internal_task_is_shutdown_visible_but_hidden_from_task_routes() { + let (_dir, reg) = registry(); + let controller = ProcessController::new(); + reg.insert(TaskEntry::new_internal( + summary("internal", TaskStatus::Running), + Some(controller.kill_handle()), + )); + + assert!(reg.list(None).is_empty()); + assert_eq!(reg.get("internal").unwrap().status, TaskStatus::Running); + assert!(reg.get_exposed("internal").is_none()); + assert!(reg.output_exposed("internal").await.is_none()); + assert!(reg.subscribe_output_exposed("internal").is_none()); + assert_eq!(reg.kill_exposed("internal", KillSignal::Term), Some(false)); + assert_eq!(reg.remove_exposed("internal").await, None); + assert_eq!(reg.kill_all(KillSignal::Term), 0); + + let result = reg.kill_all_and_wait(Duration::ZERO, Duration::ZERO).await; + assert_eq!(result.initially_running, 1); + assert_eq!(result.term_signaled, 1); + assert_eq!(result.kill_signaled, 1); + assert_eq!(result.remaining, vec!["internal"]); + assert_eq!(controller.requested(), Some(KillSignal::Kill)); + } + + #[test] + fn kill_unknown_task_is_none() { + let (_dir, reg) = registry(); + assert!(reg.kill("nope", KillSignal::Term).is_none()); + } + + #[test] + fn kill_terminal_task_is_false_not_running() { + let (_dir, reg) = registry(); + reg.insert(TaskEntry::new(summary("t1", TaskStatus::Exited), None)); + assert_eq!(reg.kill("t1", KillSignal::Term), Some(false)); + } + + #[test] + fn kill_running_task_with_handle_flags_intentional() { + let (_dir, reg) = registry(); + let handle: KillHandle = Arc::new(|_sig| true); + reg.insert(TaskEntry::new( + summary("t1", TaskStatus::Running), + Some(handle), + )); + assert_eq!(reg.kill("t1", KillSignal::Term), Some(true)); + assert_eq!(reg.get("t1").unwrap().intentional, Some(true)); + } + + #[test] + fn kill_running_task_without_handle_is_false() { + let (_dir, reg) = registry(); + reg.insert(TaskEntry::new(summary("t1", TaskStatus::Running), None)); + assert_eq!(reg.kill("t1", KillSignal::Term), Some(false)); + assert_eq!( + reg.get("t1").unwrap().intentional, + None, + "no actual signal was sent, so intentional stays unset" + ); + } + + #[tokio::test] + async fn process_controller_keeps_the_strongest_signal_without_lost_wakes() { + let controller = ProcessController::new(); + controller.signal(KillSignal::Term); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), controller.wait_for_change(None),) + .await + .expect("a pre-existing signal is durable"), + KillSignal::Term + ); + + controller.signal(KillSignal::Term); + controller.signal(KillSignal::Kill); + controller.signal(KillSignal::Term); + assert_eq!( + controller.wait_for_change(Some(KillSignal::Term)).await, + KillSignal::Kill + ); + assert_eq!(controller.requested(), Some(KillSignal::Kill)); + } + + #[tokio::test] + async fn kill_all_and_wait_uses_one_term_then_one_kill_budget() { + let (dir, registry) = registry(); + let registry = Arc::new(registry); + let term_signals = Arc::new(Mutex::new(Vec::new())); + let term_changed = Arc::new(Notify::new()); + let stubborn_signals = Arc::new(Mutex::new(Vec::new())); + let stubborn_changed = Arc::new(Notify::new()); + + let term_handle: KillHandle = { + let signals = term_signals.clone(); + let changed = term_changed.clone(); + Arc::new(move |signal| { + signals.lock().unwrap().push(signal); + changed.notify_waiters(); + true + }) + }; + let stubborn_handle: KillHandle = { + let signals = stubborn_signals.clone(); + let changed = stubborn_changed.clone(); + Arc::new(move |signal| { + signals.lock().unwrap().push(signal); + changed.notify_waiters(); + true + }) + }; + registry.insert(TaskEntry::new( + summary("term", TaskStatus::Running), + Some(term_handle), + )); + registry.insert(TaskEntry::new( + summary("stubborn", TaskStatus::Running), + Some(stubborn_handle), + )); + + let shutdown_registry = registry.clone(); + let shutdown = tokio::spawn(async move { + shutdown_registry + .kill_all_and_wait(Duration::from_millis(100), Duration::from_secs(1)) + .await + }); + + wait_for_recorded_signal(&term_signals, &term_changed, KillSignal::Term).await; + wait_for_recorded_signal(&stubborn_signals, &stubborn_changed, KillSignal::Term).await; + registry.finalize("term", TaskStatus::Killed, 143, false); + wait_for_recorded_signal(&stubborn_signals, &stubborn_changed, KillSignal::Kill).await; + registry.finalize("stubborn", TaskStatus::Killed, 137, false); + + let result = shutdown.await.unwrap(); + drop(dir); + + assert_eq!(result.initially_running, 2); + assert_eq!(result.term_signaled, 2); + assert_eq!(result.kill_signaled, 1); + assert!(result.remaining.is_empty()); + assert_eq!(registry.get("term").unwrap().intentional, Some(true)); + assert_eq!(registry.get("stubborn").unwrap().intentional, Some(true)); + } + + async fn wait_for_recorded_signal( + signals: &Mutex>, + changed: &Notify, + wanted: KillSignal, + ) { + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let notified = changed.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if signals.lock().unwrap().contains(&wanted) { + return; + } + notified.await; + } + }) + .await + .expect("shutdown delivered expected signal"); + } + + #[tokio::test] + async fn remove_running_task_is_rejected() { + let (_dir, reg) = registry(); + reg.insert(TaskEntry::new(summary("t1", TaskStatus::Running), None)); + assert_eq!(reg.remove("t1").await, Some(false)); + assert!( + reg.get("t1").is_some(), + "still-running task must survive a rejected remove" + ); + } + + #[tokio::test] + async fn remove_finished_task_succeeds() { + let (_dir, reg) = registry(); + reg.insert(TaskEntry::new(summary("t1", TaskStatus::Exited), None)); + assert_eq!(reg.remove("t1").await, Some(true)); + assert!(reg.get("t1").is_none()); + } + + #[tokio::test] + async fn remove_unknown_task_is_none() { + let (_dir, reg) = registry(); + assert!(reg.remove("nope").await.is_none()); + } + + #[tokio::test] + async fn remove_deletes_the_backing_log_files() { + let (dir, reg) = registry(); + reg.insert(TaskEntry::new(summary("t1", TaskStatus::Running), None)); + reg.append_output("t1", OutputStream::Stdout, "hi\n").await; + let out_path = + dir.path() + .join(task_key(®.storage_namespace, "t1", OutputStream::Stdout)); + assert!(out_path.exists(), "stdout file exists before remove"); + reg.finalize("t1", TaskStatus::Exited, 0, false); + reg.remove("t1").await; + assert!(!out_path.exists(), "stdout file must be deleted on remove"); + } + + #[test] + fn next_id_matches_the_daemon_task_number_contract() { + let (_dir, reg) = registry(); + let first = reg.next_id(); + let second = reg.next_id(); + assert_eq!(first, "task1"); + assert_eq!(second, "task2"); + } + + #[tokio::test] + async fn repeated_public_ids_use_distinct_private_files_across_boots() { + let dir = tempfile::tempdir().unwrap(); + let first = TaskRegistry::new(Arc::new(LogStore::new(dir.path().to_path_buf()))); + let first_id = first.next_id(); + first.insert(TaskEntry::new( + summary(&first_id, TaskStatus::Running), + None, + )); + first + .append_output(&first_id, OutputStream::Stdout, "first boot\n") + .await; + + let second = TaskRegistry::new(Arc::new(LogStore::new(dir.path().to_path_buf()))); + let second_id = second.next_id(); + second.insert(TaskEntry::new( + summary(&second_id, TaskStatus::Running), + None, + )); + second + .append_output(&second_id, OutputStream::Stdout, "second boot\n") + .await; + + assert_eq!(first_id, "task1"); + assert_eq!(second_id, "task1"); + assert_ne!(first.storage_namespace, second.storage_namespace); + assert_eq!(first.output(&first_id).await.unwrap().0, "first boot\n"); + assert_eq!(second.output(&second_id).await.unwrap().0, "second boot\n"); + } + + #[tokio::test] + async fn append_output_updates_buffer_and_truncated_flag() { + let (_dir, reg) = registry(); + reg.insert(TaskEntry::new(summary("t1", TaskStatus::Running), None)); + reg.append_output("t1", OutputStream::Stdout, "hello ") + .await; + reg.append_output("t1", OutputStream::Stderr, "oops\n") + .await; + let (stdout, stderr, truncated) = reg.output("t1").await.unwrap(); + assert_eq!(stdout, "hello "); + assert_eq!(stderr, "oops\n"); + assert!(!truncated); + assert!(!reg.get("t1").unwrap().truncated); + } + + #[tokio::test] + async fn append_output_is_a_noop_for_an_unknown_task() { + let (_dir, reg) = registry(); + // Must not panic and must not create a file for a task that was + // never inserted (or was already removed). + reg.append_output("never-inserted", OutputStream::Stdout, "hi") + .await; + assert!(reg.output("never-inserted").await.is_none()); + } + + #[tokio::test] + async fn an_async_append_racing_finalize_never_sends_chunk_after_end() { + let dir = tempfile::tempdir().unwrap(); + let logs = Arc::new(LogStore::new(dir.path().to_path_buf())); + let reg = Arc::new(TaskRegistry::new(logs.clone())); + reg.insert(TaskEntry::new(summary("t1", TaskStatus::Running), None)); + let mut rx = reg.subscribe_output("t1").unwrap(); + + // Stop this append after task admission but before its file I/O. This + // deterministically opens the old race: finalize sends End while the + // append is suspended, then the append resumes and used to send Chunk. + let (entered, release) = logs.block_next_append(); + let append_reg = reg.clone(); + let append = tokio::spawn(async move { + append_reg + .append_output("t1", OutputStream::Stdout, "late chunk\n") + .await + }); + let permit = entered.acquire().await.unwrap(); + permit.forget(); + assert!(reg.finalize("t1", TaskStatus::Exited, 0, false)); + release.add_permits(1); + assert!(!append.await.unwrap(), "terminal task must suppress Chunk"); + + assert!(matches!(rx.try_recv(), Ok(StreamEvent::End { .. }))); + assert!(matches!( + rx.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); + assert_eq!( + logs.open_writer_count().await, + 0, + "the append that lost the finalize race must close its writer" + ); + } + + #[test] + fn ring_buffer_caps_and_keeps_tail() { + let mut rb = RingBuffer::new(5); + rb.append("abc"); + rb.append("defgh"); + let (data, truncated) = rb.read(); + assert_eq!(data, "defgh"); + assert!(truncated); + } + + #[tokio::test] + async fn output_survives_finalize_so_a_settled_task_still_replays() { + // The SSE connect-time replay (`routes/events.rs`) reads a task's + // `LogStore` files for EVERY task, not just Running ones — a dev + // server or install phase that already finished must still hand + // back its buffered output, else a terminal opened after the + // process settled shows a blank pane. + let (_dir, reg) = registry(); + reg.insert(TaskEntry::new(summary("t1", TaskStatus::Running), None)); + reg.append_output("t1", OutputStream::Stdout, "Local: http://localhost:3000\n") + .await; + reg.append_output("t1", OutputStream::Stderr, "warn: deprecated\n") + .await; + + reg.finalize("t1", TaskStatus::Exited, 0, false); + + assert_eq!(reg.get("t1").unwrap().status, TaskStatus::Exited); + let (stdout, stderr, _truncated) = reg + .output("t1") + .await + .expect("output retained after finalize"); + assert_eq!(stdout, "Local: http://localhost:3000\n"); + assert_eq!(stderr, "warn: deprecated\n"); + } + + #[test] + fn finalize_unknown_task_is_false() { + let (_dir, reg) = registry(); + assert!(!reg.finalize("nope", TaskStatus::Exited, 0, false)); + } + + #[test] + fn finalize_sets_terminal_fields_and_notifies_subscriber() { + let (_dir, reg) = registry(); + reg.insert(TaskEntry::new(summary("t1", TaskStatus::Running), None)); + let mut rx = reg.subscribe_output("t1").unwrap(); + assert!(reg.finalize("t1", TaskStatus::Exited, 0, false)); + let summary = reg.get("t1").unwrap(); + assert_eq!(summary.status, TaskStatus::Exited); + assert_eq!(summary.exit_code, Some(0)); + assert!(summary.finished_at.is_some()); + + match rx.try_recv().expect("End event delivered") { + StreamEvent::End { + status, + exit_code, + timed_out, + } => { + assert_eq!(status, TaskStatus::Exited); + assert_eq!(exit_code, Some(0)); + assert!(!timed_out); + } + other => panic!("expected End, got {other:?}"), + } + } +} diff --git a/apps/native/crates/local-api/src/ui.rs b/apps/native/crates/local-api/src/ui.rs new file mode 100644 index 0000000000..b0355bc1bf --- /dev/null +++ b/apps/native/crates/local-api/src/ui.rs @@ -0,0 +1,240 @@ +use axum::body::Body; +use axum::http::{header, HeaderValue, Method, StatusCode}; +use axum::response::Response; + +/// One exact UI asset supplied by the embedding shell. The local API does +/// not know whether bytes came from Tauri resources, an archive, or memory. +#[derive(Clone)] +pub struct UiAsset { + pub bytes: bytes::Bytes, + pub content_type: String, + pub cache_control: String, +} + +impl UiAsset { + pub fn new( + bytes: impl Into, + content_type: impl Into, + cache_control: impl Into, + ) -> Self { + Self { + bytes: bytes.into(), + content_type: content_type.into(), + cache_control: cache_control.into(), + } + } +} + +/// Tauri-independent source for the bundled SPA. Implementations must perform +/// exact path lookup; the router owns traversal rejection and SPA fallback. +pub trait UiAssetProvider: Send + Sync { + fn asset(&self, path: &str) -> Option; + fn index(&self) -> Option; + fn content_security_policy(&self) -> String; +} + +pub(crate) fn asset_response(asset: UiAsset, method: &Method) -> Response { + let content_length = asset.bytes.len(); + let body = if method == Method::HEAD { + Body::empty() + } else { + Body::from(asset.bytes) + }; + let mut response = Response::new(body); + *response.status_mut() = StatusCode::OK; + let headers = response.headers_mut(); + if let Ok(value) = HeaderValue::from_str(&asset.content_type) { + headers.insert(header::CONTENT_TYPE, value); + } else { + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + ); + } + if let Ok(value) = HeaderValue::from_str(&asset.cache_control) { + headers.insert(header::CACHE_CONTROL, value); + } + if let Ok(value) = HeaderValue::from_str(&content_length.to_string()) { + headers.insert(header::CONTENT_LENGTH, value); + } + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + response +} + +pub(crate) fn index_response( + asset: UiAsset, + method: &Method, + base_csp: &str, + preview_origin: &str, +) -> Response { + if base_csp.trim().is_empty() { + return csp_error_response(); + } + let mut response = asset_response(asset, method); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response.headers_mut().insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + let csp = patch_preview_origin(base_csp, preview_origin); + let Ok(value) = HeaderValue::from_str(&csp) else { + return csp_error_response(); + }; + response + .headers_mut() + .insert(header::CONTENT_SECURITY_POLICY, value); + response +} + +fn csp_error_response() -> Response { + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header(header::CACHE_CONTROL, "no-store") + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(Body::from("UI security policy unavailable")) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + +fn patch_preview_origin(base: &str, preview_origin: &str) -> String { + let mut directives = base + .split(';') + .map(str::trim) + .filter(|directive| !directive.is_empty()) + .map(str::to_string) + .collect::>(); + let preview_ws_origin = preview_origin + .strip_prefix("http://") + .map(|authority| format!("ws://{authority}")); + add_csp_sources( + &mut directives, + "connect-src", + preview_ws_origin + .as_deref() + .map_or_else(|| vec![preview_origin], |ws| vec![preview_origin, ws]), + ); + add_csp_sources(&mut directives, "frame-src", vec![preview_origin]); + format!("{};", directives.join("; ")) +} + +fn add_csp_sources(directives: &mut Vec, name: &str, sources: Vec<&str>) { + if let Some(directive) = directives + .iter_mut() + .find(|directive| directive.split_whitespace().next() == Some(name)) + { + let mut values = directive + .split_whitespace() + .skip(1) + .filter(|value| *value != "'none'") + .collect::>(); + for source in sources { + if !values.contains(&source) { + values.push(source); + } + } + *directive = format!("{name} {}", values.join(" ")); + } else { + directives.push(format!("{name} 'self' {}", sources.join(" "))); + } +} + +pub(crate) fn is_safe_asset_path(path: &str) -> bool { + if !path.starts_with('/') || path.contains('\\') { + return false; + } + let lower = path.to_ascii_lowercase(); + if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") { + return false; + } + !path.split('/').any(|segment| matches!(segment, "." | "..")) +} + +#[cfg(test)] +mod tests { + use axum::body::to_bytes; + + use super::*; + + #[test] + fn csp_adds_preview_to_existing_directives_and_replaces_none() { + let patched = patch_preview_origin( + "default-src 'self'; connect-src 'self'; frame-src 'none'", + "http://localhost:61234", + ); + assert!(patched.contains("connect-src 'self' http://localhost:61234 ws://localhost:61234")); + assert!(patched.contains("frame-src http://localhost:61234")); + assert!(!patched.contains("frame-src 'none'")); + } + + #[test] + fn csp_creates_missing_preview_directives() { + let patched = patch_preview_origin("default-src 'self'", "http://localhost:61234"); + assert!(patched.contains("connect-src 'self' http://localhost:61234 ws://localhost:61234")); + assert!(patched.contains("frame-src 'self' http://localhost:61234")); + } + + #[test] + fn traversal_and_encoded_separators_are_rejected() { + for path in [ + "../index.html", + "/../index.html", + "/assets/./x.js", + "/assets\\x.js", + "/assets/%2e%2e/x.js", + "/assets/%2Fx.js", + ] { + assert!(!is_safe_asset_path(path), "{path} should be rejected"); + } + assert!(is_safe_asset_path("/assets/app.123.js")); + } + + #[tokio::test] + async fn head_preserves_asset_headers_but_has_no_body() { + let response = asset_response( + UiAsset::new( + bytes::Bytes::from_static(b"body"), + "text/plain", + "public, max-age=31536000, immutable", + ), + &Method::HEAD, + ); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "4"); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "public, max-age=31536000, immutable" + ); + assert!(to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .is_empty()); + } + + #[test] + fn index_fails_closed_when_provider_csp_is_missing_or_invalid() { + let asset = || { + UiAsset::new( + bytes::Bytes::from_static(b""), + "text/html", + "no-store", + ) + }; + assert_eq!( + index_response(asset(), &Method::GET, "", "http://localhost:1").status(), + StatusCode::INTERNAL_SERVER_ERROR + ); + assert_eq!( + index_response( + asset(), + &Method::GET, + "default-src 'self'\nX-Injected: yes", + "http://localhost:1", + ) + .status(), + StatusCode::INTERNAL_SERVER_ERROR + ); + } +} diff --git a/apps/native/crates/upstream/Cargo.toml b/apps/native/crates/upstream/Cargo.toml new file mode 100644 index 0000000000..9b6c6db0a2 --- /dev/null +++ b/apps/native/crates/upstream/Cargo.toml @@ -0,0 +1,75 @@ +[package] +name = "upstream" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[features] +# Exposes `tokens::test_support::MemoryTokenStore` to a DOWNSTREAM crate's +# own tests (this crate's own tests reach it via `#[cfg(test)]` directly +# and never need this feature). `local-api` enables it from ITS +# `[dev-dependencies]` entry for `upstream` so `routes/upstream.rs`'s test +# module can exercise the proxy handler against a fake token store instead +# of the real macOS Keychain — see `tokens.rs`'s `test_support` module doc. +test-support = [] + +[dependencies] +# Shared workspace-pinned deps (see apps/native/Cargo.toml's +# `[workspace.dependencies]` table) — consumed via `.workspace = true`, +# never edited here. +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +reqwest = { workspace = true } +rand = { workspace = true } +uuid = { workspace = true } +tracing = { workspace = true } +async-trait = { workspace = true } +urlencoding = { workspace = true } + +# Crate-local dependencies, NOT in the shared workspace table (see that +# table's trailing comment and `crates/harness/Cargo.toml`'s `portable-pty` +# for the established precedent) — declared directly here since this crate +# is their only consumer. +# +# `keyring` — see the native implementation contract section 3 for the full +# "keyring vs security-framework vs shelling to /usr/bin/security" +# writeup and decision. Default features (`v1`) pull in +# `apple-native-keyring-store/keychain` ONLY on macOS (the other backends +# are declared behind `[target.'cfg(...)'.dependencies]` in keyring's own +# manifest, so they're never part of this workspace's build graph on +# Darwin) — no extra feature flags needed. +keyring = "4" +# SHA-256 for PKCE's `code_challenge = BASE64URL(SHA256(code_verifier))` +# (RFC 7636). A typed, audited hash crate rather than hand-rolling SHA-2. +sha2 = "0.10" +# base64url (no padding) encoding for the PKCE verifier/challenge and for +# decoding an id_token's JWT payload segment. +base64 = "0.22" +# Resolves the OS-conventional per-app data directory +# (`~/Library/Application Support/` on macOS) for the +# non-secret OAuth dynamic-client-registration cache — see `register.rs`. +# Framework-agnostic on purpose: this crate also links into the standalone +# `local-api` binary the e2e/daemon-parity suites spawn directly, with no +# Tauri context to ask for an app-data path. +dirs = "6" +# Bounded waiting for the fixed macOS debug-Keychain helper process. Unlike an +# outer async timeout around `spawn_blocking`, this can kill and reap the exact +# child if Keychain leaves it waiting on an interactive dialog. +wait-timeout = "0.2" + +[dev-dependencies] +tempfile = { workspace = true } +# Adds `test-util` on top of the workspace-pinned `tokio` (Cargo unions +# features across `[dependencies]`/`[dev-dependencies]` for the same +# dependency) — needed for `tokio::time::{pause,advance}` / +# `#[tokio::test(start_paused = true)]` in `revalidate.rs`'s test. Not part +# of the shared `[workspace.dependencies]` entry since no OTHER crate in +# this workspace pauses virtual time in a test. +tokio = { workspace = true, features = ["test-util"] } diff --git a/apps/native/crates/upstream/src/bin/decocms-keychain-helper.rs b/apps/native/crates/upstream/src/bin/decocms-keychain-helper.rs new file mode 100644 index 0000000000..9a25b653c5 --- /dev/null +++ b/apps/native/crates/upstream/src/bin/decocms-keychain-helper.rs @@ -0,0 +1,6 @@ +fn main() -> std::process::ExitCode { + match upstream::keychain_helper::serve_stdio() { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(_) => std::process::ExitCode::FAILURE, + } +} diff --git a/apps/native/crates/upstream/src/bridge.rs b/apps/native/crates/upstream/src/bridge.rs new file mode 100644 index 0000000000..6175fa2495 --- /dev/null +++ b/apps/native/crates/upstream/src/bridge.rs @@ -0,0 +1,656 @@ +//! `auth_complete_session` — the hybrid-login bridge (owner brief, post-v1 +//! feedback: "the desktop sign-in must reuse the usual web login screen"). +//! After the desktop-embedded login screen (the SAME shared component as +//! the web `/login` route) has driven a cookie-authenticated Better Auth +//! session into this process's in-memory [`crate::cookie_jar::CookieJar`] +//! via `crates/local-api/src/routes/upstream.rs`'s bare `/api/auth/*` proxy +//! branch, this module performs the MCP OAuth 2.1 + PKCE authorize/token +//! dance ON THE JAR'S BEHALF — no browser hop, no ACTUALLY-listening +//! loopback server — and lands the result in the SAME Keychain path +//! [`crate::login::perform_interactive_login`]'s interactive flow uses. +//! +//! ## Real-UI course-correction: the cookie now OUTLIVES this call +//! +//! Earlier (Phase 3) revisions treated the jar's cookie as purely a +//! transient credential for THIS authorize call, discarded the instant the +//! bridge concluded. That undersold what the cookie is for: the real +//! production web shell's own sign-in gate (`RequiredAuthLayout` / +//! `authClient.useSession()` -> `GET /api/auth/get-session`) and org +//! switcher (`authClient.organization.list()`) hit Better Auth's NATIVE +//! `/api/auth/*` endpoints, which only ever recognize a session COOKIE — +//! the MCP OAuth bearer this crate mints satisfies org-scoped +//! `/api/:org/tools/*`/`/api/:org/decopilot/*` calls but is rejected there +//! (`403 INVALID_API_KEY`, empirically confirmed — +//! the native authentication contract). So [`complete_via_cookie_jar`] +//! now returns the cookie it used AS PART OF the [`StoredSession`] it +//! builds; `session.rs::complete_session` persists it to the Keychain +//! alongside the OAuth tokens, and `crates/local-api/src/routes/upstream.rs` +//! attaches it on every app-API call for the rest of this session's +//! life (see that file's module doc and `tokens.rs`'s `StoredSession::cookie` +//! doc comment) — not just the one authorize round trip this function makes. +//! +//! ## Empirical verification against the live mesh dev server +//! +//! Two behaviors this module depends on were verified with real HTTP +//! requests against a running `apps/api` dev server +//! (`DECOCMS_UPSTREAM_URL=http://localhost:4000`), not just by reading +//! source: +//! +//! 1. **No consent step for an authenticated session.** Studio's `mcp()` +//! plugin config (`apps/api/src/auth/index.ts`) sets no `consentPage`, +//! and this module never sends `prompt=consent`. Reading +//! `better-auth`'s installed `dist/plugins/mcp/authorize.mjs` +//! (`authorizeMCPOAuth`) confirms `query.prompt !== "consent"` skips +//! straight to `throw ctx.redirect(redirectURIWithCode)` — no consent +//! page is ever rendered. Confirmed live: `GET /api/auth/mcp/authorize` +//! with a valid session cookie returned a single `302` straight to +//! `redirect_uri?code=...&state=...` — the "walk the redirect chain" +//! loop below is defensive headroom for a future consent-page rollout, +//! not evidence multiple hops are needed today. +//! 2. **`redirect_uri` is matched by EXACT string equality, not +//! host+scheme.** `authorize.mjs`: +//! `client.redirectUrls.find(url => url === ctx.query.redirect_uri)`. +//! Confirmed live: registering a client with +//! `redirect_uris:["http://127.0.0.1:11111/"]` then calling authorize +//! with `redirect_uri=http://127.0.0.1:22222/` (same client_id, only the +//! port differs) returned `400 {"code":"INVALID_REDIRECT_URI"}`. This is +//! exactly why neither `crate::login`'s interactive flow nor this bridge +//! reuses a client_id across calls — each registers a FRESH client bound +//! to the exact ephemeral redirect_uri it is about to authorize with +//! (see `crate::register`'s module doc). This +//! function therefore always registers a FRESH client scoped to its +//! OWN freshly-reserved redirect_uri (see [`complete_via_cookie_jar`]), +//! at the cost of one extra registration round trip per bridge attempt +//! — cheap next to the network round trips this flow already makes, and +//! provably correct regardless of which port was reserved. (This same +//! exact-match behavior likely also affects a SECOND interactive +//! `crate::login` attempt reusing a first attempt's cached client_id +//! under a different ephemeral port; that's `crate::login`/ +//! `crate::register`'s own pre-existing behavior, out of this module's +//! scope to change — flagged here since this module's empirical dig +//! surfaced it.) + +use reqwest::header::{COOKIE, LOCATION}; +use reqwest::Url; + +use crate::clock::{now_rfc3339, now_unix}; +use crate::cookie_jar::CookieJar; +use crate::login::{self, LoginError}; +use crate::pkce; +use crate::register::{self, RegisterError}; +use crate::tokens::{StoredSession, UserInfo}; + +/// Hard cap on redirects this function will follow between the authorize +/// GET and landing on `redirect_uri` — see this module's doc comment for +/// why the happy path needs exactly one hop and this bound exists as +/// defensive headroom, not an expected steady-state multi-hop dance. +const MAX_REDIRECT_HOPS: usize = 5; + +#[derive(Debug, thiserror::Error)] +pub enum BridgeError { + #[error("no upstream session cookie captured yet — sign in via the /api/auth/* proxy first")] + NoSessionCookie, + #[error(transparent)] + Register(#[from] RegisterError), + #[error("failed to reserve a loopback redirect port: {0}")] + Listener(String), + #[error("network error during the auth bridge: {0}")] + Network(String), + #[error("authorize endpoint returned HTTP {0} instead of a redirect: {1}")] + UnexpectedAuthorizeStatus(u16, String), + #[error("authorize redirect had no Location header")] + MissingLocation, + #[error("could not parse the authorize redirect Location: {0}")] + MalformedLocation(String), + #[error("too many redirects while resolving the authorize response")] + TooManyRedirects, + #[error("authorization server denied the request: {0}")] + Denied(String), + #[error("authorize redirect state did not match the request this bridge sent")] + StateMismatch, + #[error("authorize redirect was missing an authorization code")] + MissingCode, + #[error(transparent)] + TokenExchange(#[from] LoginError), +} + +/// Builds a `reqwest::Client` suitable for this bridge: redirects +/// DISABLED (the whole point of [`complete_via_cookie_jar`] is to +/// intercept the redirect itself rather than let an HTTP client silently +/// follow it into a request against a loopback address nothing is +/// listening on) and the same per-request timeout every other HTTP call +/// in this crate uses. +pub fn bridge_http_client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(15)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +/// Runs the non-interactive MCP OAuth authorize/token dance against +/// `target`, using `jar`'s captured Better Auth session cookie for `host` +/// in place of a real browser + real loopback listener. Returns a +/// [`StoredSession`] ready for [`crate::tokens::TokenStore::save`] — does +/// NOT persist it or touch the jar itself; see +/// [`crate::session::UpstreamSession::complete_session`] for the caller +/// that does both (that method purges the jar unconditionally on every +/// outcome of THIS function, success or failure — see its own doc +/// comment for why). +pub async fn complete_via_cookie_jar( + http: &reqwest::Client, + target: &str, + host: &str, + jar: &CookieJar, +) -> Result { + let cookie_header = jar.header_value(host).ok_or(BridgeError::NoSessionCookie)?; + + let redirect_uri = reserve_loopback_redirect_uri().await?; + let state = uuid::Uuid::new_v4().to_string(); + let pkce_pair = pkce::generate(); + + // See this module's doc comment §2: always register fresh (the upstream + // does exact redirect_uri matching), since this call's redirect_uri port + // is never one a previous registration could have matched. + let client_id = register::register_client(http, target, &redirect_uri).await?; + + let authorize_url = build_authorize_url( + target, + &client_id, + &redirect_uri, + &state, + &pkce_pair.challenge, + ); + + let landed = follow_to_loopback(http, &authorize_url, &cookie_header, &redirect_uri).await?; + let (code, returned_state, error) = parse_callback_params(&landed); + + if let Some(desc) = error { + return Err(BridgeError::Denied(desc)); + } + if returned_state.as_deref() != Some(state.as_str()) { + return Err(BridgeError::StateMismatch); + } + let code = code.ok_or(BridgeError::MissingCode)?; + + let token = login::exchange_code( + http, + target, + &client_id, + &code, + &redirect_uri, + &pkce_pair.verifier, + ) + .await?; + + let Some(id_token) = token.id_token else { + return Err(BridgeError::TokenExchange(LoginError::NoIdToken)); + }; + let claims = login::decode_id_token(&id_token).map_err(BridgeError::TokenExchange)?; + + Ok(StoredSession { + target: target.to_string(), + client_id, + user: UserInfo { + sub: claims.sub, + email: claims.email, + name: claims.name, + }, + access_token: token.access_token, + refresh_token: token.refresh_token, + expires_at: token.expires_in.map(|s| now_unix() + s), + created_at: now_rfc3339(), + // Owner course-correction (post-Phase-3): the cookie that + // authenticated THIS authorize call is no longer a one-shot, + // immediately-discarded credential — it's persisted into the + // returned session (`session.rs::complete_session` saves it to the + // Keychain verbatim) and attached on every future app-API + // call for this session's lifetime, because the real production + // web shell's own sign-in gate and org switcher hit Better Auth's + // NATIVE `/api/auth/*` endpoints, which reject the MCP OAuth bearer + // this crate otherwise uses (empirically confirmed — + // the native authentication contract). See `tokens.rs`'s + // `StoredSession::cookie` doc comment. + cookie: Some(cookie_header.clone()), + }) +} + +/// Binds an ephemeral loopback port just long enough to learn a free port +/// number, then immediately releases it — "a real bound listener is +/// unnecessary" per the brief: this function never actually receives a +/// request on the returned URI, since the authorize redirect's `Location` +/// header is intercepted directly (see [`follow_to_loopback`]) rather than +/// actually being requested. Binding-then-dropping still buys the same +/// OS-assigned collision avoidance `login.rs`'s real `LoopbackServer::start` +/// gets from asking for port `0`, without the cost of running an axum +/// server that would never accept a connection. +async fn reserve_loopback_redirect_uri() -> Result { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .map_err(|e| BridgeError::Listener(e.to_string()))?; + let addr = listener + .local_addr() + .map_err(|e| BridgeError::Listener(e.to_string()))?; + drop(listener); + Ok(format!("http://{addr}/")) +} + +/// `GET {target}/api/auth/mcp/authorize?...` — unlike `login.rs`'s +/// `build_authorize_url` (which points at the studio `/login` PAGE, for an +/// unauthenticated browser to complete interactively), this hits the +/// backend OAuth endpoint DIRECTLY: this bridge already has an +/// authenticated session (the jar's cookie), so there is no login page to +/// visit — see this module's doc comment for the empirical verification +/// that an authenticated, non-consent request auto-redirects from here in +/// exactly one hop. +fn build_authorize_url( + target: &str, + client_id: &str, + redirect_uri: &str, + state: &str, + code_challenge: &str, +) -> String { + let params = [ + ("client_id", client_id), + ("redirect_uri", redirect_uri), + ("response_type", "code"), + ("state", state), + ("scope", login::SCOPES), + ("code_challenge", code_challenge), + ("code_challenge_method", "S256"), + ]; + let query: String = params + .iter() + .map(|(k, v)| format!("{k}={}", urlencoding::encode(v))) + .collect::>() + .join("&"); + format!("{target}/api/auth/mcp/authorize?{query}") +} + +/// Issues the authorize GET (carrying the jar's cookie) and walks any +/// redirect chain — following each `Location` in turn, re-attaching the +/// SAME cookie header on every hop — until a `Location` matches +/// `redirect_uri`'s scheme+host+port+path. Returns that final URL (query +/// string intact, containing `code`/`state`/`error`) WITHOUT ever actually +/// requesting it — the loopback address has nothing listening on it by +/// design (see [`reserve_loopback_redirect_uri`]). +async fn follow_to_loopback( + http: &reqwest::Client, + start_url: &str, + cookie_header: &str, + redirect_uri: &str, +) -> Result { + let target_uri = + Url::parse(redirect_uri).map_err(|e| BridgeError::MalformedLocation(e.to_string()))?; + let mut current = + Url::parse(start_url).map_err(|e| BridgeError::MalformedLocation(e.to_string()))?; + + for _ in 0..MAX_REDIRECT_HOPS { + if same_endpoint(¤t, &target_uri) { + return Ok(current); + } + let resp = http + .get(current.clone()) + .header(COOKIE, cookie_header) + .send() + .await + .map_err(|e| BridgeError::Network(e.to_string()))?; + let status = resp.status(); + if !status.is_redirection() { + let body = resp.text().await.unwrap_or_default(); + return Err(BridgeError::UnexpectedAuthorizeStatus( + status.as_u16(), + body, + )); + } + let location = resp + .headers() + .get(LOCATION) + .and_then(|v| v.to_str().ok()) + .ok_or(BridgeError::MissingLocation)? + .to_string(); + current = current + .join(&location) + .map_err(|e| BridgeError::MalformedLocation(e.to_string()))?; + } + Err(BridgeError::TooManyRedirects) +} + +/// Scheme+host+port+path equality — deliberately ignores the query string +/// (which carries `code`/`state`, the whole reason we're comparing) and +/// fragment. +fn same_endpoint(a: &Url, b: &Url) -> bool { + a.scheme() == b.scheme() + && a.host_str() == b.host_str() + && a.port_or_known_default() == b.port_or_known_default() + && a.path() == b.path() +} + +fn parse_callback_params(url: &Url) -> (Option, Option, Option) { + let mut code = None; + let mut state = None; + let mut error = None; + for (k, v) in url.query_pairs() { + match k.as_ref() { + "code" => code = Some(v.into_owned()), + "state" => state = Some(v.into_owned()), + "error" => error = Some(v.into_owned()), + _ => {} + } + } + (code, state, error) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::extract::Query; + use axum::http::StatusCode; + use axum::response::{IntoResponse, Redirect}; + use axum::routing::{get, post}; + use axum::{Json, Router}; + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use tokio::net::TcpListener; + + async fn spawn(app: Router) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + format!("http://{addr}") + } + + // --- reserve_loopback_redirect_uri / same_endpoint / parse_callback_params --- + + #[tokio::test] + async fn reserve_loopback_redirect_uri_returns_a_usable_http_url() { + let uri = reserve_loopback_redirect_uri().await.unwrap(); + assert!(uri.starts_with("http://127.0.0.1:")); + assert!(uri.ends_with('/')); + let parsed = Url::parse(&uri).unwrap(); + assert_eq!(parsed.host_str(), Some("127.0.0.1")); + assert!(parsed.port().is_some()); + } + + #[tokio::test] + async fn two_reservations_never_collide() { + let a = reserve_loopback_redirect_uri().await.unwrap(); + let b = reserve_loopback_redirect_uri().await.unwrap(); + assert_ne!(a, b); + } + + #[test] + fn same_endpoint_ignores_query_and_fragment() { + let a = Url::parse("http://127.0.0.1:5000/?code=abc&state=xyz").unwrap(); + let b = Url::parse("http://127.0.0.1:5000/").unwrap(); + assert!(same_endpoint(&a, &b)); + } + + #[test] + fn same_endpoint_rejects_a_different_port() { + let a = Url::parse("http://127.0.0.1:5000/").unwrap(); + let b = Url::parse("http://127.0.0.1:5001/").unwrap(); + assert!(!same_endpoint(&a, &b)); + } + + #[test] + fn parse_callback_params_extracts_code_and_state() { + let url = Url::parse("http://127.0.0.1:5000/?code=abc123&state=my-state").unwrap(); + let (code, state, error) = parse_callback_params(&url); + assert_eq!(code.as_deref(), Some("abc123")); + assert_eq!(state.as_deref(), Some("my-state")); + assert_eq!(error, None); + } + + #[test] + fn parse_callback_params_extracts_error() { + let url = Url::parse("http://127.0.0.1:5000/?error=access_denied&state=my-state").unwrap(); + let (code, state, error) = parse_callback_params(&url); + assert_eq!(code, None); + assert_eq!(state.as_deref(), Some("my-state")); + assert_eq!(error.as_deref(), Some("access_denied")); + } + + // --- follow_to_loopback: the redirect-chain walker --------------------- + + #[tokio::test] + async fn follow_to_loopback_resolves_a_single_hop_carrying_the_cookie() { + let seen_cookie = Arc::new(Mutex::new(None::)); + let seen_for_route = seen_cookie.clone(); + let app = Router::new().route( + "/api/auth/mcp/authorize", + get(move |headers: axum::http::HeaderMap| { + let seen = seen_for_route.clone(); + async move { + *seen.lock().unwrap() = headers + .get(axum::http::header::COOKIE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + Redirect::to("http://127.0.0.1:9999/?code=the-code&state=the-state") + } + }), + ); + let base = spawn(app).await; + let http = bridge_http_client(); + + let landed = follow_to_loopback( + &http, + &format!("{base}/api/auth/mcp/authorize"), + "better-auth.session_token=abc", + "http://127.0.0.1:9999/", + ) + .await + .unwrap(); + + assert_eq!( + landed.as_str(), + "http://127.0.0.1:9999/?code=the-code&state=the-state" + ); + assert_eq!( + seen_cookie.lock().unwrap().as_deref(), + Some("better-auth.session_token=abc") + ); + } + + #[tokio::test] + async fn follow_to_loopback_walks_multiple_hops() { + let app = Router::new() + .route("/hop1", get(|| async { Redirect::to("/hop2") })) + .route( + "/hop2", + get(|| async { Redirect::to("http://127.0.0.1:9999/?code=multi-hop&state=s") }), + ); + let base = spawn(app).await; + let http = bridge_http_client(); + + let landed = follow_to_loopback( + &http, + &format!("{base}/hop1"), + "cookie=1", + "http://127.0.0.1:9999/", + ) + .await + .unwrap(); + assert_eq!(landed.query(), Some("code=multi-hop&state=s")); + } + + #[tokio::test] + async fn follow_to_loopback_gives_up_after_too_many_hops() { + let hop_count = Arc::new(AtomicUsize::new(0)); + let count_for_route = hop_count.clone(); + let app = Router::new().route( + "/loop", + get(move || { + let count = count_for_route.clone(); + async move { + let n = count.fetch_add(1, Ordering::SeqCst); + Redirect::to(&format!("/loop?n={n}")) + } + }), + ); + let base = spawn(app).await; + let http = bridge_http_client(); + + let err = follow_to_loopback( + &http, + &format!("{base}/loop"), + "cookie=1", + "http://127.0.0.1:9999/", + ) + .await + .unwrap_err(); + assert!(matches!(err, BridgeError::TooManyRedirects)); + } + + #[tokio::test] + async fn follow_to_loopback_surfaces_a_non_redirect_status() { + let app = Router::new().route( + "/api/auth/mcp/authorize", + get(|| async { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"code":"INVALID_REDIRECT_URI"})), + ) + }), + ); + let base = spawn(app).await; + let http = bridge_http_client(); + + let err = follow_to_loopback( + &http, + &format!("{base}/api/auth/mcp/authorize"), + "cookie=1", + "http://127.0.0.1:9999/", + ) + .await + .unwrap_err(); + match err { + BridgeError::UnexpectedAuthorizeStatus(status, body) => { + assert_eq!(status, 400); + assert!(body.contains("INVALID_REDIRECT_URI")); + } + other => panic!("expected UnexpectedAuthorizeStatus, got {other:?}"), + } + } + + // --- complete_via_cookie_jar: end-to-end against a mock upstream -------- + + /// Spawns a mock Better Auth-shaped upstream: `/api/auth/mcp/register`, + /// `/api/auth/mcp/authorize` (redirects with `code`/`state` when the + /// expected cookie is present, otherwise 401), and `/api/auth/mcp/token`. + async fn spawn_mock_mesh(expected_cookie: &'static str) -> String { + let app = Router::new() + .route( + "/api/auth/mcp/register", + post(|| async { + Json(serde_json::json!({"client_id": "mock-client-id"})) + }), + ) + .route( + "/api/auth/mcp/authorize", + get( + move |Query(params): Query>, + headers: axum::http::HeaderMap| { + async move { + let cookie = headers + .get(axum::http::header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if cookie != expected_cookie { + return (StatusCode::UNAUTHORIZED, "no session").into_response(); + } + let redirect_uri = params.get("redirect_uri").cloned().unwrap(); + let state = params.get("state").cloned().unwrap_or_default(); + let mut url = Url::parse(&redirect_uri).unwrap(); + url.query_pairs_mut() + .append_pair("code", "mock-auth-code") + .append_pair("state", &state); + Redirect::to(url.as_str()).into_response() + } + }, + ), + ) + .route( + "/api/auth/mcp/token", + post(|| async { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + let payload = serde_json::json!({"sub":"user_1","email":"bridge@example.test","name":"Bridge User"}); + let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string()); + let id_token = format!("h.{payload_b64}.s"); + Json(serde_json::json!({ + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_in": 3600, + "id_token": id_token, + })) + }), + ); + spawn(app).await + } + + #[tokio::test] + async fn complete_via_cookie_jar_succeeds_end_to_end() { + let target = spawn_mock_mesh("better-auth.session_token=abc").await; + let jar = CookieJar::new(); + jar.capture( + "mesh.host", + std::iter::once("better-auth.session_token=abc; Path=/; HttpOnly"), + ); + + let http = bridge_http_client(); + let session = complete_via_cookie_jar(&http, &target, "mesh.host", &jar) + .await + .expect("bridge should succeed against a well-behaved mock upstream"); + + assert_eq!(session.access_token, "mock-access-token"); + assert_eq!(session.refresh_token.as_deref(), Some("mock-refresh-token")); + assert_eq!(session.user.email.as_deref(), Some("bridge@example.test")); + assert_eq!(session.client_id, "mock-client-id"); + assert_eq!( + session.cookie.as_deref(), + Some("better-auth.session_token=abc"), + "the cookie that authenticated this bridge must ride along on the returned session \ + so it can be persisted for the app's lifetime, not discarded" + ); + // The jar itself is untouched by this function — purging is the + // caller's (`UpstreamSession::complete_session`) responsibility. + assert!(!jar.is_empty("mesh.host")); + } + + #[tokio::test] + async fn complete_via_cookie_jar_fails_fast_with_no_captured_cookie() { + let target = "http://unused.invalid".to_string(); + let jar = CookieJar::new(); + let http = bridge_http_client(); + + let err = complete_via_cookie_jar(&http, &target, "mesh.host", &jar) + .await + .unwrap_err(); + assert!(matches!(err, BridgeError::NoSessionCookie)); + } + + #[tokio::test] + async fn complete_via_cookie_jar_propagates_an_unauthenticated_authorize_rejection() { + // The mock only accepts a SPECIFIC cookie value; seed the jar with a + // different one to exercise the "session cookie the mock doesn't + // recognize" path (analogous to an expired/invalidated cookie). + let target = spawn_mock_mesh("better-auth.session_token=expected").await; + let jar = CookieJar::new(); + jar.capture( + "mesh.host", + std::iter::once("better-auth.session_token=stale"), + ); + + let http = bridge_http_client(); + let err = complete_via_cookie_jar(&http, &target, "mesh.host", &jar) + .await + .unwrap_err(); + assert!(matches!( + err, + BridgeError::UnexpectedAuthorizeStatus(401, _) + )); + } +} diff --git a/apps/native/crates/upstream/src/browser_page.rs b/apps/native/crates/upstream/src/browser_page.rs new file mode 100644 index 0000000000..b2015da4e0 --- /dev/null +++ b/apps/native/crates/upstream/src/browser_page.rs @@ -0,0 +1,233 @@ +//! Shared chrome for the pages this app serves into the user's REAL browser. +//! +//! A couple of desktop flows deliberately finish outside the webview — sign-in +//! and MCP OAuth both hand the authorize URL to the system browser — and each +//! one needs a dead-end page telling the user to come back to the app. Those +//! pages are the only Studio surface a user ever sees that React does not +//! render, so without a shared definition they drift away from the product's +//! look one page at a time. +//! +//! The tokens are literal `oklch()` values copied from +//! `packages/ui/src/styles/global.css`'s `:root`/`.dark` blocks and keyed off +//! `prefers-color-scheme`, because a static page has no `.dark` class toggle +//! to hook into. Everything is inlined: these pages are served to a browser +//! that is NOT the app, frequently while offline, so a stylesheet or font +//! request that fails would leave the user staring at unstyled text. + +/// Shared tokens + reset. +const STYLE_BASE: &str = r#" + :root { + color-scheme: light dark; + --background: oklch(0.99 0.003 73); + --foreground: oklch(0.145 0.01 60); + --sidebar: oklch(0.975 0.006 80); + --muted: oklch(0.955 0.006 80); + --muted-foreground: oklch(0.46 0.012 60); + --border: oklch(0.915 0.005 80); + --destructive: oklch(0.58 0.22 27); + --font-sans: "Inter var", "Inter", ui-sans-serif, system-ui, -apple-system, + BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + } + @media (prefers-color-scheme: dark) { + :root { + --background: oklch(0.175 0.005 60); + --foreground: oklch(0.96 0.005 60); + --sidebar: oklch(0.205 0.005 60); + --muted: oklch(0.235 0.005 60); + --muted-foreground: oklch(0.62 0.008 60); + --border: oklch(0.28 0.005 60); + --destructive: oklch(0.62 0.22 27); + } + } + * { margin: 0; padding: 0; box-sizing: border-box; } + html, body { height: 100%; } + body { + font-family: var(--font-sans); + font-weight: 450; + background: var(--background); + color: var(--foreground); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + .split { display: flex; min-height: 100vh; width: 100%; } + .form-col { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: var(--sidebar); + padding: 2.5rem 1.5rem; + } + .content { width: 100%; max-width: 440px; display: grid; gap: 2.5rem; } + .brand-mark { width: 3rem; height: 3rem; display: block; } + .brand-mark--dark { display: none; } + @media (prefers-color-scheme: dark) { + .brand-mark--light { display: none; } + .brand-mark--dark { display: block; } + } + .header { display: grid; gap: 0.75rem; } + h1 { font-size: 1.875rem; line-height: 2.25rem; font-weight: 600; letter-spacing: -0.025em; } + p { font-size: 1rem; line-height: 1.5rem; color: var(--muted-foreground); } + .hint { font-size: 0.875rem; } + .visual-col { flex: 1; background: var(--muted); display: none; } + @media (min-width: 768px) { .visual-col { display: block; } } +"#; + +/// Applied only when [`Page::destructive`] is set. +const DESTRUCTIVE_STYLE: &str = r#" + h1 { color: var(--destructive); } +"#; + +/// The light-mode `deco` logo mark (`apps/web/public/logos/deco logo.svg`), +/// inlined verbatim — same asset the prod login form uses. +const BRAND_MARK_LIGHT_SVG: &str = r##""##; + +/// The dark-mode `deco` logo mark (`apps/web/public/logos/deco logo negative.svg`). +const BRAND_MARK_DARK_SVG: &str = r##""##; + +/// One dead-end page. +pub struct Page<'a> { + /// Browser tab title. + pub title: &'a str, + /// Headline. + pub heading: &'a str, + /// Lead paragraph. + pub body: &'a str, + /// Smaller follow-up line, usually "close this tab and…". + pub hint: Option<&'a str>, + /// Colour the headline with `--destructive`. + pub destructive: bool, +} + +/// Render a page. +/// +/// Every caller-supplied field is HTML-escaped here rather than at the call +/// site, so a caller cannot forget: these strings routinely carry provider- or +/// server-supplied text, and this page is served from the app's own origin. +pub fn render(page: Page<'_>) -> String { + let hint = match page.hint { + Some(hint) => format!("\n

{}

", html_escape(hint)), + None => String::new(), + }; + format!( + r#" + + + + + +{title} + + + +
+
+
+ {light} + {dark} +
+

{heading}

+

{body}

{hint} +
+
+
+ +
+ +"#, + title = html_escape(page.title), + base = STYLE_BASE, + extra = if page.destructive { + DESTRUCTIVE_STYLE + } else { + "" + }, + light = BRAND_MARK_LIGHT_SVG, + dark = BRAND_MARK_DARK_SVG, + heading = html_escape(page.heading), + body = html_escape(page.body), + hint = hint, + ) +} + +pub fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(destructive: bool) -> String { + render(Page { + title: "T", + heading: "H", + body: "B", + hint: Some("Hint"), + destructive, + }) + } + + #[test] + fn carries_the_shared_chrome() { + let html = sample(false); + // Design tokens, both schemes, and the brand mark travel with the page + // — it is served to a browser that may be offline, so nothing is + // allowed to be a separate request. + assert!(html.contains("--background: oklch(0.99 0.003 73)")); + assert!(html.contains("prefers-color-scheme: dark")); + assert!(html.contains("brand-mark--light")); + assert!(html.contains("brand-mark--dark")); + assert!(!html.contains("t", + heading: "", + body: "", + hint: Some(""), + destructive: false, + }); + for raw in [" & \"quoted\""); + assert!(html.contains("Sign-in failed")); + assert!(html.contains("<script>alert(1)</script> & "quoted"")); + assert!(!html.contains("")); + assert!(html.contains("brand-mark--light")); + assert!(html.contains("brand-mark--dark")); + } + + #[tokio::test] + async fn loopback_server_resolves_code_on_matching_state() { + let server = LoopbackServer::start( + "expected-state".to_string(), + "http://upstream.test/cli/auth-success".to_string(), + ) + .await + .unwrap(); + let addr = server.local_addr; + let waiter = tokio::spawn(server.wait_for_callback(Duration::from_secs(5))); + + // Give the server a moment to actually start accepting. + tokio::time::sleep(Duration::from_millis(50)).await; + // Redirects disabled: success now 302s to the mesh-hosted + // /cli/auth-success page (owner decision — legacy CLI parity); + // following it would DNS-resolve the fake upstream host. + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + let res = client + .get(format!( + "http://{addr}/?code=auth-code-123&state=expected-state" + )) + .send() + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + res.headers().get("location").unwrap(), + "http://upstream.test/cli/auth-success" + ); + + let code = waiter.await.unwrap().unwrap(); + assert_eq!(code, "auth-code-123"); + } + + #[tokio::test] + async fn loopback_server_rejects_state_mismatch() { + let server = LoopbackServer::start( + "expected-state".to_string(), + "http://upstream.test/cli/auth-success".to_string(), + ) + .await + .unwrap(); + let addr = server.local_addr; + let waiter = tokio::spawn(server.wait_for_callback(Duration::from_secs(5))); + + tokio::time::sleep(Duration::from_millis(50)).await; + let client = reqwest::Client::new(); + let res = client + .get(format!("http://{addr}/?code=abc&state=WRONG")) + .send() + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + + let err = waiter.await.unwrap().unwrap_err(); + assert!(matches!(err, LoginError::StateMismatch)); + } + + #[tokio::test] + async fn loopback_server_surfaces_authorization_server_error_param() { + let server = LoopbackServer::start( + "expected-state".to_string(), + "http://upstream.test/cli/auth-success".to_string(), + ) + .await + .unwrap(); + let addr = server.local_addr; + let waiter = tokio::spawn(server.wait_for_callback(Duration::from_secs(5))); + + tokio::time::sleep(Duration::from_millis(50)).await; + let client = reqwest::Client::new(); + let _ = client + .get(format!( + "http://{addr}/?error=access_denied&error_description=user+cancelled&state=expected-state" + )) + .send() + .await + .unwrap(); + + let err = waiter.await.unwrap().unwrap_err(); + match err { + LoginError::Denied(msg) => assert!(msg.contains("access_denied")), + other => panic!("expected Denied, got {other:?}"), + } + } + + #[tokio::test] + async fn loopback_server_times_out_when_nothing_ever_calls_back() { + let server = LoopbackServer::start( + "expected-state".to_string(), + "http://upstream.test/cli/auth-success".to_string(), + ) + .await + .unwrap(); + let err = server + .wait_for_callback(Duration::from_millis(50)) + .await + .unwrap_err(); + assert!(matches!(err, LoginError::Timeout)); + } + + /// Interactive end-to-end login against the REAL upstream target, + /// opening a REAL system browser — deliberately `#[ignore]`d, per the + /// brief ("do NOT attempt a real browser login in CI/agents"). Manual + /// step for a human on a macOS dev machine with a valid decocms + /// account: + /// DECOCMS_UPSTREAM_URL=https://studio.decocms.com \ + /// cargo test -p upstream real_interactive_login -- --ignored --nocapture + #[tokio::test] + #[ignore] + async fn real_interactive_login() { + let target = std::env::var("DECOCMS_UPSTREAM_URL") + .unwrap_or_else(|_| "https://studio.decocms.com".to_string()); + let http = reqwest::Client::new(); + let session = perform_interactive_login(&http, &target, open_system_browser) + .await + .expect("interactive login should succeed when a human completes it in the browser"); + println!("logged in as {:?}", session.user); + } +} diff --git a/apps/native/crates/upstream/src/pkce.rs b/apps/native/crates/upstream/src/pkce.rs new file mode 100644 index 0000000000..1609405271 --- /dev/null +++ b/apps/native/crates/upstream/src/pkce.rs @@ -0,0 +1,101 @@ +//! OAuth 2.1 + PKCE (RFC 7636) code-verifier / code-challenge generation. +//! Port of `apps/api/src/cli/lib/pkce.ts::generatePkcePair` — same shape +//! (32 random bytes -> base64url verifier; `S256` challenge = base64url of +//! the verifier's SHA-256 digest), same "generate a fresh pair per login +//! attempt" call site (`login.rs`). + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use sha2::{Digest, Sha256}; + +pub struct PkcePair { + /// Sent to the token endpoint (`code_verifier`) to prove possession of + /// the same secret that produced `challenge`. Never sent to the + /// authorize endpoint or the browser. + pub verifier: String, + /// Sent up-front in the authorize URL (`code_challenge`, + /// `code_challenge_method=S256`). + pub challenge: String, +} + +/// Generates a fresh, high-entropy verifier (32 random bytes, base64url — +/// 43 characters, well within RFC 7636's 43-128 char requirement) and its +/// derived `S256` challenge. +pub fn generate() -> PkcePair { + let mut raw = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut raw); + let verifier = base64_url_no_pad(&raw); + let challenge = challenge_for(&verifier); + PkcePair { + verifier, + challenge, + } +} + +/// `code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))` per +/// RFC 7636 §4.2 — split out from [`generate`] so it's independently +/// testable against the RFC's Appendix B worked example. +pub fn challenge_for(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + base64_url_no_pad(&digest) +} + +fn base64_url_no_pad(bytes: &[u8]) -> String { + URL_SAFE_NO_PAD.encode(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// RFC 7636 Appendix B's worked example — the one pinned, external + /// source-of-truth test vector for the S256 transform. + #[test] + fn matches_rfc_7636_appendix_b_vector() { + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + let challenge = challenge_for(verifier); + assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + } + + #[test] + fn generated_verifier_is_43_chars_of_the_unreserved_alphabet() { + let pair = generate(); + assert_eq!( + pair.verifier.len(), + 43, + "32 random bytes -> 43 base64url chars, no padding" + ); + assert!(pair + .verifier + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')); + } + + #[test] + fn generated_challenge_matches_generated_verifier() { + let pair = generate(); + assert_eq!(challenge_for(&pair.verifier), pair.challenge); + } + + #[test] + fn two_generated_pairs_never_collide() { + let a = generate(); + let b = generate(); + assert_ne!( + a.verifier, b.verifier, + "verifier must be freshly random per pair" + ); + assert_ne!(a.challenge, b.challenge); + } + + #[test] + fn base64url_output_never_contains_padding_or_url_unsafe_chars() { + let pair = generate(); + for s in [&pair.verifier, &pair.challenge] { + assert!(!s.contains('='), "must be unpadded"); + assert!( + !s.contains('+') && !s.contains('/'), + "must be URL-safe alphabet" + ); + } + } +} diff --git a/apps/native/crates/upstream/src/refresh.rs b/apps/native/crates/upstream/src/refresh.rs new file mode 100644 index 0000000000..8cc5e239fa --- /dev/null +++ b/apps/native/crates/upstream/src/refresh.rs @@ -0,0 +1,520 @@ +//! Access-token refresh + single-flight coordination. +//! +//! Port of `apps/api/src/cli/lib/refresh-session.ts` / +//! `get-valid-session.ts`'s expiry-skew + refresh-on-demand logic, plus a +//! single-flight guard neither TS file needs (the CLI is a one-shot +//! process; `local-api` is a long-lived server that can receive several +//! concurrent app-API requests whose access token has simultaneously +//! expired — without single-flighting, each would independently spend the +//! session's refresh token, and every `refresh_token` rotation in the +//! response body except the LAST writer's would silently strand the +//! others' in-flight requests on a token the server already superseded). + +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::Deserialize; +use tokio::sync::Mutex; + +use crate::tokens::{StoredSession, TokenStore, TokenStoreError}; + +/// Treat an access token as expired this many seconds before its declared +/// expiry, to avoid a request racing the exact expiry instant — byte-value +/// port of `get-valid-session.ts`'s `EXPIRY_SKEW_SECONDS`. +pub const EXPIRY_SKEW: Duration = Duration::from_secs(60); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefreshErrorKind { + /// No stored session for this host at all — never logged in, or + /// already signed out. + NoSession, + /// The refresh token is missing, or the server rejected it (4xx) — + /// this session is dead; the caller should treat it as signed-out. + InvalidGrant, + /// Network failure or a 5xx from the token endpoint, or a local + /// storage failure — transient; the caller should NOT sign the user + /// out over this, just surface/report and let a later attempt retry. + Transient, +} + +#[derive(Debug, Clone)] +pub struct RefreshError { + pub kind: RefreshErrorKind, + pub message: String, +} + +impl RefreshError { + pub(crate) fn no_session() -> Self { + Self { + kind: RefreshErrorKind::NoSession, + message: "no upstream session for this host".to_string(), + } + } + + fn invalid_grant(message: impl Into) -> Self { + Self { + kind: RefreshErrorKind::InvalidGrant, + message: message.into(), + } + } + + fn transient(message: impl Into) -> Self { + Self { + kind: RefreshErrorKind::Transient, + message: message.into(), + } + } +} + +impl std::fmt::Display for RefreshError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}: {}", self.kind, self.message) + } +} + +impl std::error::Error for RefreshError {} + +impl From for RefreshError { + fn from(err: TokenStoreError) -> Self { + RefreshError::transient(err.to_string()) + } +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option, + expires_in: Option, +} + +fn now_unix() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn is_expired(session: &StoredSession, skew: Duration) -> bool { + match session.expires_at { + None => false, + Some(exp) => exp - skew.as_secs() as i64 <= now_unix(), + } +} + +/// `POST {target}/api/auth/mcp/token` with `grant_type=refresh_token` — +/// byte-parity request shape with `refresh-session.ts::refreshSession`. +async fn refresh_access_token( + http: &reqwest::Client, + session: &StoredSession, +) -> Result { + let Some(refresh_token) = session.refresh_token.as_deref() else { + return Err(RefreshError::invalid_grant("session has no refresh token")); + }; + + let res = http + .post(format!("{}/api/auth/mcp/token", session.target)) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", session.client_id.as_str()), + ]) + .send() + .await + .map_err(|e| RefreshError::transient(e.to_string()))?; + + let status = res.status(); + if !status.is_success() { + let text = res.text().await.unwrap_or_default(); + if status.is_client_error() { + return Err(RefreshError::invalid_grant(format!("HTTP {status} {text}"))); + } + return Err(RefreshError::transient(format!("HTTP {status} {text}"))); + } + + res.json::() + .await + .map_err(|e| RefreshError::transient(format!("malformed token response: {e}"))) +} + +/// Single-flight refresh coordinator, one per [`crate::session::UpstreamSession`]. +/// Concurrent callers for the same host collapse into ONE network refresh +/// via a mutex + "did someone already win this race" double-check keyed on +/// the observed `access_token` value (not on expiry — see `do_refresh`'s +/// doc comment for why expiry is the wrong signal to double-check against). +pub struct RefreshCoordinator { + lock: Arc>, +} + +impl RefreshCoordinator { + pub fn new() -> Self { + Self { + lock: Arc::new(Mutex::new(())), + } + } + + /// Returns a session with a non-expired access token, refreshing only + /// if needed (cheap `is_expired` check, no lock acquired when the + /// cached token is still good — the overwhelmingly common case on a + /// per-request hot path). + pub async fn ensure_fresh( + &self, + http: &reqwest::Client, + store: &dyn TokenStore, + host: &str, + ) -> Result { + let Some(current) = store.load(host).await? else { + return Err(RefreshError::no_session()); + }; + self.ensure_fresh_observed(http, store, host, current).await + } + + /// Like [`Self::ensure_fresh`], but starts from the caller's already-loaded + /// session. `UpstreamSession` keeps a process-local cache specifically so a + /// normal proxied request does not ask macOS Keychain for the same value + /// again; only the actual refresh critical section re-reads durable storage + /// to preserve refresh-token single-flight semantics. + pub(crate) async fn ensure_fresh_observed( + &self, + http: &reqwest::Client, + store: &dyn TokenStore, + host: &str, + current: StoredSession, + ) -> Result { + if !is_expired(¤t, EXPIRY_SKEW) { + return Ok(current); + } + self.do_refresh(http, store, host, current).await + } + + /// Unconditionally refreshes (still single-flighted), for the "upstream + /// itself just rejected this access token with a 401" case where the + /// locally-cached expiry can't be trusted (a server-side revocation + /// invalidates a token before its declared `expires_at`). + pub async fn force_refresh( + &self, + http: &reqwest::Client, + store: &dyn TokenStore, + host: &str, + ) -> Result { + let Some(current) = store.load(host).await? else { + return Err(RefreshError::no_session()); + }; + self.force_refresh_observed(http, store, host, current) + .await + } + + /// Like [`Self::force_refresh`], but reuses a session already loaded by the + /// caller. See [`Self::ensure_fresh_observed`] for why the distinction + /// matters on the proxy hot path. + pub(crate) async fn force_refresh_observed( + &self, + http: &reqwest::Client, + store: &dyn TokenStore, + host: &str, + current: StoredSession, + ) -> Result { + self.do_refresh(http, store, host, current).await + } + + /// The single-flight critical section. `observed` is whatever the + /// caller read from `store` BEFORE acquiring the lock. After + /// acquiring, we re-read `store` and compare `access_token` identity: + /// if it changed, a concurrent caller already won this race and + /// refreshed on our behalf — reuse their result instead of spending + /// the (now possibly already-rotated) refresh token a second time. + /// This is deliberately an identity check, not an `is_expired` check — + /// `force_refresh` calls this with a session `is_expired` would call + /// "still valid" (that's the whole point of forcing), so re-checking + /// expiry here would defeat single-flighting for exactly the + /// 401-triggered path that most needs it. + async fn do_refresh( + &self, + http: &reqwest::Client, + store: &dyn TokenStore, + host: &str, + observed: StoredSession, + ) -> Result { + let _guard = self.lock.lock().await; + if let Ok(Some(latest)) = store.load(host).await { + if latest.access_token != observed.access_token { + return Ok(latest); + } + } + + let token = refresh_access_token(http, &observed).await?; + let updated = StoredSession { + access_token: token.access_token, + refresh_token: token.refresh_token.or(observed.refresh_token), + expires_at: token + .expires_in + .map(|s| now_unix() + s) + .or(observed.expires_at), + ..observed + }; + store.save(host, updated.clone()).await?; + Ok(updated) + } +} + +impl Default for RefreshCoordinator { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tokens::{test_support::MemoryTokenStore, UserInfo}; + use axum::{extract::State, routing::post, Json, Router}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::net::TcpListener; + + fn expired_session(target: &str, access_token: &str) -> StoredSession { + StoredSession { + target: target.to_string(), + client_id: "client_1".to_string(), + user: UserInfo { + sub: "user_1".to_string(), + email: None, + name: None, + }, + access_token: access_token.to_string(), + refresh_token: Some("refresh-1".to_string()), + expires_at: Some(0), // already expired + created_at: "2026-01-01T00:00:00Z".to_string(), + cookie: None, + } + } + + /// Spawns a fake `/api/auth/mcp/token` refresh endpoint that counts + /// calls and returns a fresh, unique access token each time it's hit — + /// the "mock upstream via a local axum test server" the brief asks + /// for. + async fn spawn_mock_token_server() -> (String, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + let app = Router::new() + .route( + "/api/auth/mcp/token", + post( + move |State(calls): State>, _body: axum::body::Bytes| { + let n = calls.fetch_add(1, Ordering::SeqCst) + 1; + async move { + Json(serde_json::json!({ + "access_token": format!("refreshed-{n}"), + "refresh_token": format!("refresh-{n}"), + "expires_in": 3600, + })) + } + }, + ), + ) + .with_state(calls.clone()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + (format!("http://{addr}"), calls) + } + + #[tokio::test] + async fn ensure_fresh_returns_cached_session_when_not_expired() { + let store = MemoryTokenStore::new(); + let mut session = expired_session("http://example.invalid", "still-good"); + session.expires_at = Some(now_unix() + 3600); + store.save("host", session.clone()).await.unwrap(); + + let coordinator = RefreshCoordinator::new(); + let http = reqwest::Client::new(); + let out = coordinator + .ensure_fresh(&http, &store, "host") + .await + .unwrap(); + assert_eq!(out.access_token, "still-good"); + } + + #[tokio::test] + async fn ensure_fresh_refreshes_an_expired_session() { + let (target, calls) = spawn_mock_token_server().await; + let store = MemoryTokenStore::new(); + store + .save("host", expired_session(&target, "old")) + .await + .unwrap(); + + let coordinator = RefreshCoordinator::new(); + let http = reqwest::Client::new(); + let out = coordinator + .ensure_fresh(&http, &store, "host") + .await + .unwrap(); + + assert_eq!(out.access_token, "refreshed-1"); + assert_eq!(out.refresh_token.as_deref(), Some("refresh-1")); + assert_eq!(calls.load(Ordering::SeqCst), 1); + // Persisted back to the store. + let reloaded = store.load("host").await.unwrap().unwrap(); + assert_eq!(reloaded.access_token, "refreshed-1"); + } + + #[tokio::test] + async fn concurrent_ensure_fresh_calls_single_flight_into_one_refresh() { + let (target, calls) = spawn_mock_token_server().await; + let store = Arc::new(MemoryTokenStore::new()); + store + .save("host", expired_session(&target, "old")) + .await + .unwrap(); + + let coordinator = Arc::new(RefreshCoordinator::new()); + let http = Arc::new(reqwest::Client::new()); + + let mut handles = Vec::new(); + for _ in 0..8 { + let coordinator = coordinator.clone(); + let http = http.clone(); + let store = store.clone(); + handles.push(tokio::spawn(async move { + coordinator + .ensure_fresh(&http, store.as_ref(), "host") + .await + })); + } + + let mut tokens = Vec::new(); + for h in handles { + tokens.push(h.await.unwrap().unwrap().access_token); + } + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "8 concurrent expired-token callers must collapse into exactly 1 network refresh" + ); + assert!( + tokens.iter().all(|t| t == "refreshed-1"), + "every caller must observe the SAME refreshed token, not a partial/racy one: {tokens:?}" + ); + } + + #[tokio::test] + async fn concurrent_force_refresh_calls_single_flight_too() { + // `force_refresh` bypasses the expiry check entirely (the 401-retry + // path) — the single-flight guard must still collapse concurrent + // callers, since this is exactly the shape multiple simultaneous + // app-API requests hitting a server-side-revoked token + // produce. + let (target, calls) = spawn_mock_token_server().await; + let store = Arc::new(MemoryTokenStore::new()); + let mut session = expired_session(&target, "old"); + session.expires_at = Some(now_unix() + 3600); // NOT locally expired + store.save("host", session).await.unwrap(); + + let coordinator = Arc::new(RefreshCoordinator::new()); + let http = Arc::new(reqwest::Client::new()); + + let mut handles = Vec::new(); + for _ in 0..5 { + let coordinator = coordinator.clone(); + let http = http.clone(); + let store = store.clone(); + handles.push(tokio::spawn(async move { + coordinator + .force_refresh(&http, store.as_ref(), "host") + .await + })); + } + for h in handles { + assert_eq!(h.await.unwrap().unwrap().access_token, "refreshed-1"); + } + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn no_refresh_token_is_invalid_grant() { + let store = MemoryTokenStore::new(); + let mut session = expired_session("http://example.invalid", "old"); + session.refresh_token = None; + store.save("host", session).await.unwrap(); + + let coordinator = RefreshCoordinator::new(); + let http = reqwest::Client::new(); + let err = coordinator + .ensure_fresh(&http, &store, "host") + .await + .unwrap_err(); + assert_eq!(err.kind, RefreshErrorKind::InvalidGrant); + } + + #[tokio::test] + async fn missing_session_is_no_session_error() { + let store = MemoryTokenStore::new(); + let coordinator = RefreshCoordinator::new(); + let http = reqwest::Client::new(); + let err = coordinator + .ensure_fresh(&http, &store, "host") + .await + .unwrap_err(); + assert_eq!(err.kind, RefreshErrorKind::NoSession); + } + + #[tokio::test] + async fn rejected_refresh_token_is_invalid_grant() { + let app = Router::new().route( + "/api/auth/mcp/token", + post(|| async { + ( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "invalid_grant"})), + ) + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let store = MemoryTokenStore::new(); + store + .save("host", expired_session(&format!("http://{addr}"), "old")) + .await + .unwrap(); + + let coordinator = RefreshCoordinator::new(); + let http = reqwest::Client::new(); + let err = coordinator + .ensure_fresh(&http, &store, "host") + .await + .unwrap_err(); + assert_eq!(err.kind, RefreshErrorKind::InvalidGrant); + } + + #[tokio::test] + async fn network_failure_is_transient_not_invalid_grant() { + let store = MemoryTokenStore::new(); + // Reserve then immediately drop a port so nothing listens there — + // guarantees a fast "connection refused" rather than a firewall + // black-hole timeout. + let dead_port = { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + store + .save( + "host", + expired_session(&format!("http://127.0.0.1:{dead_port}"), "old"), + ) + .await + .unwrap(); + + let coordinator = RefreshCoordinator::new(); + let http = reqwest::Client::new(); + let err = coordinator + .ensure_fresh(&http, &store, "host") + .await + .unwrap_err(); + assert_eq!(err.kind, RefreshErrorKind::Transient); + } +} diff --git a/apps/native/crates/upstream/src/register.rs b/apps/native/crates/upstream/src/register.rs new file mode 100644 index 0000000000..2cc34244aa --- /dev/null +++ b/apps/native/crates/upstream/src/register.rs @@ -0,0 +1,129 @@ +//! Dynamic OAuth client registration (`POST /api/auth/mcp/register`). +//! +//! Port of `apps/api/src/cli/commands/auth/login.ts::registerClient`. Like +//! the CLI, this registers a FRESH client on every login: the loopback +//! redirect_uri's ephemeral port varies run-to-run, and the upstream's +//! Better Auth MCP provider does EXACT redirect_uri matching (it does NOT +//! implement RFC 8252 §7.3 loopback-any-port flexibility — verified against +//! studio.decocms.com, which rejects an authorize whose redirect_uri port +//! differs from the one a client was registered with: `INVALID_REDIRECT_URI`). +//! A per-login registration is one cheap unauthenticated round trip and is +//! the only correct behavior against an exact-matching server — an earlier +//! cross-login `client_id` cache here was the direct cause of that error and +//! has been removed. +//! +//! The `client_id` is NOT a secret (`token_endpoint_auth_method: "none"` — a +//! public/native client per RFC 8252), so nothing here is persisted. + +/// `client_name` sent on every registration — distinguishes this app's +/// registrations from the CLI's (`"decocms-cli"`, `login.ts`) in any +/// server-side client list a user or admin might inspect. +const CLIENT_NAME: &str = "decocms-desktop"; + +#[derive(Debug, thiserror::Error)] +pub enum RegisterError { + #[error("network error registering OAuth client: {0}")] + Network(String), + #[error("client registration failed: HTTP {0} {1}")] + Rejected(u16, String), + #[error("client registration response had no client_id")] + NoClientId, +} + +#[derive(Debug, serde::Deserialize)] +struct RegisterResponse { + client_id: String, +} + +/// `POST {target}/api/auth/mcp/register` — byte-parity request body with +/// `login.ts::registerClient` (same fields/values), except `client_name` +/// (see module doc). +pub async fn register_client( + http: &reqwest::Client, + target: &str, + redirect_uri: &str, +) -> Result { + let res = http + .post(format!("{target}/api/auth/mcp/register")) + .json(&serde_json::json!({ + "client_name": CLIENT_NAME, + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "application_type": "native", + })) + .send() + .await + .map_err(|e| RegisterError::Network(e.to_string()))?; + + if !res.status().is_success() { + let status = res.status().as_u16(); + let body = res.text().await.unwrap_or_default(); + return Err(RegisterError::Rejected(status, body)); + } + + let data: RegisterResponse = res.json().await.map_err(|_| RegisterError::NoClientId)?; + if data.client_id.is_empty() { + return Err(RegisterError::NoClientId); + } + Ok(data.client_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{routing::post, Json, Router}; + use tokio::net::TcpListener; + + #[tokio::test] + async fn register_client_posts_native_public_client_shape_and_returns_id() { + let app = Router::new().route( + "/api/auth/mcp/register", + post(|body: axum::body::Bytes| async move { + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["client_name"], "decocms-desktop"); + assert_eq!(v["token_endpoint_auth_method"], "none"); + assert_eq!(v["application_type"], "native"); + assert_eq!(v["redirect_uris"][0], "http://127.0.0.1:9/"); + Json(serde_json::json!({"client_id": "client_xyz"})) + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let http = reqwest::Client::new(); + let client_id = register_client(&http, &format!("http://{addr}"), "http://127.0.0.1:9/") + .await + .unwrap(); + assert_eq!(client_id, "client_xyz"); + } + + #[tokio::test] + async fn register_client_surfaces_rejection_status_and_body() { + let app = Router::new().route( + "/api/auth/mcp/register", + post(|| async { (axum::http::StatusCode::BAD_REQUEST, "invalid redirect_uri") }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let http = reqwest::Client::new(); + let err = register_client(&http, &format!("http://{addr}"), "http://127.0.0.1:9/") + .await + .unwrap_err(); + match err { + RegisterError::Rejected(status, body) => { + assert_eq!(status, 400); + assert!(body.contains("invalid redirect_uri")); + } + other => panic!("expected Rejected, got {other:?}"), + } + } +} diff --git a/apps/native/crates/upstream/src/revalidate.rs b/apps/native/crates/upstream/src/revalidate.rs new file mode 100644 index 0000000000..fe3111ea0f --- /dev/null +++ b/apps/native/crates/upstream/src/revalidate.rs @@ -0,0 +1,72 @@ +//! Background periodic re-validation task — the "bounded propagation for +//! org-membership removal" the brief asks for. A user removed from their +//! org (or with a revoked/expired refresh token) on the server side must +//! eventually see the desktop app reflect that, even if they never +//! trigger an app-API request or open the account menu in the +//! meantime. +//! +//! Ties directly into [`crate::session::UpstreamSession::force_revalidate`] +//! — same logic `status()` uses on a cold cache, just run on a timer +//! instead of on-demand, and always bypassing the 5-minute status cache +//! (that cache exists to bound REQUEST-triggered network chatter; this +//! task IS the thing that keeps the cache from ever going stale for +//! longer than one tick when nothing else calls `status()`). + +use std::time::Duration; + +use crate::session::UpstreamSession; + +/// Matches [`crate::session::STATUS_CACHE_TTL`] — keeps the cache +/// perpetually warm for any caller hitting `status()` between ticks, +/// without re-validating more often than the throttle it backs already +/// allows. +pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(5 * 60); + +/// Spawns the periodic loop and returns its handle. Aborting the handle stops +/// re-validation; dropping it detaches the task, which is how the Tauri shell +/// deliberately gives the loop process lifetime. Process exit provides the +/// common cleanup, matching every other long-lived task this crate/local-api +/// spawns. +pub fn spawn(session: UpstreamSession, interval: Duration) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + // Skip catch-up bursts after a long pause (e.g. the process was + // suspended) rather than firing several revalidations back-to-back. + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // First tick fires immediately (tokio::time::interval's default) — + // deliberately consumed once up front WITHOUT revalidating, so a + // freshly-booted app doesn't immediately spend a network round + // trip that `status()`'s own cold-cache path will pay for anyway + // the first time anything asks. + ticker.tick().await; + loop { + ticker.tick().await; + session.force_revalidate().await; + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tokens::test_support::MemoryTokenStore; + use std::sync::Arc; + + #[tokio::test(start_paused = true)] + async fn spawn_keeps_ticking_without_panicking() { + let session = UpstreamSession::new( + "http://example.invalid".to_string(), + Arc::new(MemoryTokenStore::new()), + ); + let handle = spawn(session, Duration::from_millis(10)); + + // Advance well past several tick intervals — a signed-out session + // with no stored credentials is the cheapest possible + // `force_revalidate()` path (no network call at all), so this + // mainly asserts the loop survives repeated ticks without + // panicking or exiting early. + tokio::time::advance(Duration::from_secs(1)).await; + assert!(!handle.is_finished()); + handle.abort(); + } +} diff --git a/apps/native/crates/upstream/src/session.rs b/apps/native/crates/upstream/src/session.rs new file mode 100644 index 0000000000..cec50f79c5 --- /dev/null +++ b/apps/native/crates/upstream/src/session.rs @@ -0,0 +1,1835 @@ +//! `UpstreamSession` — the public API this crate exposes to everything +//! else: the shell's future Tauri IPC commands (`local_api_info`-adjacent +//! `auth_status`/`auth_login`/`auth_logout`, per the shared boot contract +//! in this task's brief) AND `routes/upstream.rs`'s proxy handler, both +//! calling into the SAME process-wide instance via [`global`] so they can +//! never observe a different sign-in state than one another. +//! +//! `status()`/`login()`/`logout()` map directly onto the three IPC +//! commands the brief specifies: +//! - `auth_status()` -> [`UpstreamSession::status`] +//! - `auth_login()` -> [`UpstreamSession::login`] +//! - `auth_logout()` -> [`UpstreamSession::logout`] +//! +//! This module intentionally has ZERO Tauri dependency — it's plain async +//! Rust, callable from the standalone `local-api` binary (no Tauri context +//! at all, e.g. under `apps/native/e2e`) exactly the same way the shell +//! would call it. +//! +//! ## One credential, forwarded everywhere — no bespoke org logic +//! +//! This crate holds exactly ONE upstream session credential per signed-in +//! user: a Better Auth session COOKIE, durable for the app's lifetime (see +//! [`Self::cookie_header`]'s doc comment) — obtained via ONE of two +//! mechanisms depending on login path: captured directly during the +//! embedded email/password/OTP login (`crates/upstream/src/bridge.rs`), or, +//! for the system-browser Google/GitHub/SAML path (`login.rs`), minted +//! server-side by exchanging the OAuth bearer that flow always ends up with +//! via the mesh-side session bridge +//! (`login.rs::mint_session_from_access_token`, +//! `POST /api/auth/desktop/session-from-oauth`). Either way, this crate does +//! no org discovery, no org-slug caching, and mints no secondary credential: +//! the real production shell (`apps/web/src`) is what the desktop app +//! renders after sign-in, and its OWN `organization.list`/last-visited-org +//! logic and org-creation flow handle everything about which org is active. +//! See the native authentication contract for the empirical +//! auth-surface investigation this design is built on, including the +//! now-RESOLVED history of why a bearer-only system-browser session could +//! not satisfy the real shell's native sign-in gate (enabling Better Auth's +//! `bearer()` plugin mesh-side did NOT fix it — a different credential-type +//! problem, not a missing plugin — and why a genuine mesh-side +//! bearer-to-session bridge was the actual fix). + +use std::sync::{Arc, OnceLock, RwLock}; +use std::time::{Duration, Instant}; + +use tokio::sync::watch; + +use crate::bridge::{self, BridgeError}; +use crate::cookie_jar::CookieJar; +use crate::login::{self, LoginError}; +use crate::refresh::{RefreshCoordinator, RefreshError, RefreshErrorKind}; +use crate::tokens::{ + host_key, InMemoryTokenStore, KeychainTokenStore, StoredSession, TokenStore, TokenStoreError, +}; + +/// `GET /api/links/me` (see `apps/api/src/api/app.ts`) is the cheapest +/// authenticated endpoint this crate found in the mesh API: dual-auth +/// (Bearer OR cookie), always returns `200` (even `null`) for a VALID +/// bearer regardless of link/presence state, and `401 +/// {"error":"unauthorized"}` specifically when bearer resolution fails — +/// i.e. it's a pure auth probe, no org/permission semantics riding along +/// (unlike an org-scoped `/api/:org/...` route, which can legitimately +/// `403` an authenticated-but-non-member caller — see `probe_upstream`'s +/// doc comment for why that distinction matters). +const PROBE_PATH: &str = "/api/links/me"; + +/// "at most once per 5 min" — the contract doc's exact throttle window for +/// `status()`'s network revalidation. +pub const STATUS_CACHE_TTL: Duration = Duration::from_secs(5 * 60); + +/// Default per-request timeout for every HTTP call this session's +/// `reqwest::Client` makes (register, token exchange, refresh, the +/// `/api/links/me` probe) — `reqwest::Client::new()` has NO default +/// timeout at all, so a DNS stall or a connection that never completes +/// would otherwise hang the calling future forever. Generous enough for a +/// slow-but-working network, bounded enough that `status()`/`login()`/ +/// `access_token()` can never wedge a caller indefinitely. `logout()`'s +/// best-effort revoke additionally wraps its own call in a tighter +/// `REVOKE_TIMEOUT` (see that constant) since it's a fire-and-forget +/// courtesy call, not a request anything is blocked waiting on. +const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(15); + +fn default_http_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(DEFAULT_HTTP_TIMEOUT) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthStorageState { + Available, + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusResult { + pub signed_in: bool, + pub user_label: Option, + pub upstream_url: String, + /// Distinguishes a genuine empty Keychain entry from a read failure. A + /// storage outage is not evidence that the user signed out. + pub storage_state: AuthStorageState, +} + +#[derive(Debug, thiserror::Error)] +pub enum SessionError { + #[error(transparent)] + Login(#[from] LoginError), + #[error(transparent)] + Store(#[from] TokenStoreError), + #[error(transparent)] + Bridge(#[from] BridgeError), + #[error(transparent)] + Refresh(#[from] RefreshError), +} + +struct StatusCache { + checked_at: Instant, + signed_in: bool, +} + +struct Inner { + target: String, + host: String, + http: reqwest::Client, + /// Redirect-following DISABLED — see `bridge.rs`'s + /// `bridge_http_client()` doc comment. Built once and reused (not + /// per-call) so `complete_session()` doesn't pay client-construction + /// cost on every bridge attempt. + http_no_redirect: reqwest::Client, + store: Arc, + coordinator: RefreshCoordinator, + /// In-memory, per-host `Set-Cookie` capture for the hybrid-login + /// bridge — see `cookie_jar.rs`'s module doc. Populated by + /// `crates/local-api/src/routes/upstream.rs`'s `/api/auth/*` proxy + /// branch (which calls `cookie_jar()` on this SAME process-wide + /// singleton), consumed and unconditionally purged by + /// `complete_session()` below, and purged again (idempotent) on + /// `logout()`/`hard_sign_out()`. + cookie_jar: CookieJar, + status_cache: RwLock>, + /// In-process copy of the stored session. The OS Keychain read behind + /// `TokenStore::load` can block on an interactive ACL prompt for up to + /// KEYCHAIN_LOAD_TIMEOUT — and load() used to hit the Keychain on EVERY + /// auth-path proxy request, so a burst of shell calls (get-session, + /// organization.list, ...) queued a prompt STORM that saturated the + /// webview's connection pool (observed live 2026-07-22). The Keychain is + /// the durable copy only: first successful load fills this cache + /// (single-flighted via `session_load_lock`), save/clear refresh it, + /// and every other read is memory-only. + session_cache: RwLock>>, + session_load_lock: tokio::sync::Mutex<()>, + watch_tx: watch::Sender, +} + +/// Cheap to clone (one `Arc` bump) — every call site (IPC commands, the +/// proxy handler, the background revalidation task) holds its own handle +/// to the same underlying state. +#[derive(Clone)] +pub struct UpstreamSession(Arc); + +impl UpstreamSession { + pub fn new(target: String, store: Arc) -> Self { + let target = target.trim_end_matches('/').to_string(); + let host = host_key(&target); + let (watch_tx, _rx) = watch::channel(StatusResult { + signed_in: false, + user_label: None, + upstream_url: target.clone(), + storage_state: AuthStorageState::Available, + }); + Self(Arc::new(Inner { + target, + host, + http: default_http_client(), + http_no_redirect: bridge::bridge_http_client(), + store, + coordinator: RefreshCoordinator::new(), + cookie_jar: CookieJar::new(), + status_cache: RwLock::new(None), + session_cache: RwLock::new(None), + session_load_lock: tokio::sync::Mutex::new(()), + watch_tx, + })) + } + + pub fn target(&self) -> &str { + &self.0.target + } + + /// The Keychain/jar scoping key for this session's target — the SAME + /// value `crates/local-api/src/routes/upstream.rs`'s auth-path branch + /// must key its `cookie_jar()` calls with (derived via + /// `tokens::host_key`, never an arbitrary caller-chosen label). + pub fn host(&self) -> &str { + &self.0.host + } + + /// The in-memory `Set-Cookie` jar — see `cookie_jar.rs`'s module doc. + /// Shared (via this SAME process-wide `UpstreamSession` singleton, + /// `global()`) between `routes/upstream.rs`'s proxy handler (which + /// captures/attaches cookies on `/api/auth/*` requests) and + /// [`Self::complete_session`] (which consumes and purges it). + pub fn cookie_jar(&self) -> &CookieJar { + &self.0.cookie_jar + } + + /// The DURABLE, Keychain-persisted Better Auth session cookie for this + /// session's host, if one was ever captured — `None` for a + /// system-browser (`login()`) session, which never has one (see + /// `tokens.rs`'s `StoredSession::cookie` doc comment), or for a host + /// with no stored session at all. + /// + /// This is what `routes/upstream.rs` attaches (as a plain `Cookie:` + /// header) on EVERY app-API request for the rest of this + /// session's life — not just the ephemeral in-memory + /// [`Self::cookie_jar`], which exists only to ferry a freshly-captured + /// cookie through the ONE `complete_session()` bridge call before being + /// purged. Real-UI course-correction: the production web shell's own + /// sign-in gate and org switcher hit Better Auth's native `/api/auth/*` + /// endpoints, which only recognize a session cookie — see + /// `crates/upstream/src/bridge.rs`'s module doc for the empirical + /// finding that drove this. + pub async fn cookie_header(&self) -> Option { + self.load().await.and_then(|s| s.cookie) + } + + /// The signed-in user's OAuth `sub` claim (`StoredSession.user.sub`), + /// if a session is stored for this host — `None` when signed out. + /// Real-UI course-correction: `routes/intercept/thread_tools.rs` uses + /// this to attribute locally-created threads (`created_by`) to the + /// ACTUAL signed-in user rather than an opaque placeholder, since the + /// production shell's own chat input gates on + /// `task.created_by === userId` (`components/chat/input.tsx`) — a + /// mismatch there permanently renders every locally-created thread + /// read-only ("viewing someone else's chat"), which is what a + /// placeholder value did before this method existed. + pub async fn current_user_sub(&self) -> Option { + self.load().await.map(|s| s.user.sub) + } + + /// The signed-in user's OAuth `sub`, preserving token-store failures. + /// + /// Local-api uses this form when selecting the account-scoped native + /// SQLite namespace. Collapsing a transient Keychain/helper failure into + /// `None` would misreport an authenticated local request as signed out. + pub async fn current_user_sub_result( + &self, + ) -> Result, crate::tokens::TokenStoreError> { + Ok(self.load_result().await?.map(|session| session.user.sub)) + } + + /// Best-effort: if this host already has a persisted OAuth session, + /// refreshes its stored cookie to `header_value` (e.g. a rotated + /// `Set-Cookie` Better Auth issued on a LATER `/api/auth/*` call, after + /// the initial `complete_session()` bridge already ran) and re-saves it + /// — keeping the durable, attached-on-every-call cookie + /// ([`Self::cookie_header`]) from silently going stale across the + /// session's lifetime. A no-op (never creates a session, never errors + /// the caller) when there is no OAuth session yet for this host — that + /// case is `complete_session()`'s job, not this one's; called from + /// `routes/upstream.rs::proxy_auth_path` immediately after it captures + /// a fresh `Set-Cookie` into the ephemeral jar. + pub async fn remember_cookie(&self, header_value: &str) { + let Some(mut stored) = self.load().await else { + return; + }; + if stored.cookie.as_deref() == Some(header_value) { + return; // unchanged — avoid a pointless Keychain write + } + stored.cookie = Some(header_value.to_string()); + match self.0.store.save(&self.0.host, stored.clone()).await { + Ok(()) => self.cache_session(Some(stored)), + Err(err) => { + tracing::warn!(error = %err, "failed to persist a refreshed upstream session cookie"); + } + } + } + + /// Subscribe to status changes. The Tauri shell bridges this receiver + /// into its JS-visible auth event, and [`crate::revalidate`] publishes + /// hard sign-outs through the same channel. + pub fn subscribe(&self) -> watch::Receiver { + self.0.watch_tx.subscribe() + } + + /// `auth_status()`. Re-validates against upstream at most once per + /// [`STATUS_CACHE_TTL`]; every call inside that window returns the + /// cached verdict without a network round trip. + pub async fn status(&self) -> StatusResult { + let session = match self.load_result().await { + Ok(Some(session)) => session, + Ok(None) => { + self.mark_validated(false); + return self.signed_out(); + } + Err(error) => { + tracing::warn!(error = %error, "session storage unavailable during auth status"); + return self.publish_storage_unavailable(); + } + }; + if self.recently_validated() { + return StatusResult { + signed_in: true, + user_label: label(&session), + upstream_url: self.0.target.clone(), + storage_state: AuthStorageState::Available, + }; + } + self.force_revalidate().await + } + + /// Bypasses the [`STATUS_CACHE_TTL`] throttle — used by `status()` on a + /// cold/expired cache and by [`crate::revalidate`]'s periodic + /// background task. + pub async fn force_revalidate(&self) -> StatusResult { + let session = match self.load_result().await { + Ok(Some(session)) => session, + Ok(None) => { + self.mark_validated(false); + return self.signed_out(); + } + Err(error) => { + tracing::warn!(error = %error, "session storage unavailable during auth revalidation"); + return self.publish_storage_unavailable(); + } + }; + + match self.ensure_fresh_session(session.clone()).await { + Ok(fresh) => match self.probe_upstream(&fresh.access_token).await { + ProbeOutcome::Authenticated => self.publish_signed_in(&fresh), + ProbeOutcome::Unauthenticated => { + match self.force_refresh_session(fresh.clone()).await { + Ok(refreshed) => match self.probe_upstream(&refreshed.access_token).await { + ProbeOutcome::Authenticated => self.publish_signed_in(&refreshed), + ProbeOutcome::Unauthenticated => { + self.hard_sign_out( + "upstream rejected an access token after a forced refresh", + ) + .await + } + ProbeOutcome::Inconclusive(reason) => { + tracing::debug!( + reason, + "upstream status probe after refresh inconclusive; keeping signed-in state" + ); + self.mark_validated(true); + StatusResult { + signed_in: true, + user_label: label(&refreshed), + upstream_url: self.0.target.clone(), + storage_state: AuthStorageState::Available, + } + } + }, + Err(RefreshError { + kind: RefreshErrorKind::InvalidGrant, + .. + }) => { + self.hard_sign_out("refresh token was rejected after a 401") + .await + } + Err(RefreshError { + kind: RefreshErrorKind::NoSession, + .. + }) => { + self.mark_validated(false); + self.signed_out() + } + Err(RefreshError { + kind: RefreshErrorKind::Transient, + message, + }) => { + tracing::debug!(error = %message, "forced token refresh failed transiently after a 401; preserving the stored session"); + self.mark_validated(true); + StatusResult { + signed_in: true, + user_label: label(&fresh), + upstream_url: self.0.target.clone(), + storage_state: AuthStorageState::Available, + } + } + } + } + ProbeOutcome::Inconclusive(reason) => { + // Fail OPEN on a transient probe failure — mirrors + // `/api/links/me`'s own "presence is best-effort" fail- + // open posture (`apps/api/src/api/app.ts`) and this + // repo's general rule that a network hiccup must never + // read as "sign the user out." + tracing::debug!( + reason, + "upstream status probe inconclusive; keeping prior signed-in state" + ); + self.mark_validated(true); + StatusResult { + signed_in: true, + user_label: label(&fresh), + upstream_url: self.0.target.clone(), + storage_state: AuthStorageState::Available, + } + } + }, + Err(RefreshError { + kind: RefreshErrorKind::InvalidGrant, + .. + }) => self.hard_sign_out("refresh token was rejected").await, + Err(RefreshError { + kind: RefreshErrorKind::NoSession, + .. + }) => { + self.mark_validated(false); + self.signed_out() + } + Err(RefreshError { + kind: RefreshErrorKind::Transient, + message, + }) => { + tracing::debug!(error = %message, "token refresh transient failure during revalidation; keeping prior signed-in state"); + self.mark_validated(true); + StatusResult { + signed_in: true, + user_label: label(&session), + upstream_url: self.0.target.clone(), + storage_state: AuthStorageState::Available, + } + } + } + } + + /// `auth_login()`. Runs the interactive PKCE loopback flow, persists + /// the resulting session, and reports it signed in. This login path now + /// also carries a real Better Auth session cookie (see `tokens.rs`'s + /// `StoredSession::cookie` doc comment) — minted server-side via the + /// mesh session bridge inside `login::perform_interactive_login` itself, + /// so by the time it returns here the cookie is already part of the + /// `StoredSession` this method persists. The app hands off to the real + /// production shell exactly the same as the cookie-relay path either + /// way; the shell's own `organization.list`/last-visited-org logic and + /// org-creation flow take it from there, with no client-side org logic + /// in this crate. + pub async fn login(&self) -> Result { + let stored = login::perform_interactive_login( + &self.0.http, + &self.0.target, + login::open_system_browser, + ) + .await?; + self.0.store.save(&self.0.host, stored.clone()).await?; + self.cache_session(Some(stored.clone())); + Ok(self.publish_signed_in(&stored)) + } + + /// `auth_logout()`. Best-effort upstream revoke THEN Keychain clear, in + /// that order (per the brief) — a revoke failure never blocks the + /// local clear, since the whole point of sign-out is that this app + /// stops holding usable credentials regardless of whether the server + /// acknowledges the revoke. A failed Keychain clear keeps the current + /// session visible so the UI cannot claim a logout that would be undone + /// by the next process restart. `store.clear()` removes the WHOLE + /// [`crate::tokens::StoredSession`] record — OAuth tokens AND the + /// durable session cookie ([`Self::cookie_header`]) together, one + /// Keychain delete, no separate "also forget the cookie" step needed. + /// Also purges the in-memory cookie jar (see `cookie_jar.rs`'s module + /// doc) — a signed-out app has no business holding onto an + /// in-flight-captured Better Auth session cookie either. + pub async fn logout(&self) -> StatusResult { + let current = self.load().await; + if let Some(session) = current.as_ref() { + revoke_upstream_best_effort(&self.0.http, session).await; + } + if let Err(err) = self.0.store.clear(&self.0.host).await { + tracing::warn!(error = %err, "failed to clear upstream Keychain entry on logout"); + self.0.cookie_jar.clear(&self.0.host); + if let Some(session) = current { + return self.publish_signed_in(&session); + } + } else { + self.cache_session(None); + } + self.0.cookie_jar.clear(&self.0.host); + self.mark_validated(false); + let result = self.signed_out(); + self.publish(result.clone()); + result + } + + /// Called on a hard, refresh-surviving auth failure — clears local + /// credentials so subsequent calls fail fast (`NoSession`) instead of + /// repeating a doomed refresh, and publishes the transition so a + /// subscribed shell can react. Distinct from `logout()`: no upstream + /// revoke attempt (the token is already known-bad server-side; nothing + /// to revoke). Also purges the cookie jar, for the same reason + /// `logout()` does. + pub async fn hard_sign_out(&self, reason: &str) -> StatusResult { + tracing::warn!(reason, target = %self.0.target, "upstream session invalidated — clearing local credentials"); + if let Err(err) = self.0.store.clear(&self.0.host).await { + tracing::warn!(error = %err, "failed to clear upstream Keychain entry during hard sign-out"); + } else { + self.cache_session(None); + } + self.0.cookie_jar.clear(&self.0.host); + self.mark_validated(false); + let result = self.signed_out(); + self.publish(result.clone()); + result + } + + /// `auth_complete_session()` — the hybrid-login bridge (see + /// `bridge.rs`'s module doc). Requires the desktop-embedded login + /// screen to have ALREADY driven a cookie-authenticated Better Auth + /// session into [`Self::cookie_jar`] via + /// `crates/local-api/src/routes/upstream.rs`'s `/api/auth/*` proxy + /// branch — [`BridgeError::NoSessionCookie`] (surfaced here as + /// [`SessionError::Bridge`]) otherwise. + /// + /// The [`crate::tokens::StoredSession`] `bridge::complete_via_cookie_jar` + /// returns on success already carries that SAME cookie in its `cookie` + /// field (see `bridge.rs`'s module doc for why it's no longer a + /// one-shot credential) — `store.save` below persists it to the + /// Keychain right alongside the OAuth tokens, durable for the rest of + /// this session's life; no separate persistence step needed here. + /// + /// Purges the EPHEMERAL in-memory jar UNCONDITIONALLY once the bridge + /// attempt concludes — success OR failure. On success the jar's job is + /// done (its value has already been copied into the persisted, + /// long-lived `StoredSession.cookie` — see [`Self::cookie_header`]). On + /// failure, the captured cookie could be stale in a way + /// that caused the failure in the first place (e.g. the session + /// expired between capture and this call), or the bridge's own OAuth + /// dance failed for a reason unrelated to the cookie's validity — + /// either way, silently retrying with the SAME possibly-bad cookie + /// forever is worse than requiring the login screen to re-establish a + /// fresh session (a cheap, fast local operation) before the next + /// attempt. This mirrors `logout()`/`hard_sign_out()`'s own + /// unconditional-clear posture: this crate treats "purge on any + /// terminal outcome" as the safer default throughout, never "keep + /// retrying with stale credentials." + pub async fn complete_session(&self) -> Result { + let outcome = bridge::complete_via_cookie_jar( + &self.0.http_no_redirect, + &self.0.target, + &self.0.host, + &self.0.cookie_jar, + ) + .await; + self.0.cookie_jar.clear(&self.0.host); + + let stored = outcome?; + self.0.store.save(&self.0.host, stored.clone()).await?; + self.cache_session(Some(stored.clone())); + Ok(self.publish_signed_in(&stored)) + } + + /// The access token `routes/upstream.rs` attaches to a forwarded + /// request — transparently refreshes if the locally-cached token has + /// expired. + pub async fn access_token(&self) -> Result { + let Some(observed) = self.load().await else { + return Err(RefreshError::no_session()); + }; + self.ensure_fresh_session(observed) + .await + .map(|session| session.access_token) + } + + /// Shared `mark_validated(true)` + publish for every "report + /// signed_in: true" call site (`login`, `complete_session`, + /// `force_revalidate`'s authenticated branch). + fn publish_signed_in(&self, stored: &StoredSession) -> StatusResult { + self.mark_validated(true); + let result = StatusResult { + signed_in: true, + user_label: label(stored), + upstream_url: self.0.target.clone(), + storage_state: AuthStorageState::Available, + }; + self.publish(result.clone()); + result + } + + /// Unconditional refresh for the proxy's "upstream itself just 401'd + /// this token" retry-once path — see `routes/upstream.rs`. + pub async fn force_refresh_access_token(&self) -> Result { + let Some(observed) = self.load().await else { + return Err(RefreshError::no_session()); + }; + self.force_refresh_session(observed) + .await + .map(|session| session.access_token) + } + + async fn ensure_fresh_session( + &self, + observed: StoredSession, + ) -> Result { + let fresh = self + .0 + .coordinator + .ensure_fresh_observed(&self.0.http, self.0.store.as_ref(), &self.0.host, observed) + .await?; + self.cache_session(Some(fresh.clone())); + Ok(fresh) + } + + async fn force_refresh_session( + &self, + observed: StoredSession, + ) -> Result { + let refreshed = self + .0 + .coordinator + .force_refresh_observed(&self.0.http, self.0.store.as_ref(), &self.0.host, observed) + .await?; + self.cache_session(Some(refreshed.clone())); + Ok(refreshed) + } + + async fn load_result(&self) -> Result, TokenStoreError> { + if let Some(cached) = self.0.session_cache.read().unwrap().clone() { + return Ok(cached); + } + // Single-flight the cache miss: concurrent callers wait for ONE + // Keychain read instead of each spawning their own (and their own + // potential ACL prompt). + let _guard = self.0.session_load_lock.lock().await; + if let Some(cached) = self.0.session_cache.read().unwrap().clone() { + return Ok(cached); + } + let loaded = self.0.store.load(&self.0.host).await?; + *self.0.session_cache.write().unwrap() = Some(loaded.clone()); + Ok(loaded) + } + + async fn load(&self) -> Option { + match self.load_result().await { + Ok(loaded) => loaded, + Err(err) => { + // Non-status callers retain their existing Option-shaped API, + // but a failure remains uncached so the next call retries. + // `status()`/`force_revalidate()` use `load_result()` directly + // and never collapse this into a signed-out verdict. + tracing::warn!(error = %err, "session load failed; not caching"); + None + } + } + } + + fn cache_session(&self, value: Option) { + *self.0.session_cache.write().unwrap() = Some(value); + } + + fn signed_out(&self) -> StatusResult { + StatusResult { + signed_in: false, + user_label: None, + upstream_url: self.0.target.clone(), + storage_state: AuthStorageState::Available, + } + } + + fn publish_storage_unavailable(&self) -> StatusResult { + let result = StatusResult { + signed_in: false, + user_label: None, + upstream_url: self.0.target.clone(), + storage_state: AuthStorageState::Unavailable, + }; + self.publish(result.clone()); + result + } + + fn recently_validated(&self) -> bool { + self.0 + .status_cache + .read() + .unwrap() + .as_ref() + .is_some_and(|c| c.signed_in && c.checked_at.elapsed() < STATUS_CACHE_TTL) + } + + fn mark_validated(&self, signed_in: bool) { + *self.0.status_cache.write().unwrap() = Some(StatusCache { + checked_at: Instant::now(), + signed_in, + }); + } + + fn publish(&self, result: StatusResult) { + self.0.watch_tx.send_if_modified(|current| { + if *current == result { + false + } else { + *current = result; + true + } + }); + } + + /// Probes `PROBE_PATH` with `access_token` — see `PROBE_PATH`'s doc + /// comment for why this endpoint specifically. Only a genuine `401` + /// (bearer resolution failure) is treated as "unauthenticated"; every + /// other non-2xx status or network failure is `Inconclusive` and must + /// NOT trigger a sign-out (a `5xx`, a timeout, or — on an org-scoped + /// endpoint, though this one isn't — a `403` for an unrelated + /// authorization reason are all NOT proof the token itself is bad). + async fn probe_upstream(&self, access_token: &str) -> ProbeOutcome { + let res = self + .0 + .http + .get(format!("{}{PROBE_PATH}", self.0.target)) + .bearer_auth(access_token) + .send() + .await; + match res { + Ok(r) if r.status().is_success() => ProbeOutcome::Authenticated, + Ok(r) if r.status() == reqwest::StatusCode::UNAUTHORIZED => { + ProbeOutcome::Unauthenticated + } + Ok(r) => ProbeOutcome::Inconclusive(format!("HTTP {}", r.status())), + Err(e) => ProbeOutcome::Inconclusive(e.to_string()), + } + } +} + +enum ProbeOutcome { + Authenticated, + Unauthenticated, + Inconclusive(String), +} + +fn label(session: &StoredSession) -> Option { + session + .user + .email + .clone() + .or_else(|| session.user.name.clone()) + .or_else(|| Some(session.user.sub.clone())) +} + +/// Bounds how long `logout()` will wait on the best-effort revoke attempt +/// before giving up and moving on to the (unconditional) Keychain clear. +/// "Best-effort" has to mean bounded, not "however long the network takes" +/// — a DNS/connect stall on a route that may 404 anyway must never turn +/// sign-out into a hang. Chosen short: this is a fire-and-forget courtesy +/// call, not a request the user is waiting on a spinner for. +const REVOKE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Best-effort: POSTs an RFC 7009-shaped revocation request and swallows +/// every possible failure, INCLUDING "the endpoint doesn't exist" (`404`) +/// and "it never responded in time" (`REVOKE_TIMEOUT`). As of this writing +/// the API server's Better Auth `mcp` plugin (`apps/api/src/auth/index.ts`, +/// `better-auth/plugins/mcp`) only wires `/mcp/register` and `/mcp/token` +/// — no revoke endpoint yet (verified against the installed `better-auth` +/// package's plugin source, which exposes exactly those two +/// `createAuthEndpoint` calls). This function still attempts the +/// conventional `/api/auth/mcp/revoke` path so sign-out starts working the +/// moment the server adds it, with zero client-side changes — that's what +/// "best-effort" buys here, not a currently-working round trip. Tracked as +/// a cross-cutting follow-up for whoever owns the mesh auth server; +/// logout's local effect (Keychain clear) is unconditional and does not +/// depend on this succeeding. +async fn revoke_upstream_best_effort(http: &reqwest::Client, session: &StoredSession) { + let (token, hint) = match &session.refresh_token { + Some(rt) => (rt.as_str(), "refresh_token"), + None => (session.access_token.as_str(), "access_token"), + }; + let request = http + .post(format!("{}/api/auth/mcp/revoke", session.target)) + .form(&[ + ("token", token), + ("token_type_hint", hint), + ("client_id", session.client_id.as_str()), + ]) + .send(); + match tokio::time::timeout(REVOKE_TIMEOUT, request).await { + Ok(Ok(r)) if r.status().is_success() => { + tracing::debug!("upstream token revoked"); + } + Ok(Ok(r)) => { + tracing::debug!( + status = r.status().as_u16(), + "upstream revoke declined or not implemented (best-effort, ignoring)" + ); + } + Ok(Err(err)) => { + tracing::debug!(error = %err, "upstream revoke request failed (best-effort, ignoring)"); + } + Err(_elapsed) => { + tracing::debug!(timeout = ?REVOKE_TIMEOUT, "upstream revoke request timed out (best-effort, ignoring)"); + } + } +} + +/// `DECOCMS_UPSTREAM_URL`, trimmed of a trailing slash, defaulting to +/// `https://studio.decocms.com` — read once, at first access, per the +/// shared boot contract ("Upstream base URL: env DECOCMS_UPSTREAM_URL at +/// app launch"). +/// Production upstream — the shipped default. +pub const PRODUCTION_UPSTREAM: &str = "https://studio.decocms.com"; +/// Staging upstream — carries in-flight branch changes ahead of the prod +/// deploy (e.g. the OAuth session bridge, so Google/system-browser login +/// works here before `POST /api/auth/desktop/session-from-oauth` ships to +/// production). +pub const STAGING_UPSTREAM: &str = "https://studio-stg.decocms.com"; + +/// The upstream the desktop app points at. FLIP THIS single constant +/// between [`PRODUCTION_UPSTREAM`] and [`STAGING_UPSTREAM`] to switch which +/// environment the app talks to (Keychain sessions are keyed per-host, so +/// each environment keeps its own independent login across a switch). +/// `DECOCMS_UPSTREAM_URL` still overrides this at runtime for dev/CI. +const DEFAULT_UPSTREAM: &str = PRODUCTION_UPSTREAM; + +fn resolve_target() -> String { + std::env::var("DECOCMS_UPSTREAM_URL") + .ok() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_UPSTREAM.to_string()) + .trim_end_matches('/') + .to_string() +} + +/// The process-wide singleton `routes/upstream.rs` and every future Tauri +/// IPC command call into — see this module's doc comment for why a +/// singleton (rather than plumbing a handle through `AppState`, a shared +/// file this crate's owner does not touch) is the deliberate design here. +pub fn global() -> UpstreamSession { + static SESSION: OnceLock = OnceLock::new(); + SESSION + .get_or_init(|| { + // `LOCAL_API_TOKEN_STORE=memory` swaps the Keychain for a + // process-lifetime in-memory store — for headless drives / CI + // where an interactive macOS ACL prompt would otherwise wedge + // sign-in and no persistence-across-restart is wanted. Never set + // in the shipped app; the default is always the real Keychain. + let store: Arc = + if std::env::var("LOCAL_API_TOKEN_STORE").as_deref() == Ok("memory") { + tracing::warn!( + "LOCAL_API_TOKEN_STORE=memory: tokens are NOT persisted (test/headless mode)" + ); + Arc::new(InMemoryTokenStore::default()) + } else { + Arc::new(KeychainTokenStore::new()) + }; + UpstreamSession::new(resolve_target(), store) + }) + .clone() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tokens::{test_support::MemoryTokenStore, UserInfo}; + use axum::{ + routing::{get, post}, + Json, Router, + }; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio::net::TcpListener; + + /// Represents an ALREADY fully-credentialed session (a durable + /// `cookie`, matching the common real-world `complete_session()`- + /// derived case) — these generic status/cache/logout tests are about + /// the probe/cache/sign-out machinery. + fn valid_session(target: &str) -> StoredSession { + StoredSession { + target: target.to_string(), + client_id: "client_1".to_string(), + user: UserInfo { + sub: "user_1".to_string(), + email: Some("a@b.com".to_string()), + name: None, + }, + access_token: "access-good".to_string(), + refresh_token: Some("refresh-good".to_string()), + expires_at: Some(now_unix() + 3600), + created_at: "2026-01-01T00:00:00Z".to_string(), + cookie: Some("better-auth.session_token=test".to_string()), + } + } + + fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + } + + async fn spawn_probe_server(behavior: ProbeBehavior) -> (String, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + let calls_for_route = calls.clone(); + let app = Router::new().route( + "/api/links/me", + get(move || { + let calls = calls_for_route.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + match behavior { + ProbeBehavior::Ok => (StatusCode::OK, Json(serde_json::json!(null))), + ProbeBehavior::AlwaysUnauthorized => ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error":"unauthorized"})), + ), + ProbeBehavior::AlwaysServerError => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error":"boom"})), + ), + } + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + (format!("http://{addr}"), calls) + } + + #[derive(Clone, Copy)] + enum ProbeBehavior { + Ok, + AlwaysUnauthorized, + AlwaysServerError, + } + + use axum::http::StatusCode; + + /// Seeds `store` under the SAME key `UpstreamSession::new(target, ..)` + /// will look it up under (`host_key(target)`) — a raw `"host"` literal + /// would silently miss, since the session always derives its Keychain + /// "account"/store key from the target URL, never an arbitrary label. + fn seeded_store(target: &str, session: StoredSession) -> Arc { + Arc::new(MemoryTokenStore::seeded(&host_key(target), session)) + } + + struct RejectingSaveStore; + + #[async_trait::async_trait] + impl TokenStore for RejectingSaveStore { + async fn load(&self, _host: &str) -> Result, TokenStoreError> { + Ok(None) + } + + async fn save(&self, _host: &str, _session: StoredSession) -> Result<(), TokenStoreError> { + Err(TokenStoreError::Backend( + "injected save failure".to_string(), + )) + } + + async fn clear(&self, _host: &str) -> Result<(), TokenStoreError> { + Ok(()) + } + } + + struct RejectingLoadStore; + + #[async_trait::async_trait] + impl TokenStore for RejectingLoadStore { + async fn load(&self, _host: &str) -> Result, TokenStoreError> { + Err(TokenStoreError::Backend( + "injected load failure".to_string(), + )) + } + + async fn save(&self, _host: &str, _session: StoredSession) -> Result<(), TokenStoreError> { + Ok(()) + } + + async fn clear(&self, _host: &str) -> Result<(), TokenStoreError> { + Ok(()) + } + } + + struct RejectingClearStore { + stored: StoredSession, + } + + #[async_trait::async_trait] + impl TokenStore for RejectingClearStore { + async fn load(&self, _host: &str) -> Result, TokenStoreError> { + Ok(Some(self.stored.clone())) + } + + async fn save(&self, _host: &str, _session: StoredSession) -> Result<(), TokenStoreError> { + Ok(()) + } + + async fn clear(&self, _host: &str) -> Result<(), TokenStoreError> { + Err(TokenStoreError::Backend( + "injected clear failure".to_string(), + )) + } + } + + /// Minimal `application/x-www-form-urlencoded` field lookup — only + /// needs to handle the plain ASCII tokens this crate's own `.form(&[..])` + /// calls send (no percent-decoding needed for the test values used + /// here), so a dependency on a full `url`/`form_urlencoded` crate isn't + /// warranted just for this one assertion helper. + fn form_field(body: &[u8], key: &str) -> Option { + std::str::from_utf8(body).ok()?.split('&').find_map(|pair| { + let (k, v) = pair.split_once('=')?; + (k == key).then(|| v.to_string()) + }) + } + + #[tokio::test] + async fn status_is_signed_out_with_no_stored_session() { + let session = UpstreamSession::new( + "http://example.invalid".to_string(), + Arc::new(MemoryTokenStore::new()), + ); + let status = session.status().await; + assert!(!status.signed_in); + assert!(status.user_label.is_none()); + assert_eq!(status.storage_state, AuthStorageState::Available); + } + + #[tokio::test] + async fn status_reports_storage_unavailable_instead_of_signed_out_on_load_failure() { + let session = UpstreamSession::new( + "http://example.invalid".to_string(), + Arc::new(RejectingLoadStore), + ); + let mut status_rx = session.subscribe(); + + let status = session.status().await; + + assert!(!status.signed_in); + assert_eq!(status.storage_state, AuthStorageState::Unavailable); + status_rx.changed().await.unwrap(); + assert_eq!( + status_rx.borrow().storage_state, + AuthStorageState::Unavailable + ); + } + + #[tokio::test] + async fn status_reports_signed_in_and_probes_upstream_on_a_cold_cache() { + let (target, calls) = spawn_probe_server(ProbeBehavior::Ok).await; + let store = seeded_store(&target, valid_session(&target)); + let session = UpstreamSession::new(target, store); + + let status = session.status().await; + assert!(status.signed_in); + assert_eq!(status.user_label.as_deref(), Some("a@b.com")); + assert_eq!(status.storage_state, AuthStorageState::Available); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn status_within_the_cache_window_does_not_hit_the_network_again() { + let (target, calls) = spawn_probe_server(ProbeBehavior::Ok).await; + let store = seeded_store(&target, valid_session(&target)); + let session = UpstreamSession::new(target, store); + + session.status().await; + session.status().await; + session.status().await; + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "repeated status() calls within STATUS_CACHE_TTL must reuse the cached verdict" + ); + } + + #[tokio::test] + async fn force_revalidate_signs_out_on_a_hard_401_after_refresh() { + let (target, _calls) = spawn_probe_server(ProbeBehavior::AlwaysUnauthorized).await; + let store = seeded_store(&target, valid_session(&target)); + let host = host_key(&target); + let session = UpstreamSession::new(target, store.clone()); + + let status = session.force_revalidate().await; + assert!(!status.signed_in); + assert!( + store.load(&host).await.unwrap().is_none(), + "hard sign-out must clear the stored session" + ); + } + + #[tokio::test] + async fn force_revalidate_refreshes_once_after_a_probe_401() { + let probe_calls = Arc::new(AtomicUsize::new(0)); + let refresh_calls = Arc::new(AtomicUsize::new(0)); + let probes = probe_calls.clone(); + let refreshes = refresh_calls.clone(); + let app = Router::new() + .route( + PROBE_PATH, + get(move |headers: axum::http::HeaderMap| { + let probes = probes.clone(); + async move { + probes.fetch_add(1, Ordering::SeqCst); + match headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + { + Some("Bearer access-refreshed") => StatusCode::OK, + _ => StatusCode::UNAUTHORIZED, + } + } + }), + ) + .route( + "/api/auth/mcp/token", + post(move || { + let refreshes = refreshes.clone(); + async move { + refreshes.fetch_add(1, Ordering::SeqCst); + Json(serde_json::json!({ + "access_token": "access-refreshed", + "refresh_token": "refresh-rotated", + "expires_in": 3600, + })) + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let target = format!("http://{addr}"); + let host = host_key(&target); + let store = seeded_store(&target, valid_session(&target)); + let session = UpstreamSession::new(target, store.clone()); + + let status = session.force_revalidate().await; + + assert!(status.signed_in); + assert_eq!(probe_calls.load(Ordering::SeqCst), 2); + assert_eq!(refresh_calls.load(Ordering::SeqCst), 1); + let persisted = store.load(&host).await.unwrap().unwrap(); + assert_eq!(persisted.access_token, "access-refreshed"); + assert_eq!(persisted.refresh_token.as_deref(), Some("refresh-rotated")); + assert_eq!(session.access_token().await.unwrap(), "access-refreshed"); + } + + #[tokio::test] + async fn force_revalidate_fails_open_on_a_transient_probe_error() { + let (target, _calls) = spawn_probe_server(ProbeBehavior::AlwaysServerError).await; + let store = seeded_store(&target, valid_session(&target)); + let host = host_key(&target); + let session = UpstreamSession::new(target, store.clone()); + + let status = session.force_revalidate().await; + assert!( + status.signed_in, + "a 5xx from the probe endpoint must not be treated as proof the token is invalid" + ); + assert!( + store.load(&host).await.unwrap().is_some(), + "a transient probe failure must not clear the stored session" + ); + } + + #[tokio::test] + async fn force_revalidate_authenticated_reports_signed_in_with_no_org_logic() { + // The whole point of this simplification: an authenticated, + // upstream-verified session ALWAYS resolves straight to + // `signed_in: true` — there is no org-choice/bootstrap detour here + // at all, regardless of which login path produced the session + // (cookie or bearer-only). + let (target, _calls) = spawn_probe_server(ProbeBehavior::Ok).await; + let mut bearer_only = valid_session(&target); + bearer_only.cookie = None; + let store = seeded_store(&target, bearer_only); + let session = UpstreamSession::new(target, store); + + let status = session.force_revalidate().await; + assert!(status.signed_in); + } + + #[tokio::test] + async fn logout_clears_the_store_and_publishes_signed_out() { + let (target, _calls) = spawn_probe_server(ProbeBehavior::Ok).await; + let store = seeded_store(&target, valid_session(&target)); + let host = host_key(&target); + let session = UpstreamSession::new(target, store.clone()); + + // Establish a genuinely PUBLISHED signed-in state first — a fresh + // `UpstreamSession`'s watch channel already starts at + // `signed_in:false` (its own constructor's initial value), so + // `logout()` on a session nothing has ever published a "signed in" + // state for would be a no-op transition on the channel (equal to + // equal), and `rx.changed()` below would never resolve. Calling + // `status()` first against a working probe server exercises the + // real path that publishes `signed_in:true`. + session.status().await; + let mut rx = session.subscribe(); + assert!( + rx.borrow().signed_in, + "precondition: must start genuinely signed in" + ); + session.cookie_jar().capture( + session.host(), + std::iter::once("better-auth.session_token=abc"), + ); + assert!(!session.cookie_jar().is_empty(session.host())); + + let status = session.logout().await; + assert!(!status.signed_in); + assert!(store.load(&host).await.unwrap().is_none()); + assert!( + session.cookie_jar().is_empty(session.host()), + "logout must purge the in-memory cookie jar" + ); + + rx.changed().await.unwrap(); + assert!(!rx.borrow().signed_in); + } + + #[tokio::test] + async fn logout_does_not_publish_signed_out_when_keychain_clear_fails() { + let (target, _calls) = spawn_probe_server(ProbeBehavior::Ok).await; + let stored = valid_session(&target); + let session = UpstreamSession::new( + target, + Arc::new(RejectingClearStore { + stored: stored.clone(), + }), + ); + + let status = session.logout().await; + + assert!(status.signed_in); + assert_eq!(status.user_label, label(&stored)); + assert_eq!(session.cookie_header().await, stored.cookie); + } + + #[tokio::test] + async fn hard_sign_out_purges_the_cookie_jar() { + let session = UpstreamSession::new( + "http://example.invalid".to_string(), + Arc::new(MemoryTokenStore::new()), + ); + session + .cookie_jar() + .capture(session.host(), std::iter::once("session=abc")); + assert!(!session.cookie_jar().is_empty(session.host())); + + session.hard_sign_out("test").await; + assert!(session.cookie_jar().is_empty(session.host())); + } + + /// Logout ordering: the best-effort upstream revoke MUST fire (with the + /// still-present session's refresh token) BEFORE the Keychain entry is + /// cleared — reversing that order would make the revoke request + /// unbuildable in the first place (no token left to send), silently + /// downgrading "best-effort revoke" into "no revoke ever happens." + /// Verified by observing that the mock revoke endpoint actually + /// received the seeded session's token, THEN asserting the store is + /// empty afterward. + #[tokio::test] + async fn logout_revokes_before_clearing_and_tolerates_any_revoke_outcome() { + let revoke_token_seen = Arc::new(std::sync::Mutex::new(None)); + let seen_for_route = revoke_token_seen.clone(); + let app = Router::new().route( + "/api/auth/mcp/revoke", + post(move |body: axum::body::Bytes| { + let seen = seen_for_route.clone(); + async move { + *seen.lock().unwrap() = form_field(&body, "token"); + // A non-2xx (or even a missing endpoint) must still be + // tolerated by logout() — see `revoke_upstream_best_effort`'s + // doc comment on why the mesh server not implementing + // this yet must never block sign-out. + StatusCode::NOT_FOUND + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let target = format!("http://{addr}"); + let mut session_record = valid_session(&target); + session_record.refresh_token = Some("the-refresh-token".to_string()); + let store = seeded_store(&target, session_record); + let host = host_key(&target); + let session = UpstreamSession::new(target, store.clone()); + + let status = session.logout().await; + + assert_eq!( + revoke_token_seen.lock().unwrap().as_deref(), + Some("the-refresh-token"), + "revoke must be attempted with the session's OWN token — only \ + possible if it ran before the store was cleared" + ); + assert!(!status.signed_in); + assert!( + store.load(&host).await.unwrap().is_none(), + "credentials must be cleared even though the revoke endpoint 404'd" + ); + } + + #[tokio::test] + async fn access_token_attempts_a_refresh_for_an_expired_session() { + // Reserve then immediately drop a port so the refresh POST hits a + // fast "connection refused" — proves `access_token()` actually + // attempts a network refresh for an expired session rather than + // blindly returning the stale cached token. + let dead_port = { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + l.local_addr().unwrap().port() + }; + let target = format!("http://127.0.0.1:{dead_port}"); + let mut expired = valid_session(&target); + expired.expires_at = Some(0); + let store = seeded_store(&target, expired); + let session = UpstreamSession::new(target, store); + + let err = session.access_token().await.unwrap_err(); + assert_eq!(err.kind, RefreshErrorKind::Transient); + } + + // --- complete_session (the hybrid-login bridge) ------------------------- + + /// Mock Better Auth-shaped upstream for `complete_session()`'s own + /// integration tests: `/api/auth/mcp/register`, `/api/auth/mcp/authorize` + /// (redirects with `code`/`state` when `expected_cookie` is present in + /// the request's `Cookie` header, otherwise `401`), and + /// `/api/auth/mcp/token`. Deliberately re-implemented here (not imported + /// from `bridge`'s own `#[cfg(test)]` module, which is private) — small + /// enough that owning the fixture at this call site beats reaching into + /// another module's private test internals. + async fn spawn_mock_mesh_for_bridge(expected_cookie: &'static str) -> String { + use axum::extract::Query; + use axum::response::{IntoResponse, Redirect}; + use std::collections::HashMap; + + let app = Router::new() + .route( + "/api/auth/mcp/register", + post(|| async { Json(serde_json::json!({"client_id": "mock-client-id"})) }), + ) + .route( + "/api/auth/mcp/authorize", + axum::routing::get( + move |Query(params): Query>, + headers: axum::http::HeaderMap| async move { + let cookie = headers + .get(axum::http::header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if cookie != expected_cookie { + return (StatusCode::UNAUTHORIZED, "no session").into_response(); + } + let redirect_uri = params.get("redirect_uri").cloned().unwrap(); + let state = params.get("state").cloned().unwrap_or_default(); + let mut url = reqwest::Url::parse(&redirect_uri).unwrap(); + url.query_pairs_mut() + .append_pair("code", "mock-auth-code") + .append_pair("state", &state); + Redirect::to(url.as_str()).into_response() + }, + ), + ) + .route( + "/api/auth/mcp/token", + post(|| async { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + let payload = serde_json::json!({ + "sub": "user_1", + "email": "bridge-session@example.test", + "name": "Bridge Session", + }); + let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string()); + let id_token = format!("h.{payload_b64}.s"); + Json(serde_json::json!({ + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_in": 3600, + "id_token": id_token, + })) + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + format!("http://{addr}") + } + + #[tokio::test] + async fn complete_session_persists_to_the_store_and_purges_the_jar_on_success() { + let target = spawn_mock_mesh_for_bridge("better-auth.session_token=abc").await; + let store = Arc::new(MemoryTokenStore::new()); + let session = UpstreamSession::new(target.clone(), store.clone()); + session.cookie_jar().capture( + session.host(), + std::iter::once("better-auth.session_token=abc"), + ); + + let mut rx = session.subscribe(); + let result = session + .complete_session() + .await + .expect("bridge should succeed against a well-behaved mock upstream"); + + assert!(result.signed_in); + assert_eq!( + result.user_label.as_deref(), + Some("bridge-session@example.test") + ); + assert!( + session.cookie_jar().is_empty(session.host()), + "complete_session must purge the jar on success" + ); + let stored = store.load(session.host()).await.unwrap().unwrap(); + assert_eq!(stored.access_token, "mock-access-token"); + + rx.changed().await.unwrap(); + assert!(rx.borrow().signed_in); + } + + #[tokio::test] + async fn complete_session_purges_the_jar_even_when_the_bridge_fails() { + // Mock only accepts a specific cookie value; seed a different one so + // the authorize call is rejected (analogous to a stale/expired + // captured session cookie). + let target = spawn_mock_mesh_for_bridge("better-auth.session_token=expected").await; + let store = Arc::new(MemoryTokenStore::new()); + let session = UpstreamSession::new(target, store.clone()); + session.cookie_jar().capture( + session.host(), + std::iter::once("better-auth.session_token=stale"), + ); + + let err = session.complete_session().await.unwrap_err(); + assert!(matches!(err, SessionError::Bridge(_))); + assert!( + session.cookie_jar().is_empty(session.host()), + "complete_session must purge the jar even on failure" + ); + assert!(store.load(session.host()).await.unwrap().is_none()); + } + + #[tokio::test] + async fn complete_session_fails_fast_with_no_captured_cookie() { + let store = Arc::new(MemoryTokenStore::new()); + let session = UpstreamSession::new("http://unused.invalid".to_string(), store); + let err = session.complete_session().await.unwrap_err(); + assert!(matches!( + err, + SessionError::Bridge(BridgeError::NoSessionCookie) + )); + } + + // --- login() (system-browser path) bridges into a real session cookie ---- + // + // The contract this section pins down: given a mesh that implements the + // real MCP OAuth wire shape PLUS the new + // `POST /api/auth/desktop/session-from-oauth` bridge, the interactive + // system-browser login path (`login::perform_interactive_login`, called + // from `login()` below) ends up with a `StoredSession.cookie` carrying + // EXACTLY the value that endpoint returned for the bearer it minted — + // and that once persisted, the EXISTING, unchanged forwarding mechanism + // (`cookie_header()`, which `routes/upstream.rs`'s + // `attach_persisted_cookie`/`proxy_auth_path` read from on every + // app-API call — see this module's doc comment) hands that same + // value back out. No real browser involved: `open_browser` is faked to + // hit the mock mesh's `/api/auth/mcp/authorize` directly (mirrors how + // Better Auth's real hosted `/login` page would, after a human + // authenticates) instead of rendering a UI, exactly like this file's own + // `spawn_mock_mesh_for_bridge` already stands in for the non-interactive + // bridge's authorize call. + + /// Mock mesh for the interactive login path: `/api/auth/mcp/register`, + /// `/api/auth/mcp/authorize` (always succeeds — the interactive flow's + /// authorize call presents no cookie of its own; a real mesh would only + /// reach this point after the human authenticated via the hosted + /// `/login` UI this mock skips rendering), `/api/auth/mcp/token`, and the + /// new `/api/auth/desktop/session-from-oauth` bridge (accepts ONLY the + /// exact bearer the token endpoint minted, mirroring the real + /// endpoint's `resolveLinkBearer`-backed guard). + async fn spawn_mock_mesh_for_login() -> (String, Arc>) { + use axum::extract::Query; + use axum::response::{IntoResponse, Redirect}; + use std::collections::HashMap; + + let bridge_calls = Arc::new(std::sync::Mutex::new(0usize)); + let calls_for_route = bridge_calls.clone(); + + let app = Router::new() + .route( + "/api/auth/mcp/register", + post(|| async { Json(serde_json::json!({"client_id": "mock-client-id"})) }), + ) + .route( + "/api/auth/mcp/authorize", + axum::routing::get(|Query(params): Query>| async move { + let redirect_uri = params.get("redirect_uri").cloned().unwrap(); + let state = params.get("state").cloned().unwrap_or_default(); + let mut url = reqwest::Url::parse(&redirect_uri).unwrap(); + url.query_pairs_mut() + .append_pair("code", "mock-auth-code") + .append_pair("state", &state); + Redirect::to(url.as_str()).into_response() + }), + ) + .route( + "/api/auth/mcp/token", + post(|| async { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + let payload = serde_json::json!({ + "sub": "user_1", + "email": "system-browser-user@example.test", + "name": "System Browser User", + }); + let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string()); + let id_token = format!("h.{payload_b64}.s"); + Json(serde_json::json!({ + "access_token": "minted-access-token", + "refresh_token": "minted-refresh-token", + "expires_in": 3600, + "id_token": id_token, + })) + }), + ) + .route( + "/api/auth/desktop/session-from-oauth", + post(move |headers: axum::http::HeaderMap| { + let calls = calls_for_route.clone(); + async move { + *calls.lock().unwrap() += 1; + let bearer = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if bearer != "Bearer minted-access-token" { + return (StatusCode::UNAUTHORIZED, "unauthorized").into_response(); + } + Json(serde_json::json!({"sessionToken": "minted-session-token"})) + .into_response() + } + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + (format!("http://{addr}"), bridge_calls) + } + + #[tokio::test] + async fn login_via_system_browser_bridges_the_oauth_bearer_into_a_real_session_cookie() { + let (target, bridge_calls) = spawn_mock_mesh_for_login().await; + let http = reqwest::Client::new(); + + // Stands in for a human completing Better Auth's hosted `/login` + // page in the system browser: hits the mock's authorize endpoint + // directly (skipping the UI this mock doesn't render), following + // redirects — reqwest's default policy — all the way to the REAL + // loopback listener `login::perform_interactive_login` started, + // delivering the code exactly like a real browser redirect would. + // Fires from a separately-spawned task so it runs CONCURRENTLY with + // the main call below, which is blocked awaiting that same + // callback — a single-threaded `#[tokio::test]` runtime interleaves + // both cooperatively, no real network hop or thread needed. + let (url_tx, url_rx) = tokio::sync::oneshot::channel::(); + let follow_http = http.clone(); + tokio::spawn(async move { + let Ok(authorize_page_url) = url_rx.await else { + return; + }; + let mut authorize_endpoint = reqwest::Url::parse(&authorize_page_url).unwrap(); + authorize_endpoint.set_path("/api/auth/mcp/authorize"); + let _ = follow_http.get(authorize_endpoint).send().await; + }); + let url_tx = std::sync::Mutex::new(Some(url_tx)); + let fake_open_browser = move |url: &str| { + if let Some(tx) = url_tx.lock().unwrap().take() { + let _ = tx.send(url.to_string()); + } + Ok(()) + }; + + let stored = login::perform_interactive_login(&http, &target, fake_open_browser) + .await + .expect("interactive login should succeed against a well-behaved mock mesh"); + + assert_eq!(stored.access_token, "minted-access-token"); + assert_eq!( + stored.cookie.as_deref(), + Some("better-auth.session_token=minted-session-token"), + "the system-browser login path must carry the session token the mesh bridge minted \ + for its OAuth bearer, not `None`" + ); + assert_eq!( + *bridge_calls.lock().unwrap(), + 1, + "the session bridge must be called exactly once during login" + ); + + // The forwarding contract itself: once persisted, the EXISTING, + // unchanged `cookie_header()`/proxy-attachment mechanism hands back + // exactly this value — proving it's not just this function's return + // value but what actually rides on a subsequent app-API call + // (see `routes/upstream.rs::attach_persisted_cookie`/ + // `proxy_auth_path`, which read from precisely this method). + let store = Arc::new(MemoryTokenStore::new()); + let host = host_key(&target); + store.save(&host, stored.clone()).await.unwrap(); + let session = UpstreamSession::new(target, store); + assert_eq!( + session.cookie_header().await.as_deref(), + Some("better-auth.session_token=minted-session-token") + ); + } + + #[tokio::test] + async fn login_fails_when_the_mesh_session_bridge_rejects_the_bearer() { + // A mesh that never recognizes ANY bearer for the bridge endpoint + // (simulates a misconfigured/unreachable bridge) — `login()` must + // surface a clear error, never a bearer-only "half-signed-in" + // session (see `login::mint_session_from_access_token`'s doc + // comment for why this is non-negotiable). + use axum::extract::Query; + use axum::response::{IntoResponse, Redirect}; + use std::collections::HashMap; + + let app = Router::new() + .route( + "/api/auth/mcp/register", + post(|| async { Json(serde_json::json!({"client_id": "mock-client-id"})) }), + ) + .route( + "/api/auth/mcp/authorize", + axum::routing::get(|Query(params): Query>| async move { + let redirect_uri = params.get("redirect_uri").cloned().unwrap(); + let state = params.get("state").cloned().unwrap_or_default(); + let mut url = reqwest::Url::parse(&redirect_uri).unwrap(); + url.query_pairs_mut() + .append_pair("code", "mock-auth-code") + .append_pair("state", &state); + Redirect::to(url.as_str()).into_response() + }), + ) + .route( + "/api/auth/mcp/token", + post(|| async { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + let payload = serde_json::json!({"sub": "user_1"}); + let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string()); + let id_token = format!("h.{payload_b64}.s"); + Json(serde_json::json!({ + "access_token": "minted-access-token", + "expires_in": 3600, + "id_token": id_token, + })) + }), + ) + .route( + "/api/auth/desktop/session-from-oauth", + post(|| async { (StatusCode::UNAUTHORIZED, "unauthorized").into_response() }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let target = format!("http://{addr}"); + + let http = reqwest::Client::new(); + + let (url_tx, url_rx) = tokio::sync::oneshot::channel::(); + let follow_http = http.clone(); + tokio::spawn(async move { + let Ok(authorize_page_url) = url_rx.await else { + return; + }; + let mut authorize_endpoint = reqwest::Url::parse(&authorize_page_url).unwrap(); + authorize_endpoint.set_path("/api/auth/mcp/authorize"); + let _ = follow_http.get(authorize_endpoint).send().await; + }); + let url_tx = std::sync::Mutex::new(Some(url_tx)); + let fake_open_browser = move |url: &str| { + if let Some(tx) = url_tx.lock().unwrap().take() { + let _ = tx.send(url.to_string()); + } + Ok(()) + }; + + let err = login::perform_interactive_login(&http, &target, fake_open_browser) + .await + .unwrap_err(); + assert!(matches!(err, LoginError::SessionBridgeRejected(401, _))); + } + + // --- cookie_header / remember_cookie — the real-UI course-correction --- + + #[tokio::test] + async fn complete_session_persists_the_cookie_durably_for_the_apps_lifetime() { + // See `spawn_mock_mesh_for_bridge` above — the bridge's own + // returned `StoredSession.cookie` already carries the cookie that + // authenticated the authorize call. This test asserts the OUTER + // contract: after `complete_session()`, `cookie_header()` returns + // that same value from durable storage — not just from the + // (already-purged) ephemeral jar. + let target = spawn_mock_mesh_for_bridge("better-auth.session_token=abc").await; + let store = Arc::new(MemoryTokenStore::new()); + let session = UpstreamSession::new(target, store); + session.cookie_jar().capture( + session.host(), + std::iter::once("better-auth.session_token=abc"), + ); + + session.complete_session().await.unwrap(); + + assert_eq!( + session.cookie_header().await.as_deref(), + Some("better-auth.session_token=abc"), + "the cookie must be durably persisted (Keychain-backed store), not just held in the \ + ephemeral jar the bridge already purged" + ); + } + + #[tokio::test] + async fn complete_session_does_not_publish_or_cache_when_persistence_fails() { + let target = spawn_mock_mesh_for_bridge("better-auth.session_token=abc").await; + let session = UpstreamSession::new(target, Arc::new(RejectingSaveStore)); + let status_rx = session.subscribe(); + session.cookie_jar().capture( + session.host(), + std::iter::once("better-auth.session_token=abc"), + ); + + let error = session.complete_session().await.unwrap_err(); + + assert!(matches!(error, SessionError::Store(_))); + assert!(!status_rx.borrow().signed_in); + assert_eq!(session.cookie_header().await, None); + } + + #[tokio::test] + async fn cookie_header_is_none_with_no_stored_session() { + let session = UpstreamSession::new( + "http://example.invalid".to_string(), + Arc::new(MemoryTokenStore::new()), + ); + assert_eq!(session.cookie_header().await, None); + } + + #[tokio::test] + async fn current_user_sub_is_none_with_no_stored_session() { + let session = UpstreamSession::new( + "http://example.invalid".to_string(), + Arc::new(MemoryTokenStore::new()), + ); + assert_eq!(session.current_user_sub().await, None); + } + + #[tokio::test] + async fn current_user_sub_returns_the_signed_in_users_sub() { + let target = "http://example.invalid".to_string(); + let store = seeded_store(&target, valid_session(&target)); + let session = UpstreamSession::new(target, store); + assert_eq!(session.current_user_sub().await.as_deref(), Some("user_1")); + } + + #[tokio::test] + async fn current_user_sub_result_preserves_storage_failure() { + let session = UpstreamSession::new( + "http://example.invalid".to_string(), + Arc::new(RejectingLoadStore), + ); + + assert!(matches!( + session.current_user_sub_result().await, + Err(TokenStoreError::Backend(message)) if message == "injected load failure" + )); + } + + #[tokio::test] + async fn logout_purges_the_persisted_cookie_along_with_the_rest_of_the_session() { + let (target, _calls) = spawn_probe_server(ProbeBehavior::Ok).await; + let mut seeded = valid_session(&target); + seeded.cookie = Some("better-auth.session_token=xyz".to_string()); + let store = seeded_store(&target, seeded); + let session = UpstreamSession::new(target, store); + + assert_eq!( + session.cookie_header().await.as_deref(), + Some("better-auth.session_token=xyz") + ); + session.logout().await; + assert_eq!( + session.cookie_header().await, + None, + "logout must clear the durable cookie together with the rest of the session \ + (store.clear() removes the whole record, not just the tokens)" + ); + } + + #[tokio::test] + async fn remember_cookie_updates_an_existing_sessions_persisted_cookie() { + let (target, _calls) = spawn_probe_server(ProbeBehavior::Ok).await; + let mut seeded = valid_session(&target); + seeded.cookie = Some("better-auth.session_token=old".to_string()); + let store = seeded_store(&target, seeded); + let session = UpstreamSession::new(target, store); + + session + .remember_cookie("better-auth.session_token=rotated") + .await; + + assert_eq!( + session.cookie_header().await.as_deref(), + Some("better-auth.session_token=rotated") + ); + } + + #[tokio::test] + async fn remember_cookie_is_a_no_op_with_no_oauth_session_yet() { + // No StoredSession exists for this host at all (e.g. mid-embedded- + // login, before `complete_session()` has ever run) — must not + // fabricate one. + let session = UpstreamSession::new( + "http://example.invalid".to_string(), + Arc::new(MemoryTokenStore::new()), + ); + session.remember_cookie("session=whatever").await; + assert_eq!(session.cookie_header().await, None); + } +} diff --git a/apps/native/crates/upstream/src/tokens.rs b/apps/native/crates/upstream/src/tokens.rs new file mode 100644 index 0000000000..da03ebbc90 --- /dev/null +++ b/apps/native/crates/upstream/src/tokens.rs @@ -0,0 +1,1353 @@ +//! Access + refresh token storage. Production storage is the macOS +//! Keychain via the `keyring` crate (see the native implementation contract +//! section 3 for the crate choice) — service `"com.decocms.studio"` in +//! release builds and `"com.decocms.studio.dev"` in debug builds, with ONE +//! account per upstream host. Release/non-macOS processes call `keyring` +//! directly. A macOS debug build delegates to the fixed helper installed by +//! `bun run --cwd=apps/native dev:signing:setup`, because the app executable's +//! CDHash changes on every rebuild while that helper's does not. The secret +//! protocol uses JSON stdin/stdout, never argv or logs. Keeping dev and release +//! in different Keychain namespaces prevents their different signing +//! identities from competing for the same item ACL. The secret payload is a +//! single JSON blob +//! (this module's [`StoredSession`]) written via `Entry::set_password` — +//! there is no plaintext-on-disk fallback anywhere in this crate; a +//! Keychain failure surfaces as [`TokenStoreError`], never a degraded +//! on-disk write. +//! +//! ## iCloud-sync verification (spike follow-up) +//! +//! the native implementation contract flags a pre-ship check: confirm the macOS store's default +//! `kSecAttrAccessible`/`kSecAttrSynchronizable` behavior doesn't let a +//! refresh token silently sync via iCloud Keychain. Read `keyring` 4.1.5's +//! `apple-native-keyring-store` v1.0.1 source +//! (`src/keychain.rs`, the module the default `v1` feature actually wires +//! up — NOT `src/protected.rs`, an opt-in module for a different feature): +//! it never sets `kSecAttrSynchronizable` on the `SecItemAdd` query +//! dictionary at all. Apple's documented default for an omitted +//! `kSecAttrSynchronizable` is a NON-synchronizable item (see Apple's +//! Keychain Services docs for `kSecAttrSynchronizable`: "If the attribute +//! is not explicitly specified, keychain items are treated as if the +//! attribute is set to `false`"). That satisfies the spike's core +//! requirement ("never syncs via iCloud Keychain") without needing the +//! `security-framework`-direct escape hatch the spike reserved. The +//! narrower ask — pinning `kSecAttrAccessible` to the maximally-strict +//! `...ThisDeviceOnly` variant rather than the platform default +//! (`kSecAttrAccessibleWhenUnlocked`, still device-unlock-gated, just also +//! eligible for inclusion in an encrypted local device backup) — is left +//! at the platform default: the spike's stated blocking concern was iCloud +//! sync specifically, which this already satisfies, and the `v1` feature's +//! `Entry` type exposes no knob to change it (only the opt-in `protected` +//! module does, which would mean dropping `keyring` entirely for a +//! different dependency shape than the spike chose). Flagged here rather +//! than silently assumed, per this repo's "a comment that takes a +//! paragraph to justify a workaround is a signal the code is wrong" +//! guidance — this isn't a workaround, it's a verified, documented, +//! deliberately-accepted default. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use wait_timeout::ChildExt; + +use crate::keychain_helper::{ + KeychainHelperOutcome, KeychainHelperRequest, KeychainHelperResponse, + KEYCHAIN_HELPER_APP_DIRECTORY, KEYCHAIN_HELPER_FILENAME, KEYCHAIN_HELPER_PROTOCOL_VERSION, +}; + +/// The production Keychain service. Never change this after release: doing so +/// would strand every installed app's stored session. +pub const KEYCHAIN_SERVICE: &str = "com.decocms.studio"; + +/// Debug builds use a separate Keychain namespace. This remains an OS-backed +/// Keychain credential — it is not a file or in-memory fallback. +pub const DEV_KEYCHAIN_SERVICE: &str = "com.decocms.studio.dev"; + +#[cfg(debug_assertions)] +const DEFAULT_KEYCHAIN_SERVICE: &str = DEV_KEYCHAIN_SERVICE; + +#[cfg(not(debug_assertions))] +const DEFAULT_KEYCHAIN_SERVICE: &str = KEYCHAIN_SERVICE; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct UserInfo { + pub sub: String, + pub email: Option, + pub name: Option, +} + +/// The full session record persisted as one Keychain secret (JSON), one +/// entry per upstream host. Field shape deliberately mirrors +/// `apps/api/src/cli/lib/session.ts`'s `Session` type so the two remain +/// easy to compare/port from. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StoredSession { + /// OAuth issuer / decocms target, e.g. `https://studio.decocms.com`. + pub target: String, + /// Dynamically-registered OAuth client id for this app install. + pub client_id: String, + pub user: UserInfo, + pub access_token: String, + pub refresh_token: Option, + /// Unix epoch seconds when `access_token` expires, when known. + pub expires_at: Option, + /// RFC3339 timestamp this session was minted (login) or last refreshed. + pub created_at: String, + /// The Better Auth session cookie (`name=value`, e.g. + /// `"better-auth.session_token=..."`), persisted alongside the OAuth + /// tokens for this session's ENTIRE lifetime (not purged after a bridge + /// completes, unlike Phase 3's original design) — see `session.rs`'s + /// `cookie_header()`/`remember_cookie()` doc comments for why: the real + /// production web shell's own sign-in gate + /// (`RequiredAuthLayout`/`authClient.useSession()`) and org switcher + /// (`authClient.organization.list()`) hit Better Auth's NATIVE + /// `/api/auth/*` endpoints, which only recognize a session COOKIE — the + /// MCP OAuth bearer this crate otherwise uses satisfies org-scoped + /// tool/data routes but is rejected there. + /// + /// Populated by BOTH login paths, via two different mechanisms: + /// - Embedded email/password/OTP: captured directly during the + /// cookie-relay login (`crates/upstream/src/bridge.rs`'s + /// `complete_via_cookie_jar`) — Better Auth's own `Set-Cookie` lands in + /// this process's `CookieJar` because the login POSTs transit THIS + /// proxy. + /// - System-browser Google/GitHub/SAML: that flow's Better Auth session + /// cookie is set in the SYSTEM BROWSER's own cookie jar (a separate OS + /// process) and never transits the OAuth redirect, so this process + /// cannot capture it directly. Instead, `login.rs::perform_interactive_login` + /// exchanges the OAuth access token it always ends up with for a REAL + /// Better Auth session via the mesh-side bridge + /// (`login.rs`'s `mint_session_from_access_token`, + /// `POST /api/auth/desktop/session-from-oauth`) and populates this + /// field with the result. See + /// the native authentication contract for the empirical trail + /// this closes. + /// + /// `#[serde(default)]` so a session persisted by a build before this + /// field existed still deserializes (as `None`) instead of corrupting. + #[serde(default)] + pub cookie: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum TokenStoreError { + #[error("keychain unavailable: {0}")] + Backend(String), + #[error("stored session was not valid JSON: {0}")] + Corrupt(String), + #[error("keychain {operation} timed out after {timeout_ms}ms")] + Timeout { + operation: &'static str, + timeout_ms: u128, + }, +} + +impl From for TokenStoreError { + fn from(err: keyring::Error) -> Self { + TokenStoreError::Backend(err.to_string()) + } +} + +/// Storage abstraction so `session.rs`/`refresh.rs` are testable without +/// touching a real OS Keychain (see `KeychainTokenStore`'s doc comment for +/// why hitting the real backend from `cargo test` would be actively +/// harmful, not just slow). `host` is the Keychain "account" — one entry +/// per upstream host, per the module ownership brief. +#[async_trait] +pub trait TokenStore: Send + Sync { + async fn load(&self, host: &str) -> Result, TokenStoreError>; + async fn save(&self, host: &str, session: StoredSession) -> Result<(), TokenStoreError>; + /// Idempotent — clearing an already-empty/never-set entry is success, + /// not an error (mirrors this crate's other idempotent-delete + /// conventions, e.g. the contract doc's `/threads/:id` DELETE). + async fn clear(&self, host: &str) -> Result<(), TokenStoreError>; +} + +/// The production store: macOS Keychain via `keyring::Entry`. +/// +/// `keyring::Entry`'s `get_password`/`set_password`/`delete_credential` +/// are SYNCHRONOUS and can block for an arbitrary, user-paced amount of +/// time (a Keychain ACL prompt waits on the human). Every call here goes +/// through `tokio::task::spawn_blocking` so a slow/blocked Keychain prompt +/// never stalls a tokio worker thread — the same "no synchronous work on +/// the async runtime" discipline this repo's `CONTRIBUTING.md` rule #1 +/// applies to the sandbox daemon's event loop. +#[derive(Clone)] +pub struct KeychainTokenStore { + service: &'static str, + backend: Arc, + operation_gate: Arc>, + timeouts: KeychainTimeouts, +} + +impl std::fmt::Debug for KeychainTokenStore { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("KeychainTokenStore") + .field("service", &self.service) + .finish_non_exhaustive() + } +} + +/// How long a Keychain READ may block on an interactive ACL prompt before we +/// give up and treat the credential as absent for this attempt. Properly +/// configured builds should not prompt repeatedly, but stale credentials or +/// a changed signing identity can still make `get_password` wait for a human. +/// Thirty seconds gives a visible prompt time to be answered while remaining +/// bounded so an abandoned or hidden dialog cannot wedge the auth gate +/// forever. The frontend re-polls `auth_status`, so a later approval +/// self-heals into signed-in. +/// Writes and clears have their own shorter commit deadline below: unlike a +/// read, timing either mutation out must surface as an error, never success. +const KEYCHAIN_LOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// Writes and clears use a shorter deadline: mutations must either commit or +/// return an error promptly, never leave sign-in reporting false success. +const KEYCHAIN_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(6); + +/// The helper must finish first so the blocking worker can kill + reap it and +/// release the operation fence before the outer async deadline expires. +const HELPER_LOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(29); +const HELPER_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const MAX_HELPER_RESPONSE_BYTES: u64 = 1024 * 1024; + +#[derive(Debug, Clone, Copy)] +struct KeychainTimeouts { + load: std::time::Duration, + write: std::time::Duration, +} + +impl Default for KeychainTimeouts { + fn default() -> Self { + Self { + load: KEYCHAIN_LOAD_TIMEOUT, + write: KEYCHAIN_WRITE_TIMEOUT, + } + } +} + +trait BlockingKeychainBackend: Send + Sync + 'static { + fn load(&self, service: &str, host: &str) -> Result, TokenStoreError>; + fn save( + &self, + service: &str, + host: &str, + session: &StoredSession, + ) -> Result<(), TokenStoreError>; + fn clear(&self, service: &str, host: &str) -> Result<(), TokenStoreError>; +} + +#[derive(Debug, Default)] +struct SystemKeychainBackend; + +impl BlockingKeychainBackend for SystemKeychainBackend { + fn load(&self, service: &str, host: &str) -> Result, TokenStoreError> { + load_blocking(service, host) + } + + fn save( + &self, + service: &str, + host: &str, + session: &StoredSession, + ) -> Result<(), TokenStoreError> { + save_blocking(service, host, session) + } + + fn clear(&self, service: &str, host: &str) -> Result<(), TokenStoreError> { + clear_blocking(service, host) + } +} + +#[derive(Debug)] +struct StableDevHelperBackend { + executable: PathBuf, + timeouts: KeychainTimeouts, + #[cfg(test)] + spawned_pids: Option>>>, +} + +impl StableDevHelperBackend { + fn installed() -> Self { + Self { + executable: installed_dev_helper_path(), + timeouts: KeychainTimeouts { + load: HELPER_LOAD_TIMEOUT, + write: HELPER_WRITE_TIMEOUT, + }, + #[cfg(test)] + spawned_pids: None, + } + } + + fn invoke( + &self, + operation: &'static str, + timeout: std::time::Duration, + request: KeychainHelperRequest, + ) -> Result, TokenStoreError> { + validate_dev_helper(&self.executable)?; + let input = serde_json::to_vec(&request) + .map_err(|error| TokenStoreError::Corrupt(error.to_string()))?; + + let mut child = helper_command(&self.executable) + .spawn() + .map_err(|error| helper_backend_error("launch", error))?; + #[cfg(test)] + if let Some(spawned_pids) = &self.spawned_pids { + spawned_pids.lock().unwrap().push(child.id()); + } + let stdin = child + .stdin + .take() + .ok_or_else(|| { + TokenStoreError::Backend( + "installed development Keychain helper had no stdin".to_string(), + ) + }) + .inspect_err(|_| kill_and_reap_helper(&mut child))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| { + TokenStoreError::Backend( + "installed development Keychain helper had no stdout".to_string(), + ) + }) + .inspect_err(|_| kill_and_reap_helper(&mut child))?; + + // Drain both pipes concurrently with process waiting. Writing first + // can block if a malformed/oversized session fills stdin; waiting + // first can deadlock if a response fills stdout. Killing the child + // closes both pipes, so these workers always join before the operation + // fence is released. + let writer = std::thread::Builder::new() + .name("decocms-keychain-helper-stdin".to_string()) + .spawn(move || { + let mut stdin = stdin; + stdin.write_all(&input) + }) + .map_err(|error| { + kill_and_reap_helper(&mut child); + helper_backend_error("start the stdin worker for", error) + })?; + let reader_result = std::thread::Builder::new() + .name("decocms-keychain-helper-stdout".to_string()) + .spawn(move || { + let mut output = Vec::new(); + stdout + .take(MAX_HELPER_RESPONSE_BYTES + 1) + .read_to_end(&mut output)?; + Ok::<_, std::io::Error>(output) + }); + let reader = match reader_result { + Ok(reader) => reader, + Err(error) => { + kill_and_reap_helper(&mut child); + let _ = writer.join(); + return Err(helper_backend_error("start the stdout worker for", error)); + } + }; + + let status = match child.wait_timeout(timeout) { + Ok(Some(status)) => status, + Ok(None) => { + kill_and_reap_helper(&mut child); + let _ = writer.join(); + let _ = reader.join(); + return Err(keychain_timeout(operation, timeout)); + } + Err(error) => { + kill_and_reap_helper(&mut child); + let _ = writer.join(); + let _ = reader.join(); + return Err(helper_backend_error("wait for", error)); + } + }; + + join_helper_writer(writer)?; + let output = join_helper_reader(reader)?; + if !status.success() { + return Err(TokenStoreError::Backend(format!( + "installed development Keychain helper exited with {}", + status + ))); + } + if output.len() as u64 > MAX_HELPER_RESPONSE_BYTES { + return Err(TokenStoreError::Backend( + "installed development Keychain helper response exceeded the size limit" + .to_string(), + )); + } + + let response: KeychainHelperResponse = serde_json::from_slice(&output).map_err(|_| { + TokenStoreError::Backend( + "installed development Keychain helper returned an invalid response".to_string(), + ) + })?; + if response.version != KEYCHAIN_HELPER_PROTOCOL_VERSION { + return Err(TokenStoreError::Backend( + "installed development Keychain helper uses an unsupported protocol version" + .to_string(), + )); + } + match response.outcome { + KeychainHelperOutcome::Ok { session } => Ok(session.map(|value| *value)), + KeychainHelperOutcome::Error { message } => Err(TokenStoreError::Backend(format!( + "development Keychain helper: {message}" + ))), + } + } +} + +impl BlockingKeychainBackend for StableDevHelperBackend { + fn load(&self, service: &str, host: &str) -> Result, TokenStoreError> { + ensure_dev_helper_service(service)?; + self.invoke( + "read", + self.timeouts.load, + KeychainHelperRequest::load(host), + ) + } + + fn save( + &self, + service: &str, + host: &str, + session: &StoredSession, + ) -> Result<(), TokenStoreError> { + ensure_dev_helper_service(service)?; + match self.invoke( + "write", + self.timeouts.write, + KeychainHelperRequest::save(host, session.clone()), + )? { + None => Ok(()), + Some(_) => Err(TokenStoreError::Backend( + "development Keychain helper returned a session for a save request".to_string(), + )), + } + } + + fn clear(&self, service: &str, host: &str) -> Result<(), TokenStoreError> { + ensure_dev_helper_service(service)?; + match self.invoke( + "clear", + self.timeouts.write, + KeychainHelperRequest::clear(host), + )? { + None => Ok(()), + Some(_) => Err(TokenStoreError::Backend( + "development Keychain helper returned a session for a clear request".to_string(), + )), + } + } +} + +fn kill_and_reap_helper(child: &mut std::process::Child) { + let _ = child.kill(); + let _ = child.wait(); +} + +fn join_helper_writer( + writer: std::thread::JoinHandle>, +) -> Result<(), TokenStoreError> { + writer + .join() + .map_err(|_| { + TokenStoreError::Backend( + "development Keychain helper stdin worker panicked".to_string(), + ) + })? + .map_err(|error| helper_backend_error("write request to", error)) +} + +fn join_helper_reader( + reader: std::thread::JoinHandle, std::io::Error>>, +) -> Result, TokenStoreError> { + reader + .join() + .map_err(|_| { + TokenStoreError::Backend( + "development Keychain helper stdout worker panicked".to_string(), + ) + })? + .map_err(|error| helper_backend_error("read response from", error)) +} + +fn helper_command(executable: &Path) -> Command { + let mut command = Command::new(executable); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + command +} + +fn helper_backend_error(action: &str, error: std::io::Error) -> TokenStoreError { + TokenStoreError::Backend(format!( + "could not {action} the installed development Keychain helper: {error}" + )) +} + +fn ensure_dev_helper_service(service: &str) -> Result<(), TokenStoreError> { + if service == DEV_KEYCHAIN_SERVICE { + Ok(()) + } else { + Err(TokenStoreError::Backend( + "development Keychain helper refused a non-development service".to_string(), + )) + } +} + +fn installed_dev_helper_path() -> PathBuf { + dirs::data_dir() + .unwrap_or_default() + .join(KEYCHAIN_HELPER_APP_DIRECTORY) + .join(KEYCHAIN_HELPER_FILENAME) +} + +fn validate_dev_helper(path: &Path) -> Result<(), TokenStoreError> { + let metadata = std::fs::symlink_metadata(path).map_err(|error| { + TokenStoreError::Backend(format!( + "development Keychain helper is not installed at {}: {error}; run `bun run --cwd=apps/native dev:signing:setup`", + path.display() + )) + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(TokenStoreError::Backend(format!( + "development Keychain helper at {} is not a regular file", + path.display() + ))); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o777 != 0o700 { + return Err(TokenStoreError::Backend(format!( + "development Keychain helper at {} must have mode 0700", + path.display() + ))); + } + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeychainAccessKind { + Direct, + StableDevHelper, +} + +const fn keychain_access_kind(debug_build: bool, macos: bool) -> KeychainAccessKind { + if debug_build && macos { + KeychainAccessKind::StableDevHelper + } else { + KeychainAccessKind::Direct + } +} + +fn keychain_operation_gate() -> Arc> { + static GATE: std::sync::OnceLock>> = std::sync::OnceLock::new(); + Arc::clone(GATE.get_or_init(|| Arc::new(tokio::sync::Mutex::new(())))) +} + +impl KeychainTokenStore { + pub fn new() -> Self { + let access_kind = keychain_access_kind(cfg!(debug_assertions), cfg!(target_os = "macos")); + let backend: Arc = match access_kind { + KeychainAccessKind::Direct => Arc::new(SystemKeychainBackend), + KeychainAccessKind::StableDevHelper => Arc::new(StableDevHelperBackend::installed()), + }; + Self { + service: keyring_service(), + backend, + operation_gate: keychain_operation_gate(), + timeouts: KeychainTimeouts::default(), + } + } + + #[cfg(test)] + fn with_backend( + backend: Arc, + operation_gate: Arc>, + timeouts: KeychainTimeouts, + ) -> Self { + Self { + service: KEYCHAIN_SERVICE, + backend, + operation_gate, + timeouts, + } + } + + async fn run_blocking( + &self, + operation: &'static str, + timeout: std::time::Duration, + action: impl FnOnce(Arc) -> Result + + Send + + 'static, + ) -> Result + where + T: Send + 'static, + { + // A timed-out `spawn_blocking` task cannot be cancelled once it has + // started. Acquire one process-wide FIFO fence BEFORE spawning it, and + // move the owned guard into the worker. If the caller times out, the + // unfinished Keychain operation keeps the fence until it really exits, + // so a retry, logout, or refresh can never overtake it. + let deadline = tokio::time::Instant::now() + timeout; + let guard = + tokio::time::timeout_at(deadline, Arc::clone(&self.operation_gate).lock_owned()) + .await + .map_err(|_| keychain_timeout(operation, timeout))?; + + let backend = Arc::clone(&self.backend); + let abandoned = Arc::new(AtomicBool::new(false)); + let worker_abandoned = Arc::clone(&abandoned); + let mut task = tokio::task::spawn_blocking(move || { + let _guard = guard; + // `abort()` can cancel a queued spawn_blocking task, but not one + // that has begun. This flag closes the small queue/start race: a + // worker first scheduled after its deadline must not perform a + // stale save or clear. + if worker_abandoned.load(Ordering::Acquire) { + return Err(TokenStoreError::Backend(format!( + "keychain {operation} was abandoned before execution" + ))); + } + action(backend) + }); + + match tokio::time::timeout_at(deadline, &mut task).await { + Ok(joined) => joined.map_err(|error| { + TokenStoreError::Backend(format!("keychain {operation} task failed: {error}")) + })?, + Err(_) => { + abandoned.store(true, Ordering::Release); + task.abort(); + Err(keychain_timeout(operation, timeout)) + } + } + } +} + +impl Default for KeychainTokenStore { + fn default() -> Self { + Self::new() + } +} + +fn keychain_timeout(operation: &'static str, timeout: std::time::Duration) -> TokenStoreError { + TokenStoreError::Timeout { + operation, + timeout_ms: timeout.as_millis(), + } +} + +/// `LOCAL_API_KEYRING_SERVICE` overrides the direct Keychain service name so +/// release/non-macOS harnesses can isolate credentials. macOS debug builds +/// deliberately ignore it: their installed helper is hardcoded to +/// [`DEV_KEYCHAIN_SERVICE`], and tests must use `LOCAL_API_TOKEN_STORE=memory` +/// instead of weakening that boundary. +fn keyring_service() -> &'static str { + static SERVICE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); + SERVICE.get_or_init(|| { + if keychain_access_kind(cfg!(debug_assertions), cfg!(target_os = "macos")) + == KeychainAccessKind::StableDevHelper + { + return DEV_KEYCHAIN_SERVICE; + } + match std::env::var("LOCAL_API_KEYRING_SERVICE") { + Ok(s) if !s.trim().is_empty() => Box::leak(s.into_boxed_str()), + _ => DEFAULT_KEYCHAIN_SERVICE, + } + }) +} + +#[async_trait] +impl TokenStore for KeychainTokenStore { + async fn load(&self, host: &str) -> Result, TokenStoreError> { + let service = self.service; + let host_owned = host.to_string(); + let result = self + .run_blocking("read", self.timeouts.load, move |backend| { + backend.load(service, &host_owned) + }) + .await; + if matches!(result, Err(TokenStoreError::Timeout { .. })) { + tracing::warn!( + host, + timeout_s = self.timeouts.load.as_secs(), + "keychain read timed out (likely an interactive ACL prompt awaiting Always Allow)" + ); + } + result + } + + async fn save(&self, host: &str, session: StoredSession) -> Result<(), TokenStoreError> { + let service = self.service; + let host_owned = host.to_string(); + let result = self + .run_blocking("write", self.timeouts.write, move |backend| { + backend.save(service, &host_owned, &session) + }) + .await; + if matches!(result, Err(TokenStoreError::Timeout { .. })) { + tracing::warn!( + host, + timeout_s = self.timeouts.write.as_secs(), + "keychain write timed out (likely an interactive ACL prompt); sign-in is not committed" + ); + } + result + } + + async fn clear(&self, host: &str) -> Result<(), TokenStoreError> { + let service = self.service; + let host_owned = host.to_string(); + let result = self + .run_blocking("clear", self.timeouts.write, move |backend| { + backend.clear(service, &host_owned) + }) + .await; + if matches!(result, Err(TokenStoreError::Timeout { .. })) { + tracing::warn!( + host, + timeout_s = self.timeouts.write.as_secs(), + "keychain clear timed out (likely an interactive ACL prompt); sign-out is not committed" + ); + } + result + } +} + +/// Process-lifetime in-memory [`TokenStore`], selected in the SHIPPING +/// binary only via `LOCAL_API_TOKEN_STORE=memory` (see +/// `session::global`) — for headless drives / CI where a real Keychain ACL +/// prompt would wedge sign-in. Distinct from the `#[cfg(test)]` +/// `test_support::MemoryTokenStore`, which is not compiled into the release +/// binary. Never the default; tokens vanish on exit. +#[derive(Default)] +pub struct InMemoryTokenStore { + entries: std::sync::Mutex>, +} + +#[async_trait] +impl TokenStore for InMemoryTokenStore { + async fn load(&self, host: &str) -> Result, TokenStoreError> { + Ok(self.entries.lock().unwrap().get(host).cloned()) + } + async fn save(&self, host: &str, session: StoredSession) -> Result<(), TokenStoreError> { + self.entries + .lock() + .unwrap() + .insert(host.to_string(), session); + Ok(()) + } + async fn clear(&self, host: &str) -> Result<(), TokenStoreError> { + self.entries.lock().unwrap().remove(host); + Ok(()) + } +} + +fn load_blocking(service: &str, host: &str) -> Result, TokenStoreError> { + let entry = keyring::Entry::new(service, host)?; + match entry.get_password() { + Ok(json) => serde_json::from_str(&json) + .map(Some) + .map_err(|e| TokenStoreError::Corrupt(e.to_string())), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn save_blocking( + service: &str, + host: &str, + session: &StoredSession, +) -> Result<(), TokenStoreError> { + let entry = keyring::Entry::new(service, host)?; + let json = + serde_json::to_string(session).map_err(|e| TokenStoreError::Corrupt(e.to_string()))?; + entry.set_password(&json)?; + Ok(()) +} + +fn clear_blocking(service: &str, host: &str) -> Result<(), TokenStoreError> { + let entry = keyring::Entry::new(service, host)?; + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(e.into()), + } +} + +/// Derives the Keychain "account" (this crate's per-host scoping key) from +/// an upstream base URL. `https://studio.decocms.com` -> `studio.decocms.com`; +/// a non-default port is preserved (`http://localhost:4000` -> +/// `localhost:4000`) so a local dev target never collides with prod in the +/// same Keychain. Falls back to the raw string if it doesn't parse as a +/// URL (defensive — `session.rs` always passes an already-validated +/// target, but this function has no way to enforce that from here). +pub fn host_key(target: &str) -> String { + let without_scheme = target + .strip_prefix("https://") + .or_else(|| target.strip_prefix("http://")) + .unwrap_or(target); + without_scheme + .split(['/', '?', '#']) + .next() + .unwrap_or(without_scheme) + .to_string() +} + +#[cfg(any(test, feature = "test-support"))] +pub mod test_support { + //! Gated behind `test` (this crate's OWN test builds) OR the + //! `test-support` Cargo feature (so a DOWNSTREAM crate's tests — + //! `local-api`'s `routes/upstream.rs`, specifically — can also use it: + //! `#[cfg(test)]` only compiles a module into the crate being tested + //! itself, never into a dependency's rlib as linked by another crate's + //! `cargo test`. `local-api` enables this feature from its own + //! `[dev-dependencies]` entry for `upstream` — see that crate's + //! Cargo.toml). + //! + //! An in-memory [`TokenStore`] so tests never touch the real OS + //! Keychain — see [`KeychainTokenStore`]'s doc comment for why that + //! would be actively unsafe to do from `cargo test` (it would write + //! real app-service entries into whoever's Keychain runs the test suite, + //! and on a headless/locked-Keychain CI runner a Keychain + //! prompt could hang the test forever). + + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + #[derive(Default)] + pub struct MemoryTokenStore { + entries: Mutex>, + } + + impl MemoryTokenStore { + pub fn new() -> Self { + Self::default() + } + + pub fn seeded(host: &str, session: StoredSession) -> Self { + let store = Self::new(); + store + .entries + .lock() + .unwrap() + .insert(host.to_string(), session); + store + } + } + + #[async_trait] + impl TokenStore for MemoryTokenStore { + async fn load(&self, host: &str) -> Result, TokenStoreError> { + Ok(self.entries.lock().unwrap().get(host).cloned()) + } + + async fn save(&self, host: &str, session: StoredSession) -> Result<(), TokenStoreError> { + self.entries + .lock() + .unwrap() + .insert(host.to_string(), session); + Ok(()) + } + + async fn clear(&self, host: &str) -> Result<(), TokenStoreError> { + self.entries.lock().unwrap().remove(host); + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::test_support::MemoryTokenStore; + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::mpsc::{self, Receiver, Sender}; + use tokio::sync::Notify; + + #[derive(Default)] + struct FakeBlockingKeychainBackend { + save_calls: AtomicUsize, + clear_calls: AtomicUsize, + save_started: Notify, + save_finished: Notify, + clear_started: Notify, + clear_finished: Notify, + save_release: std::sync::Mutex>>, + clear_release: std::sync::Mutex>>, + } + + impl FakeBlockingKeychainBackend { + fn blocking_save() -> (Arc, Sender<()>) { + let (release, receiver) = mpsc::channel(); + ( + Arc::new(Self { + save_release: std::sync::Mutex::new(Some(receiver)), + ..Self::default() + }), + release, + ) + } + + fn blocking_clear() -> (Arc, Sender<()>) { + let (release, receiver) = mpsc::channel(); + ( + Arc::new(Self { + clear_release: std::sync::Mutex::new(Some(receiver)), + ..Self::default() + }), + release, + ) + } + } + + impl BlockingKeychainBackend for FakeBlockingKeychainBackend { + fn load( + &self, + _service: &str, + _host: &str, + ) -> Result, TokenStoreError> { + Ok(None) + } + + fn save( + &self, + _service: &str, + _host: &str, + _session: &StoredSession, + ) -> Result<(), TokenStoreError> { + self.save_calls.fetch_add(1, Ordering::SeqCst); + self.save_started.notify_one(); + if let Some(receiver) = self.save_release.lock().unwrap().take() { + receiver.recv().map_err(|error| { + TokenStoreError::Backend(format!("fake save release failed: {error}")) + })?; + } + self.save_finished.notify_one(); + Ok(()) + } + + fn clear(&self, _service: &str, _host: &str) -> Result<(), TokenStoreError> { + self.clear_calls.fetch_add(1, Ordering::SeqCst); + self.clear_started.notify_one(); + if let Some(receiver) = self.clear_release.lock().unwrap().take() { + receiver.recv().map_err(|error| { + TokenStoreError::Backend(format!("fake clear release failed: {error}")) + })?; + } + self.clear_finished.notify_one(); + Ok(()) + } + } + + fn fake_keychain_store(backend: Arc) -> KeychainTokenStore { + KeychainTokenStore::with_backend( + backend, + Arc::new(tokio::sync::Mutex::new(())), + KeychainTimeouts { + load: std::time::Duration::from_millis(100), + write: std::time::Duration::from_millis(100), + }, + ) + } + + fn assert_timeout(error: TokenStoreError, operation: &'static str) { + assert!( + matches!( + error, + TokenStoreError::Timeout { + operation: actual, + timeout_ms: 100 + } if actual == operation + ), + "expected a {operation} timeout, got {error:?}" + ); + } + + fn sample_session() -> StoredSession { + StoredSession { + target: "https://studio.decocms.com".to_string(), + client_id: "client_abc".to_string(), + user: UserInfo { + sub: "user_1".to_string(), + email: Some("a@b.com".to_string()), + name: None, + }, + access_token: "access-1".to_string(), + refresh_token: Some("refresh-1".to_string()), + expires_at: Some(1_000_000), + created_at: "2026-01-01T00:00:00Z".to_string(), + cookie: Some("better-auth.session_token=abc".to_string()), + } + } + + /// A session serialized by a build BEFORE the `cookie` field existed — + /// exercises the `#[serde(default)]` backward-compat path. + fn pre_cookie_field_json() -> &'static str { + r#"{"target":"https://studio.decocms.com","client_id":"client_abc","user":{"sub":"user_1","email":"a@b.com","name":null},"access_token":"access-1","refresh_token":"refresh-1","expires_at":1000000,"created_at":"2026-01-01T00:00:00Z"}"# + } + + #[test] + fn a_session_persisted_before_the_cookie_field_existed_still_deserializes() { + let back: StoredSession = serde_json::from_str(pre_cookie_field_json()).unwrap(); + assert_eq!(back.cookie, None); + assert_eq!(back.access_token, "access-1"); + } + + #[test] + fn host_key_strips_scheme_and_path() { + assert_eq!(host_key("https://studio.decocms.com"), "studio.decocms.com"); + assert_eq!( + host_key("https://studio.decocms.com/"), + "studio.decocms.com" + ); + assert_eq!(host_key("http://localhost:4000"), "localhost:4000"); + assert_eq!(host_key("studio.decocms.com"), "studio.decocms.com"); + } + + #[cfg(debug_assertions)] + #[test] + fn debug_build_uses_an_isolated_keychain_service() { + assert_eq!(DEFAULT_KEYCHAIN_SERVICE, DEV_KEYCHAIN_SERVICE); + assert_ne!(DEFAULT_KEYCHAIN_SERVICE, KEYCHAIN_SERVICE); + } + + #[cfg(not(debug_assertions))] + #[test] + fn release_build_uses_the_production_keychain_service() { + assert_eq!(DEFAULT_KEYCHAIN_SERVICE, KEYCHAIN_SERVICE); + assert_ne!(DEFAULT_KEYCHAIN_SERVICE, DEV_KEYCHAIN_SERVICE); + } + + #[test] + fn stored_session_json_round_trips() { + let session = sample_session(); + let json = serde_json::to_string(&session).unwrap(); + let back: StoredSession = serde_json::from_str(&json).unwrap(); + assert_eq!(back, session); + } + + #[test] + fn only_debug_macos_uses_the_stable_helper() { + assert_eq!( + keychain_access_kind(true, true), + KeychainAccessKind::StableDevHelper + ); + assert_eq!( + keychain_access_kind(false, true), + KeychainAccessKind::Direct + ); + assert_eq!( + keychain_access_kind(true, false), + KeychainAccessKind::Direct + ); + assert_eq!( + keychain_access_kind(false, false), + KeychainAccessKind::Direct + ); + } + + #[test] + fn helper_command_never_places_secrets_in_arguments() { + let command = helper_command(Path::new("/fixed/helper")); + assert_eq!(command.get_program(), "/fixed/helper"); + assert_eq!(command.get_args().count(), 0); + } + + fn test_helper_backend( + executable: PathBuf, + timeout: std::time::Duration, + ) -> StableDevHelperBackend { + StableDevHelperBackend { + executable, + timeouts: KeychainTimeouts { + load: timeout, + write: timeout, + }, + spawned_pids: None, + } + } + + #[test] + fn missing_helper_is_a_storage_backend_error_not_a_direct_fallback() { + let temp = tempfile::tempdir().unwrap(); + let backend = test_helper_backend( + temp.path().join("missing-helper"), + std::time::Duration::from_millis(100), + ); + let error = backend + .load(DEV_KEYCHAIN_SERVICE, "studio.decocms.com") + .unwrap_err(); + assert!( + matches!(error, TokenStoreError::Backend(message) if message.contains("dev:signing:setup")) + ); + } + + #[cfg(unix)] + #[test] + fn helper_with_group_or_world_permissions_is_rejected() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let executable = temp.path().join("permissive-helper"); + std::fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let error = validate_dev_helper(&executable).unwrap_err(); + assert!( + matches!(error, TokenStoreError::Backend(message) if message.contains("mode 0700")) + ); + } + + #[cfg(unix)] + #[test] + fn stable_helper_receives_a_save_only_through_stdin() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let executable = temp.path().join("fake-keychain-helper"); + std::fs::write( + &executable, + r#"#!/bin/sh +if [ "$#" -ne 0 ]; then + exit 40 +fi +request=$(cat) +case "$request" in + *'"operation":"save"'*'"access_token":"access-1"'*) + printf '%s\n' '{"version":2,"status":"ok"}' + ;; + *) + exit 41 + ;; +esac +"#, + ) + .unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let backend = test_helper_backend(executable, std::time::Duration::from_secs(2)); + backend + .save( + DEV_KEYCHAIN_SERVICE, + "studio.decocms.com", + &sample_session(), + ) + .unwrap(); + } + + #[cfg(unix)] + fn hanging_helper() -> ( + tempfile::TempDir, + StableDevHelperBackend, + Arc>>, + ) { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let executable = temp.path().join("hanging-keychain-helper"); + std::fs::write( + &executable, + r#"#!/bin/sh +cat >/dev/null +exec /bin/sleep 60 +"#, + ) + .unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap(); + let spawned_pids = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut backend = test_helper_backend(executable, std::time::Duration::from_millis(500)); + backend.spawned_pids = Some(Arc::clone(&spawned_pids)); + (temp, backend, spawned_pids) + } + + #[cfg(unix)] + fn assert_helper_timed_out_and_was_reaped( + error: TokenStoreError, + operation: &'static str, + spawned_pids: &Arc>>, + ) { + assert!( + matches!( + error, + TokenStoreError::Timeout { + operation: actual, + timeout_ms: 500 + } if actual == operation + ), + "expected a typed {operation} timeout, got {error:?}" + ); + let pids = spawned_pids.lock().unwrap(); + assert_eq!(pids.len(), 1); + let pid = pids[0].to_string(); + let still_alive = Command::new("/bin/kill") + .args(["-0", pid.trim()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap() + .success(); + assert!(!still_alive, "timed-out helper PID {} survived", pid.trim()); + } + + #[cfg(unix)] + #[test] + fn hanging_helper_load_is_killed_and_reaped() { + let (_temp, backend, spawned_pids) = hanging_helper(); + let error = backend + .load(DEV_KEYCHAIN_SERVICE, "studio.decocms.com") + .unwrap_err(); + assert_helper_timed_out_and_was_reaped(error, "read", &spawned_pids); + } + + #[cfg(unix)] + #[test] + fn hanging_helper_save_is_killed_and_reaped() { + let (_temp, backend, spawned_pids) = hanging_helper(); + let error = backend + .save( + DEV_KEYCHAIN_SERVICE, + "studio.decocms.com", + &sample_session(), + ) + .unwrap_err(); + assert_helper_timed_out_and_was_reaped(error, "write", &spawned_pids); + } + + #[cfg(unix)] + #[test] + fn hanging_helper_clear_is_killed_and_reaped() { + let (_temp, backend, spawned_pids) = hanging_helper(); + let error = backend + .clear(DEV_KEYCHAIN_SERVICE, "studio.decocms.com") + .unwrap_err(); + assert_helper_timed_out_and_was_reaped(error, "clear", &spawned_pids); + } + + #[tokio::test] + async fn memory_store_load_save_clear_round_trip() { + let store = MemoryTokenStore::new(); + assert!(store.load("studio.decocms.com").await.unwrap().is_none()); + + let session = sample_session(); + store + .save("studio.decocms.com", session.clone()) + .await + .unwrap(); + assert_eq!( + store.load("studio.decocms.com").await.unwrap(), + Some(session) + ); + + store.clear("studio.decocms.com").await.unwrap(); + assert!(store.load("studio.decocms.com").await.unwrap().is_none()); + // Idempotent: clearing an already-empty entry is not an error. + store.clear("studio.decocms.com").await.unwrap(); + } + + #[tokio::test] + async fn memory_store_scopes_entries_per_host() { + let store = MemoryTokenStore::new(); + let mut prod = sample_session(); + prod.target = "https://studio.decocms.com".to_string(); + let mut dev = sample_session(); + dev.target = "http://localhost:4000".to_string(); + dev.access_token = "dev-token".to_string(); + + store + .save("studio.decocms.com", prod.clone()) + .await + .unwrap(); + store.save("localhost:4000", dev.clone()).await.unwrap(); + + assert_eq!(store.load("studio.decocms.com").await.unwrap(), Some(prod)); + assert_eq!(store.load("localhost:4000").await.unwrap(), Some(dev)); + } + + #[tokio::test] + async fn timed_out_save_is_an_error_and_fences_later_operations() { + let (backend, release_save) = FakeBlockingKeychainBackend::blocking_save(); + let store = fake_keychain_store(backend.clone()); + + let save_started = backend.save_started.notified(); + let save = tokio::spawn({ + let store = store.clone(); + async move { store.save("studio.decocms.com", sample_session()).await } + }); + save_started.await; + assert_timeout(save.await.unwrap().unwrap_err(), "write"); + + // `spawn_blocking` cannot cancel the already-running save. Its owned + // operation fence must remain held, so a clear cannot overtake it and + // delete an item before the late save finally returns. + let clear = tokio::spawn({ + let store = store.clone(); + async move { store.clear("studio.decocms.com").await } + }); + assert_timeout(clear.await.unwrap().unwrap_err(), "clear"); + assert_eq!(backend.clear_calls.load(Ordering::SeqCst), 0); + + let save_finished = backend.save_finished.notified(); + release_save.send(()).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(1), save_finished) + .await + .expect("late save should finish after the fake backend is released"); + + // The clear that timed out while waiting was cancelled, not queued for + // a surprise future mutation. A new explicit clear is the only one + // that executes after the save releases the fence. + store.clear("studio.decocms.com").await.unwrap(); + assert_eq!(backend.clear_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn timed_out_clear_is_an_error_not_false_success() { + let (backend, release_clear) = FakeBlockingKeychainBackend::blocking_clear(); + let store = fake_keychain_store(backend.clone()); + + let clear_started = backend.clear_started.notified(); + let clear = tokio::spawn(async move { store.clear("studio.decocms.com").await }); + clear_started.await; + assert_timeout(clear.await.unwrap().unwrap_err(), "clear"); + assert_eq!(backend.clear_calls.load(Ordering::SeqCst), 1); + + let clear_finished = backend.clear_finished.notified(); + release_clear.send(()).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(1), clear_finished) + .await + .expect("late clear should finish after the fake backend is released"); + } + + /// Real-Keychain round trip — deliberately `#[ignore]`d. Writes an + /// actual debug-service entry into whoever runs its macOS + /// Keychain, and `keyring`'s Keychain backend can trigger a one-time + /// OS access-consent prompt the first time a given binary reads it — + /// both wrong for an unattended `cargo test` run (CI or an agent + /// sandbox). Run manually on a macOS dev machine to sanity-check the + /// real backend: + /// cargo test -p upstream keychain_round_trip -- --ignored + #[tokio::test] + #[ignore] + async fn keychain_round_trip() { + let store = KeychainTokenStore::new(); + let host = "upstream-crate-manual-test.invalid"; + let session = sample_session(); + store.save(host, session.clone()).await.unwrap(); + assert_eq!(store.load(host).await.unwrap(), Some(session)); + store.clear(host).await.unwrap(); + assert!(store.load(host).await.unwrap().is_none()); + } +} diff --git a/apps/native/docs/org-fs-plan.md b/apps/native/docs/org-fs-plan.md new file mode 100644 index 0000000000..334dcd1ac0 --- /dev/null +++ b/apps/native/docs/org-fs-plan.md @@ -0,0 +1,348 @@ +# Org filesystem on the desktop — implementation plan + +Restores the `org/` filesystem for the Tauri desktop app, which `bunx decocms +link` had and the Rust `local-api` never implemented. + +**Status:** plan only. Nothing below is built yet. + +## Why + +`decocms link` mounted the org's volumes into every sandbox, so an agent could +read `org/home/MEMORY.md`, load skills from `org/public//`, read the user's +chat attachments from `org/upload/`, and write deliverables to `org/output/`. + +The Rust backend implements **none** of it. `routes/orgfs.rs` exists but is a +config *relay* only — it validates a config and writes it to a file a +privileged sidecar would watch. On the desktop it never even does that: +`ORGFS_CONFIG` and `ORGFS_SIDECAR_CONFIG_PATH` are never set, so the handler +returns `{"written": false}` and exits. + +The gap that bites today: `decopilot.rs` computes `has_attachments()` and +reports it in the queue payload — the UI shows the paperclip — but nothing +materializes the file. **A user attaches a file and the agent cannot see it.** + +The gap that does *not* bite: `buildOrgFilesystemPrompt` (the block naming +`org/home`, `org/upload`, …) is consumed by the *cluster* decopilot harness. +`append_claude_args` injects no system prompt, so no desktop agent is currently +told to read a path that does not exist. Prompt work must therefore come +**after** the paths exist, never before. + +## Decisions + +| # | Decision | Rationale | +|---|---|---| +| 1 | **Mount, not materialize** | Live `org/home` browsing is a requirement. Ad-hoc download/upload cannot offer it. | +| 2 | **Shared mounts per org** at `/orgs//` | Volumes are org-wide and every sandbox is the same user on one machine. Mounting per sandbox would run N×4 rclone processes serving identical content. Same shape as the canonical repo store: share one, link many. | +| 3 | **No hidden `.uploads`/`.outputs`** | The dot-prefix existed so "a stray symlink never collides with a real volume mount". Mounts now live in `orgs/`, symlinks in `sandboxes//org/` — different trees, no collision possible. | +| 4 | **`org` is a sibling of `repo`** | Keeps the mount out of the git worktree. A hung NFS mount cannot wedge `bun install`, the dev server, or any git operation. | +| 5 | **Absolute paths in the prompt**, no `repo/org` symlink | Nothing enters the worktree at all, so no `.git/info/exclude` entry is needed — a simplification over `link`. | +| 6 | **No per-thread symlink repointing** | Narrowing moves off the filesystem into the prompt, which is built per dispatch and names `…/uploads//` directly. No repointing, no race between concurrent runs. Thread subdirs still exist because that layout is a cluster contract (see Constraints). | +| 7 | **Lazy mount on first sandbox ensure** | Users who never open a repo-backed agent never start rclone. | +| 8 | **macOS only** | `rust-checks` and every desktop build/sign job run on `macos-latest`; the only `ubuntu-latest` job is the nightly TS stub-seam. Other platforms would be untested weight. | +| 9 | **Bundle rclone** (not download-on-first-use) | Avoids a network dependency and macOS quarantine handling at runtime. | +| 10 | **Mount down ⇒ agent sees an empty `org/`** | The view always exists; a failed mount reads as empty rather than a missing path. | + +### Constraints that are not ours to choose + +- **`uploads`/`outputs` subdir layout is a cluster contract.** `file-materializer` + writes attachments under the thread's folder, and outputs are "shared back to + the organization under this run's folder". Flattening would mean desktop + cannot find cluster-written uploads, and desktop-written outputs would not + surface where the org's UI looks for them. +- **The official rclone build is required.** Homebrew's ships without `mount`. +- **rclone must run in the foreground.** `mount --rc --daemon` is broken — the + launcher hangs and never attaches the mount. It is a supervised child process. + +## Layout + +``` +/orgs// ← the ONLY real mounts, one rclone set per org +├── home/ rw +├── public// ro +├── uploads// rw (subdir layout = cluster contract) +└── outputs// rw + +/sandboxes// +├── repo/ ← git worktree, harness cwd — nothing org-related inside +└── org/ ← per-sandbox view: symlinks into the org mounts + ├── home -> ../../../orgs//home + ├── public -> ../../../orgs//public + ├── uploads -> ../../../orgs//uploads + └── outputs -> ../../../orgs//outputs +``` + +For reference, what `decocms link` actually produced (verified on disk) — +mounts were per sandbox, and `upload`/`output` were per-run symlinks into +*hidden* mounts: + +``` +~/deco/sandboxes// +├── repo/ +│ ├── .git/info/exclude ← contained "/org" +│ └── org -> ../org +├── org/ +│ ├── home/ public// ← mounts +│ ├── .outputs/ .uploads/ ← hidden mounts +│ ├── output -> .outputs/ +│ └── upload -> .uploads/ +└── tmp/ +``` + +## Phases + +Land **P0–P2 behind a default-off flag** before P3–P5. Per the repo's +first-pass checklist: a change on a boot/spawn hot path gets its own flag, and +"deployed" must not mean "enabled". + +### P0 — rclone sidecar + +**Verified on macOS 26.5.2 / arm64 with rclone v1.74.4 — mount, read, write, +mkdir, rename, delete all confirmed end to end through a hardened-runtime +signed binary.** + +Bundle a pinned official rclone as a Tauri `externalBin`, signed with the app. + +```json5 +// src-tauri/tauri.conf.json5 → bundle +externalBin: ["binaries/rclone"] +// files: src-tauri/binaries/rclone-aarch64-apple-darwin +// src-tauri/binaries/rclone-x86_64-apple-darwin +``` + +- **Size is 78 MB per arch (156 MB for both)** — not the ~50 MB first assumed. + Fetch + checksum-verify in `beforeBuildCommand`/CI into a git-ignored + `src-tauri/binaries/`, rather than committing the binaries. +- Pin **v1.74.4** and verify against the published + `https://downloads.rclone.org//SHA256SUMS`. Homebrew's build has no + `mount` — the official build is required. +- **No `tauri-plugin-shell` needed.** The bundler copies externalBins into + `.app/Contents/MacOS/` with the triple stripped, so + `current_exe()?.parent()?.join("rclone")` resolves it. That avoids adding + `shell:allow-execute` to `capabilities/`, and a supervised long-lived child + wants `tokio::process::Command` anyway. +- **Signing is automatic** — the bundler signs every externalBin before the app + ("inside out") with `--options runtime` under hardened runtime, replacing + rclone's shipped ad-hoc signature. No entitlements needed: rclone links only + system libs, and hardened runtime permits its `/sbin/mount_nfs` exec. +- Keep the mount call behind a single `#[cfg(target_os = "macos")]` seam that + returns a clean "unsupported" elsewhere. + +Two caveats worth knowing before trusting a green run: + +- `scripts/dev-runner.sh:10` short-circuits on any binary not named + `decocms-desktop`, so in `tauri dev` the sidecar is **not** signed by the dev + runner. Dev runs therefore do not exercise the signing path. +- `mount_nfs` is incompatible with App Sandbox, so this feature rules out Mac + App Store distribution. Direct Developer ID + notarization is unaffected. + +### P1 — WebDAV server in Rust + +A loopback WebDAV surface backed by `upstream::call_org_tool` against the +`ORG_FS_*` tools. + +- Methods rclone needs: `OPTIONS`, `PROPFIND`, `HEAD`, `GET`, `PUT`, `DELETE`, + `MKCOL`, `MOVE`. +- Read-only enforced per volume (`public/*`), rejecting writes at this layer + rather than trusting the mount flag. +- **No token minting.** `ORGFS_CONFIG` carried a short-lived fs-scoped API key + because the cluster pod had no identity; Rust already holds the user's + session. The entire key-provisioning path disappears. +- Never block the tokio runtime in a handler — the TS daemon documented a + deadlock (kernel → rclone → WebDAV → blocked event loop). A multi-threaded + executor makes this far less likely but not impossible. + +**Done when:** unit tests cover each method and the read-only rejection, and +`rclone lsd` against the loopback URL lists the org's volumes. + +### P2 — Mount manager (shared per org) + +- Mount `/orgs//{home,public/,uploads,outputs}` lazily on + the first sandbox ensure for that org. +- rclone as a supervised foreground child, reaped with the sandbox lifecycle. +- Cache invalidation via rclone's `rc` control API. +- **Stale-mount detach at boot**, mirroring `repo_store::prune_all`. A SIGKILL + leaves mounts attached; the next run must reclaim them. +- Idempotent: a second ensure for the same org must not spawn a second rclone. + +**Verified mount argv:** + +``` +rclone nfsmount wd: + --option actimeo=1,locallocks,soft,timeo=100,retrans=2,nobrowse + --vfs-cache-mode full --vfs-write-back 1s --dir-cache-time 10s + --timeout 5s --contimeout 3s --low-level-retries 1 + --rc --rc-addr 127.0.0.1: --rc-no-auth + [--read-only --file-perms 0755] # public/* volumes +``` + +**Auth — a bearer is NOT enough.** The remote is configured by env, no config +file: + +```sh +RCLONE_CONFIG_WD_TYPE=webdav +RCLONE_CONFIG_WD_URL=http:///_sandbox/orgfs// +RCLONE_CONFIG_WD_VENDOR=other +RCLONE_CONFIG_WD_HEADERS="Origin,,Cookie," +``` + +The shipped app runs local-api **embedded**, where `guard` requires the exact +`Host` (so the URL host:port must match the app's string exactly — +`localhost:43120`, not `127.0.0.1:43120`), the exact `Origin` on unsafe methods +(**`PROPFIND` counts as unsafe**; rclone sends no `Origin` by default), and the +per-launch session cookie. Without the headers this fails as +`couldn't list files: forbidden origin: 403`. + +Use the **env** form, not `--header` on argv — argv is world-readable via `ps`, +the same rule the keychain-helper work established. The session token is +generated once per process (`client_auth.rs`) and never rotates, and rclone is +the app's own child, so the credential's lifetime already matches the mount's. + +**Timeouts are not optional.** rclone's defaults give a ~9-hour worst-case +block when the backing server stops answering (its VFS downloader retries to a +hard-coded 10-error limit: `--timeout` × `--low-level-retries` × 11). Measured: +defaults >5 min and climbing; `--timeout 5s --low-level-retries 1` → 62 s. The +failure surfaces as `EACCES` ("Permission denied"), not `EIO` — worth an +`[org-fs]` log line so it is not misread. The mount fully recovers once the +server answers again; no remount needed. + +**Readiness:** poll `rc` `POST /vfs/list` for liveness, then confirm with +`statfs(mountpoint).f_fstypename == "nfs"` — `vfs/list` goes non-empty ~36 ms +*before* the kernel attaches the mount (0.080 s vs 0.116 s measured). Fail fast +if the child exits. `nfsmount --daemon --rc` is confirmed broken (exits 1), so +the foreground supervised child is mandatory. + +**Invalidation:** `rc` `POST /vfs/refresh` with `{"dir":"sub"}`, and `{}` for +the root — `{"dir":""}` returns `file does not exist`. Freshness is two-layered +(rclone's VFS dir cache plus the kernel attr cache), so budget ~1 s after a +refresh. + +**The boot sweep is overdue, not theoretical.** This machine currently has +**21 ghost NFS mounts** under the old TS link daemon's sandbox dirs with **no +rclone process alive** to back them. Sweep by parsing `mount` for `nfs` entries +whose *mountpoint* is under `/orgs/` and `umount -f` each — keyed on +mountpoint, never the source, because rclone derives the export name from +remote+config hash and distinct mounts collide on it (`localhost:/wd{KnsWa}` +appeared for three different mountpoints). `umount -f` reclaims instantly and +exits 1 harmlessly on a non-mount, so the sweep can be unconditional. + +**Done when:** two sandboxes in one org share one rclone set, `mount` shows the +expected NFS entries, and a killed app leaves nothing stale after the next boot. + +### P3 — Per-sandbox org view + +Create the four symlinks at ensure; remove them on sandbox delete. + +- Nothing is written inside `repo/`, so no `.git/info/exclude` entry. +- Symlinks are relative, so the tree survives being moved. + +**Done when:** `/org/home` resolves to the mounted volume and +`git status` in the worktree is unchanged by the view existing. + +### P4 — Desktop prompt + +A desktop variant of `buildOrgFilesystemPrompt` emitting **absolute** paths, +including this thread's `uploads/`/`outputs/` directory. + +**Injection mechanism — verified against the installed CLIs, both work:** + +| Harness | Flag | Semantics | +|---|---|---| +| `claude-code` | `--append-system-prompt ` | **Appends** to the CLI's default prompt; non-destructive. (`--system-prompt` would replace it — do not use.) | +| `codex` | `-c developer_instructions=` | Sets the developer-instruction layer. Multi-line values pass through `-c` correctly. | + +Verified empirically, not just from `--help`: with +`-c developer_instructions="…answer with exactly BANANA"`, `codex exec` answered +`BANANA` to "What is 2+2?", and a multi-line block produced its sentinel too. + +Notes for implementation: + +- `codex` has **no** file-based variant — `experimental_instructions_file`, + `instructions_file` and `base_instructions` are all rejected by + `--strict-config`. Only `instructions` and `developer_instructions` are real + config keys. +- `developer_instructions` **sets** the developer layer rather than appending. + Rust passes none today, so the org block is purely additive — but if a second + block is ever added it must be composed into ONE string, the way the TS + harness joins its parts array. +- The asymmetry is deliberate: claude appends, codex sets. Do not model these + as one "system prompt" parameter without accounting for it. + +Strictly gated on P1–P3 landing. Naming a path before it exists reproduces +the silent-wrong-path failure this plan exists to remove. + +### P5 — Degradation and observability + +- Emit a `[org-fs]` line into the setup transcript when a mount is skipped or + fails, so "no files found" is distinguishable from "storage unavailable". +- Never fail a dispatch because a mount failed (consistent with clone/fetch + failures, which fall back rather than aborting the run). + +## P6 — Final verification (drive the real app) + +Unit tests do not prove an agent can see the org. This phase drives the running +app through the Tauri MCP bridge and asserts on the filesystem underneath. + +**Setup** + +1. `bun run --cwd=apps/native dev` +2. Bridge listens on `ws://127.0.0.1:9223`; drive it with `execute_js` + (debug builds only). +3. Use a git-backed agent whose GitHub attachment is **active** — a `detached` + attachment never auto-starts a sandbox, which is a pre-existing product + behavior unrelated to this work. + +**Driving the chat** + +The composer is a `[contenteditable=true]` div, *not* the 8×20px `