From 0ace1487c2fa8b4e2c1e750f1301b5f3772a6126 Mon Sep 17 00:00:00 2001 From: Alexander Rymdeko-Harvey Date: Mon, 5 Oct 2026 04:22:04 -0400 Subject: [PATCH 01/18] feat(cloud): add control plane and outbound runtime connections --- .dockerignore | 2 + .github/workflows/premerge.yml | 46 +- Cargo.lock | 374 +++- Cargo.toml | 11 + apps/desktop/native-credential-ui/Cargo.toml | 3 + apps/desktop/native-credential-ui/src/lib.rs | 7 +- .../desktop/native-credential-ui/src/linux.rs | 191 ++ .../native-credential-ui/src/prompt.rs | 12 +- apps/desktop/package.json | 1 + apps/desktop/src-tauri/Cargo.lock | 171 ++ apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/build.rs | 22 +- .../src-tauri/capabilities/main-chat.json | 6 + apps/desktop/src-tauri/src/bundle.rs | 33 +- apps/desktop/src-tauri/src/cloud_connector.rs | 319 +++ .../src-tauri/src/desktop_credentials.rs | 4 + apps/desktop/src-tauri/src/lib.rs | 10 + apps/desktop/src-tauri/src/managed_runtime.rs | 32 +- apps/desktop/src-tauri/src/state.rs | 4 + apps/desktop/src-tauri/src/terminal.rs | 3 + .../src/components/CloudConnectionPane.tsx | 241 +++ .../src/components/ManagedSettingsPane.tsx | 146 +- .../src/components/cloud-connection.css | 131 ++ .../src/dev/cloud-connection-preview.tsx | 59 + apps/desktop/src/main.tsx | 13 + apps/web/.gitignore | 2 + apps/web/.npmrc | 2 + apps/web/index.html | 14 + apps/web/package-lock.json | 1809 +++++++++++++++++ apps/web/package.json | 32 + apps/web/public/favicon.svg | 1 + apps/web/src/App.tsx | 721 +++++++ apps/web/src/Fleet.tsx | 371 ++++ apps/web/src/Interactions.tsx | 262 +++ apps/web/src/TaskDetail.tsx | 235 +++ apps/web/src/api.test.ts | 103 + apps/web/src/api.ts | 204 ++ apps/web/src/main.tsx | 12 + apps/web/src/style.css | 1387 +++++++++++++ apps/web/src/vite-env.d.ts | 1 + apps/web/tsconfig.json | 24 + apps/web/vite.config.ts | 14 + crates/colossus-cli/Cargo.toml | 2 + crates/colossus-cli/src/commands.rs | 7 + crates/colossus-cli/src/entrypoint.rs | 41 +- crates/colossus-cli/src/public_api_admin.rs | 157 +- crates/colossus-cli/src/worker_dispatch.rs | 3 +- .../tests/bootstrap_install_smoke.rs | 9 + .../tests/bundle_distribution_smoke.rs | 10 + crates/colossus-cli/tests/ci_contract.rs | 2 + crates/colossus-cli/tests/release_contract.rs | 30 +- crates/colossus-cloud-protocol/Cargo.toml | 23 + crates/colossus-cloud-protocol/build.rs | 12 + .../colossus/cloud/v1alpha1/connector.proto | 97 + crates/colossus-cloud-protocol/src/lib.rs | 13 + .../colossus-cloud-protocol/src/messages.rs | 102 + crates/colossus-cloud-protocol/src/tests.rs | 19 + crates/colossus-cloud-server/Cargo.toml | 58 + .../examples/init_local.rs | 91 + .../examples/init_runtime.rs | 27 + crates/colossus-cloud-server/src/auth.rs | 297 +++ .../colossus-cloud-server/src/auth/tests.rs | 226 ++ .../src/auth/tests/transport.rs | 675 ++++++ .../src/auth/tests/transport/managed.rs | 260 +++ .../colossus-cloud-server/src/certificates.rs | 42 + crates/colossus-cloud-server/src/config.rs | 171 ++ .../colossus-cloud-server/src/connection.rs | 291 +++ crates/colossus-cloud-server/src/http.rs | 451 ++++ crates/colossus-cloud-server/src/lib.rs | 10 + crates/colossus-cloud-server/src/main.rs | 33 + crates/colossus-cloud-server/src/server.rs | 205 ++ .../src/sign-in-failed.html | 78 + crates/colossus-cloud-server/src/tls.rs | 64 + crates/colossus-cloud/Cargo.toml | 26 + crates/colossus-cloud/src/enrollment.rs | 50 + crates/colossus-cloud/src/error.rs | 35 + crates/colossus-cloud/src/identity.rs | 67 + crates/colossus-cloud/src/lib.rs | 22 + crates/colossus-cloud/src/repository.rs | 91 + .../colossus-cloud/src/repository/delivery.rs | 378 ++++ .../src/repository/enrollment.rs | 113 + crates/colossus-cloud/src/repository/nodes.rs | 162 ++ .../colossus-cloud/src/repository/renewal.rs | 95 + crates/colossus-cloud/src/repository/tasks.rs | 324 +++ crates/colossus-cloud/src/tests.rs | 450 ++++ crates/colossus-cloud/src/types.rs | 75 + crates/colossus-cloud/src/validation.rs | 26 + crates/colossus-connector/Cargo.toml | 39 + crates/colossus-connector/src/cli.rs | 293 +++ crates/colossus-connector/src/cli_control.rs | 229 +++ crates/colossus-connector/src/connection.rs | 456 +++++ crates/colossus-connector/src/enrollment.rs | 190 ++ .../src/enrollment/renewal.rs | 122 ++ .../src/enrollment/revocation.rs | 63 + crates/colossus-connector/src/headless.rs | 53 + crates/colossus-connector/src/lib.rs | 11 + crates/colossus-connector/src/main.rs | 15 + crates/colossus-connector/src/released.rs | 117 ++ crates/colossus-credentials/src/database.rs | 17 +- .../colossus-credentials/src/environment.rs | 117 ++ crates/colossus-credentials/src/lib.rs | 2 + crates/colossus-credentials/src/vault.rs | 1 + crates/colossus-credentials/tests/headless.rs | 69 + .../tests/native_linux.rs | 71 + crates/colossus-grpc/src/auth.rs | 6 +- crates/colossus-home/src/confined.rs | 9 +- .../src/plugin_authorization_tests.rs | 1 + crates/colossus-sdk/Cargo.toml | 4 + crates/colossus-sdk/src/backend.rs | 10 + crates/colossus-sdk/src/client.rs | 10 + crates/colossus-sdk/src/grpc.rs | 6 + crates/colossus-sdk/src/lib.rs | 2 + .../src/linux_credential_session.rs | 177 ++ crates/colossus-sdk/src/native_sidecar.rs | 69 + .../src/native_sidecar_windows.rs | 52 + crates/colossus-sdk/src/sidecar.rs | 35 + crates/colossus-sdk/src/types.rs | 393 ++++ .../colossus-sidecar-protocol/src/protocol.rs | 40 +- crates/colossus-sidecar/src/managed.rs | 63 +- .../tests/native_lifecycle.rs | 60 +- .../tests/windows_lifecycle.rs | 72 +- deny.toml | 7 +- deploy/cloud/Dockerfile | 32 + deploy/cloud/kubernetes/cloud.json | 36 + deploy/cloud/kubernetes/control-plane.yaml | 106 + deploy/cloud/kubernetes/ingress.yaml | 40 + deploy/cloud/kubernetes/kustomization.yaml | 15 + deploy/cloud/kubernetes/namespace.yaml | 4 + deploy/cloud/linux-desktop.Dockerfile | 30 + deploy/cloud/local/compose.yml | 24 + deploy/cloud/local/realm.json | 45 + docs/admin/cloud-control-plane.md | 228 +++ docs/develop/adr/0006-cloud-control-plane.md | 96 + docs/develop/adr/index.md | 1 + docs/develop/application-sdk.md | 7 + docs/develop/architecture.md | 8 + docs/develop/cloud-control-plane.md | 152 ++ docs/develop/testing.md | 1 + release/verify-release-readiness.sh | 6 +- scripts/ci/classify-changes.sh | 2 +- scripts/ci/desktop-manifest-binding.test.mjs | 30 +- scripts/ci/test-contracts.sh | 5 + scripts/package-desktop-linux | 83 + scripts/patch-desktop-manifest-binding.mjs | 28 +- scripts/test-desktop-linux-native | 24 + xtask/src/checks/mod.rs | 1 + xtask/src/checks/surfaces.rs | 35 +- xtask/src/cli.rs | 4 + zensical.toml | 3 + 149 files changed, 16185 insertions(+), 198 deletions(-) create mode 100644 apps/desktop/native-credential-ui/src/linux.rs create mode 100644 apps/desktop/src-tauri/src/cloud_connector.rs create mode 100644 apps/desktop/src/components/CloudConnectionPane.tsx create mode 100644 apps/desktop/src/components/cloud-connection.css create mode 100644 apps/desktop/src/dev/cloud-connection-preview.tsx create mode 100644 apps/web/.gitignore create mode 100644 apps/web/.npmrc create mode 100644 apps/web/index.html create mode 100644 apps/web/package-lock.json create mode 100644 apps/web/package.json create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/Fleet.tsx create mode 100644 apps/web/src/Interactions.tsx create mode 100644 apps/web/src/TaskDetail.tsx create mode 100644 apps/web/src/api.test.ts create mode 100644 apps/web/src/api.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/style.css create mode 100644 apps/web/src/vite-env.d.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 crates/colossus-cloud-protocol/Cargo.toml create mode 100644 crates/colossus-cloud-protocol/build.rs create mode 100644 crates/colossus-cloud-protocol/proto/colossus/cloud/v1alpha1/connector.proto create mode 100644 crates/colossus-cloud-protocol/src/lib.rs create mode 100644 crates/colossus-cloud-protocol/src/messages.rs create mode 100644 crates/colossus-cloud-protocol/src/tests.rs create mode 100644 crates/colossus-cloud-server/Cargo.toml create mode 100644 crates/colossus-cloud-server/examples/init_local.rs create mode 100644 crates/colossus-cloud-server/examples/init_runtime.rs create mode 100644 crates/colossus-cloud-server/src/auth.rs create mode 100644 crates/colossus-cloud-server/src/auth/tests.rs create mode 100644 crates/colossus-cloud-server/src/auth/tests/transport.rs create mode 100644 crates/colossus-cloud-server/src/auth/tests/transport/managed.rs create mode 100644 crates/colossus-cloud-server/src/certificates.rs create mode 100644 crates/colossus-cloud-server/src/config.rs create mode 100644 crates/colossus-cloud-server/src/connection.rs create mode 100644 crates/colossus-cloud-server/src/http.rs create mode 100644 crates/colossus-cloud-server/src/lib.rs create mode 100644 crates/colossus-cloud-server/src/main.rs create mode 100644 crates/colossus-cloud-server/src/server.rs create mode 100644 crates/colossus-cloud-server/src/sign-in-failed.html create mode 100644 crates/colossus-cloud-server/src/tls.rs create mode 100644 crates/colossus-cloud/Cargo.toml create mode 100644 crates/colossus-cloud/src/enrollment.rs create mode 100644 crates/colossus-cloud/src/error.rs create mode 100644 crates/colossus-cloud/src/identity.rs create mode 100644 crates/colossus-cloud/src/lib.rs create mode 100644 crates/colossus-cloud/src/repository.rs create mode 100644 crates/colossus-cloud/src/repository/delivery.rs create mode 100644 crates/colossus-cloud/src/repository/enrollment.rs create mode 100644 crates/colossus-cloud/src/repository/nodes.rs create mode 100644 crates/colossus-cloud/src/repository/renewal.rs create mode 100644 crates/colossus-cloud/src/repository/tasks.rs create mode 100644 crates/colossus-cloud/src/tests.rs create mode 100644 crates/colossus-cloud/src/types.rs create mode 100644 crates/colossus-cloud/src/validation.rs create mode 100644 crates/colossus-connector/Cargo.toml create mode 100644 crates/colossus-connector/src/cli.rs create mode 100644 crates/colossus-connector/src/cli_control.rs create mode 100644 crates/colossus-connector/src/connection.rs create mode 100644 crates/colossus-connector/src/enrollment.rs create mode 100644 crates/colossus-connector/src/enrollment/renewal.rs create mode 100644 crates/colossus-connector/src/enrollment/revocation.rs create mode 100644 crates/colossus-connector/src/headless.rs create mode 100644 crates/colossus-connector/src/lib.rs create mode 100644 crates/colossus-connector/src/main.rs create mode 100644 crates/colossus-connector/src/released.rs create mode 100644 crates/colossus-credentials/src/environment.rs create mode 100644 crates/colossus-credentials/tests/headless.rs create mode 100644 crates/colossus-credentials/tests/native_linux.rs create mode 100644 crates/colossus-sdk/src/linux_credential_session.rs create mode 100644 deploy/cloud/Dockerfile create mode 100644 deploy/cloud/kubernetes/cloud.json create mode 100644 deploy/cloud/kubernetes/control-plane.yaml create mode 100644 deploy/cloud/kubernetes/ingress.yaml create mode 100644 deploy/cloud/kubernetes/kustomization.yaml create mode 100644 deploy/cloud/kubernetes/namespace.yaml create mode 100644 deploy/cloud/linux-desktop.Dockerfile create mode 100644 deploy/cloud/local/compose.yml create mode 100644 deploy/cloud/local/realm.json create mode 100644 docs/admin/cloud-control-plane.md create mode 100644 docs/develop/adr/0006-cloud-control-plane.md create mode 100644 docs/develop/cloud-control-plane.md create mode 100644 scripts/package-desktop-linux create mode 100644 scripts/test-desktop-linux-native diff --git a/.dockerignore b/.dockerignore index e95a640d6..700baea70 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,8 @@ .githooks/ .agents/ .codex/ +.local/ +.playwright-cli/ # Local runtime state and credentials must never enter the build context. .colossus/ diff --git a/.github/workflows/premerge.yml b/.github/workflows/premerge.yml index a02d8ae9b..715b9e7f3 100644 --- a/.github/workflows/premerge.yml +++ b/.github/workflows/premerge.yml @@ -177,9 +177,34 @@ jobs: run: >- cargo test --locked -p colossus-sidecar --test native_lifecycle -- --ignored --nocapture + - name: Exercise cloud connection through the native Managed Local grant + env: + COLOSSUS_ACCEPTANCE_SIDECAR: ${{ github.workspace }}/apps/desktop/src-tauri/binaries/colossus-sidecar-aarch64-apple-darwin + run: cargo test --locked -p colossus-cloud-server --lib managed_sidecar_cloud_run_isolated_and_application_exit_disconnects -- --ignored - name: Run native isolation and escape acceptance run: cargo test --locked -p colossus-cli --test native_sandbox -- --nocapture + linux-desktop: + name: Linux Desktop package and native credential entry + if: needs.eligibility.result == 'success' + needs: eligibility + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Build sealed Linux Desktop package and test native entry + run: docker build -f deploy/cloud/linux-desktop.Dockerfile --target artifacts --output type=local,dest=dist/desktop-linux . + - name: Verify Linux package checksums + working-directory: dist/desktop-linux + run: sha256sum --check ./*.sha256 + - name: Retain Linux Desktop package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-desktop-preview + path: dist/desktop-linux + retention-days: 7 + credential-vault: name: Credential vault (${{ matrix.os }}) if: needs.eligibility.result == 'success' @@ -550,6 +575,20 @@ jobs: env: COLOSSUS_ACCEPTANCE_SIDECAR: ${{ github.workspace }}/apps/desktop/src-tauri/binaries/colossus-sidecar-x86_64-pc-windows-msvc.exe run: cargo test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml --lib managed_runtime::credential_acceptance::native_vault_restarts_reach_managed_sidecar_mcp -- --ignored --exact + - name: Exercise Windows cloud transport and native Managed Local lifetime + id: cloud_native + if: steps.desktop_prepare.outcome == 'success' + continue-on-error: true + env: + COLOSSUS_ACCEPTANCE_SIDECAR: ${{ github.workspace }}/apps/desktop/src-tauri/binaries/colossus-sidecar-x86_64-pc-windows-msvc.exe + run: | + cargo test --locked -p colossus-cloud-server --lib oidc_to_runtime_prompt_approval_cancel_rotate_and_recover + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo test --locked -p colossus-cloud-server --lib managed_sidecar_cloud_run_isolated_and_application_exit_disconnects -- --ignored + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo test --locked -p colossus-sidecar --test windows_lifecycle -- --ignored + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo test --locked -p colossus-cli --bin colossus public_api_admin::tests - name: Exercise Windows setup reset and uninstall credential cleanup if: steps.desktop_prepare.outcome == 'success' run: | @@ -618,6 +657,7 @@ jobs: NATIVE_TEST_OUTCOME: ${{ steps.native_tests.outcome }} PLUGIN_RUNTIME_OUTCOME: ${{ steps.plugin_runtime.outcome }} APPROVAL_RUNTIME_OUTCOME: ${{ steps.approval_runtime.outcome }} + CLOUD_NATIVE_OUTCOME: ${{ steps.cloud_native.outcome }} run: | set -eu failed= @@ -626,7 +666,8 @@ jobs: "desktop-clippy=$CLIPPY_OUTCOME" \ "desktop-tests=$NATIVE_TEST_OUTCOME" \ "desktop-plugin-runtime=$PLUGIN_RUNTIME_OUTCOME" \ - "desktop-approval-runtime=$APPROVAL_RUNTIME_OUTCOME" + "desktop-approval-runtime=$APPROVAL_RUNTIME_OUTCOME" \ + "desktop-cloud-native=$CLOUD_NATIVE_OUTCOME" do if test "${result#*=}" != success; then failed="$failed ${result%%=*}" @@ -929,7 +970,7 @@ jobs: gate: name: Colossus pre-merge gate if: always() - needs: [eligibility, linux-rust-integration, macos-native, macos-desktop-acceptance, macos-desktop-bundle, windows-runtime, windows-desktop, credential-vault, fuzz, supply-chain, chroma, storage, live-security] + needs: [eligibility, linux-rust-integration, linux-desktop, macos-native, macos-desktop-acceptance, macos-desktop-bundle, windows-runtime, windows-desktop, credential-vault, fuzz, supply-chain, chroma, storage, live-security] runs-on: ubuntu-slim timeout-minutes: 5 steps: @@ -943,6 +984,7 @@ jobs: RESULTS: >- eligibility=${{ needs.eligibility.result }} linux-rust-integration=${{ needs['linux-rust-integration'].result }} + linux-desktop=${{ needs['linux-desktop'].result }} macos=${{ needs['macos-native'].result }} macos-desktop-acceptance=${{ needs['macos-desktop-acceptance'].result }} macos-desktop-bundle=${{ needs['macos-desktop-bundle'].result }} diff --git a/Cargo.lock b/Cargo.lock index b8d377eb3..b6e37de30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -461,10 +461,13 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", "http-body-util", + "hyper", + "hyper-util", "itoa", "matchit", "memchr", @@ -472,10 +475,15 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", + "tokio", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -494,6 +502,7 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -502,6 +511,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[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" @@ -1023,7 +1038,7 @@ name = "colossus-api" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-contracts", "colossus-ports", "colossus-testkit", @@ -1055,7 +1070,7 @@ name = "colossus-api-runtime" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-agent", "colossus-api", "colossus-contracts", @@ -1084,7 +1099,7 @@ name = "colossus-audit" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-contracts", "colossus-journal-redb", "colossus-policy", @@ -1117,7 +1132,7 @@ name = "colossus-bundles" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-contracts", "colossus-policy", "ed25519-dalek", @@ -1137,13 +1152,15 @@ version = "0.11.7" dependencies = [ "agent-client-protocol", "async-trait", - "base64", + "base64 0.22.1", "clap", "colossus-access", "colossus-api", "colossus-bundles", "colossus-codex-auth", + "colossus-connector", "colossus-contracts", + "colossus-credentials", "colossus-darwin-process", "colossus-grpc", "colossus-home", @@ -1189,11 +1206,93 @@ dependencies = [ "zeroize", ] +[[package]] +name = "colossus-cloud" +version = "0.11.7" +dependencies = [ + "colossus-cloud-protocol", + "colossus-contracts", + "colossus-journal-redb", + "colossus-ports", + "colossus-sdk", + "hex", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "colossus-cloud-protocol" +version = "0.11.7" +dependencies = [ + "colossus-sdk", + "prost", + "protoc-bin-vendored", + "serde", + "serde_json", + "thiserror 2.0.18", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + +[[package]] +name = "colossus-cloud-server" +version = "0.11.7" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "chrono", + "colossus-api", + "colossus-api-runtime", + "colossus-cloud", + "colossus-cloud-protocol", + "colossus-connector", + "colossus-contracts", + "colossus-credentials", + "colossus-home", + "colossus-journal-postgres", + "colossus-journal-redb", + "colossus-policy", + "colossus-ports", + "colossus-runtime", + "colossus-sdk", + "colossus-testkit", + "futures", + "getrandom 0.4.3", + "hex", + "keyring", + "openidconnect", + "p256", + "rcgen", + "reqwest 0.12.28", + "rustls", + "serde", + "serde-saphyr", + "serde_json", + "sha2 0.10.9", + "tempfile", + "time", + "tokio", + "tokio-rustls", + "tokio-stream", + "tonic", + "tower", + "tower-http", + "url", + "uuid", + "zeroize", +] + [[package]] name = "colossus-codex-auth" version = "0.11.7" dependencies = [ - "base64", + "base64 0.22.1", "colossus-windows-native", "directories", "fs4", @@ -1207,6 +1306,37 @@ dependencies = [ "zeroize", ] +[[package]] +name = "colossus-connector" +version = "0.11.7" +dependencies = [ + "async-trait", + "clap", + "colossus-cloud", + "colossus-cloud-protocol", + "colossus-contracts", + "colossus-credentials", + "colossus-home", + "colossus-ports", + "colossus-sdk", + "fs4", + "futures", + "hex", + "rcgen", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "tokio", + "tokio-stream", + "tonic", + "url", + "uuid", + "x509-parser", + "zeroize", +] + [[package]] name = "colossus-context" version = "0.11.7" @@ -1243,7 +1373,7 @@ name = "colossus-credentials" version = "0.11.7" dependencies = [ "apple-native-keyring-store", - "base64", + "base64 0.22.1", "chacha20poly1305", "colossus-contracts", "colossus-home", @@ -1289,7 +1419,7 @@ dependencies = [ name = "colossus-grpc" version = "0.11.7" dependencies = [ - "base64", + "base64 0.22.1", "colossus-api", "colossus-api-proto", "colossus-contracts", @@ -1392,7 +1522,7 @@ dependencies = [ name = "colossus-journal-redb" version = "0.11.7" dependencies = [ - "base64", + "base64 0.22.1", "chacha20poly1305", "colossus-contracts", "colossus-memory", @@ -1429,7 +1559,7 @@ name = "colossus-mcp" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "chacha20poly1305", "colossus-contracts", "colossus-network", @@ -1467,7 +1597,7 @@ name = "colossus-media" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-contracts", "colossus-ports", "colossus-testkit", @@ -1577,7 +1707,7 @@ dependencies = [ name = "colossus-plugins" version = "0.11.7" dependencies = [ - "base64", + "base64 0.22.1", "colossus-contracts", "colossus-journal-redb", "colossus-network", @@ -1610,7 +1740,7 @@ name = "colossus-policy" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-contracts", "colossus-network", "colossus-ports", @@ -1679,7 +1809,7 @@ name = "colossus-provider" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-codex-auth", "colossus-contracts", "colossus-network", @@ -1725,7 +1855,7 @@ name = "colossus-runtime" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-access", "colossus-agent", "colossus-audit", @@ -1789,7 +1919,7 @@ name = "colossus-sandbox" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "capctl", "colossus-contracts", "colossus-network", @@ -1825,7 +1955,7 @@ name = "colossus-sdk" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-api", "colossus-api-proto", "colossus-contracts", @@ -2049,7 +2179,7 @@ name = "colossus-worker" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-api", "colossus-api-proto", "colossus-api-runtime", @@ -2082,7 +2212,7 @@ dependencies = [ name = "colossus-worker-protocol" version = "0.11.7" dependencies = [ - "base64", + "base64 0.22.1", "colossus-contracts", "getrandom 0.4.3", "hex", @@ -2732,6 +2862,7 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -3381,6 +3512,12 @@ dependencies = [ "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" @@ -3460,7 +3597,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -3753,6 +3890,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -3905,7 +4051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5" dependencies = [ "ahash", - "base64", + "base64 0.22.1", "bytecount", "email_address", "fancy-regex", @@ -3973,6 +4119,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "levenshtein_automata" @@ -4159,6 +4308,16 @@ 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 = "minimal-lexical" version = "0.2.1" @@ -4360,6 +4519,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + [[package]] name = "num-cmp" version = "0.1.0" @@ -4469,11 +4644,12 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http", "rand 0.8.7", + "reqwest 0.12.28", "serde", "serde_json", "serde_path_to_error", @@ -4624,6 +4800,37 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac 0.12.1", + "http", + "itertools 0.10.5", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.7", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2 0.10.9", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -4725,6 +4932,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-float" version = "5.3.0" @@ -4771,6 +4987,18 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "palette" version = "0.7.6" @@ -4831,7 +5059,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -4917,6 +5145,17 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -5027,7 +5266,7 @@ version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" dependencies = [ - "base64", + "base64 0.22.1", "byteorder", "bytes", "fallible-iterator", @@ -5283,7 +5522,7 @@ dependencies = [ "bytemuck", "libm", "num-traits", - "ordered-float", + "ordered-float 5.3.0", "palette", "rand 0.10.2", "rand_xoshiro", @@ -5731,7 +5970,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -5774,7 +6013,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -5858,6 +6097,26 @@ dependencies = [ "url", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rubato" version = "0.16.2" @@ -6223,7 +6482,7 @@ checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" dependencies = [ "ahash", "annotate-snippets", - "base64", + "base64 0.22.1", "encoding_rs_io", "getrandom 0.3.4", "granit-parser", @@ -6234,6 +6493,16 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float 2.10.1", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -6312,6 +6581,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -6371,7 +6649,7 @@ version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ - "base64", + "base64 0.22.1", "bs58", "chrono", "hex", @@ -6526,7 +6804,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94ba33ef96bafd16f5dfc954113c37a689dcf721816d11680667626b1ebb0496" dependencies = [ - "base64", + "base64 0.22.1", "hex", "serde", "serde_json", @@ -6544,7 +6822,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ecc48bf9facc710e772b3e7ab6a3ebc81c34be208a6ebf6c872d0ccbb8b52f" dependencies = [ "aws-lc-rs", - "base64", + "base64 0.22.1", "const-oid 0.9.6", "der", "digest 0.10.7", @@ -6565,7 +6843,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f6fc5c295b5b9fa929e38acc53b29c8ed9ba3ee93e5fa87e7daa44072f0b26e" dependencies = [ - "base64", + "base64 0.22.1", "hex", "sigstore-crypto", "sigstore-types", @@ -6578,7 +6856,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ece0d614b93c94e2ddab5c2142959396ce5a5f1d315435779e8ce0f9da250f5d" dependencies = [ - "base64", + "base64 0.22.1", "hex", "reqwest 0.13.4", "serde", @@ -6596,7 +6874,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a91dbe81d236075c568926b5540d86dbc0e272cd0c6cfc5523e35d5200e71cf0" dependencies = [ - "base64", + "base64 0.22.1", "directories", "hex", "jiff", @@ -6619,7 +6897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a04c841cdc52fc5da6284d6aa1fbf93f6b65d71437b2cc04751d117eac8aa33" dependencies = [ "aws-lc-rs", - "base64", + "base64 0.22.1", "cmpv2", "cms", "const-oid 0.9.6", @@ -6666,7 +6944,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a48661f5c7a4f7496de80865e5b258adac0bc514164e6c944b93b65ba5de334b" dependencies = [ - "base64", + "base64 0.22.1", "hex", "pem", "serde", @@ -6680,7 +6958,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c4fed99a3609ebb236284b781674d8c45beea93bbc823ef2facd2261b3801c7" dependencies = [ - "base64", + "base64 0.22.1", "cms", "const-oid 0.9.6", "hex", @@ -6763,6 +7041,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "spki" version = "0.7.3" @@ -6918,7 +7202,7 @@ checksum = "edde6a10743fff00a4e1a8c9ef020bf5f3cbad301b7d2d39f2b07f123c4eac07" dependencies = [ "aho-corasick", "arc-swap", - "base64", + "base64 0.22.1", "bitpacking", "bon", "byteorder", @@ -7020,7 +7304,7 @@ checksum = "dfadb8526b6da90704feb293b0701a6aae62ea14983143344be2dc5ce30f1d82" dependencies = [ "fnv", "nom", - "ordered-float", + "ordered-float 5.3.0", "serde", "serde_json", ] @@ -7417,7 +7701,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "h2", "http", @@ -7517,10 +7801,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.13.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -7545,6 +7838,7 @@ 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", diff --git a/Cargo.toml b/Cargo.toml index 9020a6565..622e4c490 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,10 @@ [workspace] resolver = "3" members = [ + "crates/colossus-cloud", + "crates/colossus-cloud-server", + "crates/colossus-connector", + "crates/colossus-cloud-protocol", "xtask", "crates/colossus-domain", "crates/colossus-contracts", @@ -69,6 +73,13 @@ repository = "https://github.com/obscuritylabs/Colossus" publish-sdks = false [workspace.dependencies] +chrono = "0.4.44" +openidconnect = { version = "4.0.1", default-features = false, features = ["reqwest", "rustls-tls"] } +colossus-connector = { version = "=0.11.7", path = "crates/colossus-connector" } +colossus-cloud-server = { version = "=0.11.7", path = "crates/colossus-cloud-server" } +axum = { version = "0.8.9", features = ["json", "tokio", "http1", "http2"] } +colossus-cloud-protocol = { version = "=0.11.7", path = "crates/colossus-cloud-protocol" } +colossus-cloud = { version = "=0.11.7", path = "crates/colossus-cloud" } agent-client-protocol = "=2.2.0" colossus-bundled-plugins = { version = "=0.11.7", path = "crates/colossus-bundled-plugins" } async-trait = "0.1.89" diff --git a/apps/desktop/native-credential-ui/Cargo.toml b/apps/desktop/native-credential-ui/Cargo.toml index 8b03862c5..be424486d 100644 --- a/apps/desktop/native-credential-ui/Cargo.toml +++ b/apps/desktop/native-credential-ui/Cargo.toml @@ -49,6 +49,9 @@ objc2-foundation = { version = "0.3.2", default-features = false, features = [ "std", "NSDate", "NSFormatter", "NSGeometry", "NSNotification", "NSObject", "NSString", "NSThread", "NSTimer", ] } +[target.'cfg(target_os = "linux")'.dependencies] +gtk = "=0.18.2" + [lints.clippy] all = "warn" pedantic = "warn" diff --git a/apps/desktop/native-credential-ui/src/lib.rs b/apps/desktop/native-credential-ui/src/lib.rs index 9e9b8a662..a0d5f4795 100644 --- a/apps/desktop/native-credential-ui/src/lib.rs +++ b/apps/desktop/native-credential-ui/src/lib.rs @@ -5,12 +5,15 @@ //! the displayed control contents can be explicitly cleared here. mod appearance; -#[cfg(any(windows, target_os = "macos"))] +#[cfg(any(windows, target_os = "macos", target_os = "linux"))] mod lifecycle; mod prompt; -#[cfg(any(windows, target_os = "macos"))] +#[cfg(any(windows, target_os = "macos", target_os = "linux"))] mod validation; +#[cfg(target_os = "linux")] +mod linux; + #[cfg(target_os = "macos")] mod macos; #[cfg(windows)] diff --git a/apps/desktop/native-credential-ui/src/linux.rs b/apps/desktop/native-credential-ui/src/linux.rs new file mode 100644 index 000000000..ee21db3e4 --- /dev/null +++ b/apps/desktop/native-credential-ui/src/linux.rs @@ -0,0 +1,191 @@ +//! Owned GTK password entry on Tauri's existing UI thread; no nested event loop. +use crate::{DialogAppearance, PromptError, lifecycle::Completion, validation}; +use colossus_contracts::HostSecret; +use gtk::{glib, prelude::*}; +use std::{ + cell::RefCell, + rc::Rc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; +use zeroize::Zeroizing; + +pub(crate) fn open( + parent: &tauri::Window, + cancelled: Arc, + completion: Completion, + _appearance: DialogAppearance, +) { + let Ok(parent) = parent.gtk_window() else { + completion.finish(Err(PromptError::Unavailable)); + return; + }; + create(parent.upcast_ref(), cancelled, completion); +} + +fn create( + parent: >k::Window, + cancelled: Arc, + completion: Completion, +) -> (gtk::Dialog, gtk::Entry) { + let dialog = gtk::Dialog::builder() + .title("Save a credential") + .transient_for(parent) + .modal(true) + .destroy_with_parent(true) + .default_width(520) + .resizable(false) + .build(); + dialog.add_button("Cancel", gtk::ResponseType::Cancel); + dialog.add_button("Save credential", gtk::ResponseType::Accept); + dialog.set_default_response(gtk::ResponseType::Accept); + let content = dialog.content_area(); + content.set_spacing(12); + content.set_margin_start(24); + content.set_margin_end(24); + content.set_margin_top(20); + content.set_margin_bottom(20); + let description = gtk::Label::new(Some( + "This token stays in native credential storage and is never sent to the app's web view.", + )); + description.set_line_wrap(true); + description.set_xalign(0.0); + let label = gtk::Label::new(Some("_Token")); + label.set_use_underline(true); + label.set_xalign(0.0); + let entry = gtk::Entry::new(); + entry.set_visibility(false); + entry.set_input_purpose(gtk::InputPurpose::Password); + entry.set_activates_default(true); + label.set_mnemonic_widget(Some(&entry)); + // GTK's password controls already suppress copying. Keep that guarantee + // explicit for keyboard/menu signal paths without changing the user's clipboard. + entry.connect_copy_clipboard(|entry| entry.stop_signal_emission_by_name("copy-clipboard")); + entry.connect_cut_clipboard(|entry| entry.stop_signal_emission_by_name("cut-clipboard")); + let error = gtk::Label::new(None); + error.set_line_wrap(true); + error.set_xalign(0.0); + content.add(&description); + content.add(&label); + content.add(&entry); + content.add(&error); + let completion = Rc::new(RefCell::new(Some(completion))); + let result = Rc::new(RefCell::new(None)); + dialog.connect_response({ + let result = result.clone(); + let entry = entry.clone(); + move |dialog, response| { + // GtkWindow::close emits a second delete response. Preserve the + // accepted result until destruction releases native ownership. + if result.borrow().is_some() { + return; + } + if response == gtk::ResponseType::Accept { + let text = Zeroizing::new(entry.text().to_string()); + if let Err(invalid) = validation::validate(&text) { + error.set_text(invalid.message()); + entry.grab_focus(); + return; + } + *result.borrow_mut() = + Some(HostSecret::new(text.to_string()).map_err(|_| PromptError::Unavailable)); + } else { + *result.borrow_mut() = Some(Err(PromptError::Cancelled)); + } + entry.set_text(""); + dialog.close(); + } + }); + dialog.connect_destroy({ + let entry = entry.clone(); + move |_| { + entry.set_text(""); + if let Some(completion) = completion.borrow_mut().take() { + completion.finish( + result + .borrow_mut() + .take() + .unwrap_or(Err(PromptError::Cancelled)), + ); + } + } + }); + let weak = dialog.downgrade(); + glib::timeout_add_local(Duration::from_millis(50), move || { + let Some(dialog) = weak.upgrade() else { + return glib::ControlFlow::Break; + }; + if cancelled.load(Ordering::Acquire) { + dialog.response(gtk::ResponseType::Cancel); + glib::ControlFlow::Break + } else { + glib::ControlFlow::Continue + } + }); + dialog.show_all(); + entry.grab_focus(); + (dialog, entry) +} + +#[cfg(test)] +mod tests { + use super::*; + fn wait_for_completion( + received: &mut tokio::sync::oneshot::Receiver>, + ) -> Result { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + loop { + match received.try_recv() { + Ok(result) => return result, + Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {} + Err(error) => panic!("native completion closed: {error}"), + } + assert!( + std::time::Instant::now() < deadline, + "native dialog did not finish closing" + ); + let context = glib::MainContext::default(); + while context.pending() { + context.iteration(false); + } + std::thread::sleep(Duration::from_millis(1)); + } + } + #[test] + #[ignore = "operator-owned: xvfb-run and a GTK display; see cloud development guide"] + fn native_linux_password_entry_and_cancel() { + let _ownership = crate::lifecycle::TEST_OWNERSHIP.lock().unwrap(); + gtk::init().unwrap(); + let parent = gtk::Window::new(gtk::WindowType::Toplevel); + parent.show_all(); + let (completion, mut received) = Completion::acquire().unwrap(); + let (dialog, entry) = create(&parent, Arc::new(AtomicBool::new(false)), completion); + assert!(!gtk::prelude::EntryExt::is_visible(&entry)); + let clipboard = gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD); + clipboard.set_text("unrelated-synthetic-clipboard"); + entry.set_text("synthetic-token"); + entry.select_region(0, -1); + entry.emit_copy_clipboard(); + assert_eq!( + clipboard.wait_for_text().as_deref(), + Some("unrelated-synthetic-clipboard") + ); + entry.set_text("bad token"); + dialog.response(gtk::ResponseType::Accept); + assert!(received.try_recv().is_err()); + entry.set_text("synthetic-token"); + dialog.response(gtk::ResponseType::Accept); + wait_for_completion(&mut received).expect("valid native credential entry"); + let (completion, mut received) = Completion::acquire().unwrap(); + let (dialog, _) = create(&parent, Arc::new(AtomicBool::new(false)), completion); + dialog.response(gtk::ResponseType::Cancel); + assert_eq!( + wait_for_completion(&mut received).unwrap_err(), + PromptError::Cancelled + ); + parent.close(); + } +} diff --git a/apps/desktop/native-credential-ui/src/prompt.rs b/apps/desktop/native-credential-ui/src/prompt.rs index 52a6724f9..2c5a52403 100644 --- a/apps/desktop/native-credential-ui/src/prompt.rs +++ b/apps/desktop/native-credential-ui/src/prompt.rs @@ -1,12 +1,12 @@ -#[cfg(any(windows, target_os = "macos"))] +#[cfg(any(windows, target_os = "macos", target_os = "linux"))] use std::sync::{Arc, atomic::AtomicBool}; -#[cfg(any(windows, target_os = "macos"))] +#[cfg(any(windows, target_os = "macos", target_os = "linux"))] use crate::ColorScheme; use crate::DialogAppearance; use colossus_contracts::HostSecret; -#[cfg(any(windows, target_os = "macos"))] +#[cfg(any(windows, target_os = "macos", target_os = "linux"))] use crate::lifecycle::{CancelOnDrop, Completion}; /// Categorical native errors; never contain credential data or platform errors. @@ -44,12 +44,12 @@ pub async fn prompt( parent: tauri::Window, appearance: DialogAppearance, ) -> Result { - #[cfg(not(any(windows, target_os = "macos")))] + #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] { let _ = (parent, appearance); return Err(PromptError::Unsupported); } - #[cfg(any(windows, target_os = "macos"))] + #[cfg(any(windows, target_os = "macos", target_os = "linux"))] { let mut appearance = appearance; if appearance.color_scheme == ColorScheme::System { @@ -69,6 +69,8 @@ pub async fn prompt( crate::windows::open(&native_parent, cancelled, completion, appearance); #[cfg(target_os = "macos")] crate::macos::open(&native_parent, cancelled, completion, appearance); + #[cfg(target_os = "linux")] + crate::linux::open(&native_parent, cancelled, completion, appearance); }) .map_err(|_| PromptError::Unavailable)?; receiver.await.map_err(|_| PromptError::Cancelled)? diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9ac0754a4..820d093f3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -30,6 +30,7 @@ "tauri:build": "npm run prepare:sidecars:release && tauri build --no-bundle -- --locked", "tauri:bundle:macos": "../../scripts/package-desktop-macos", "tauri:bundle:windows": "pwsh -File ../../scripts/package-desktop-windows.ps1", + "tauri:bundle:linux": "sh ../../scripts/package-desktop-linux", "prepare:dictation": "node ../../scripts/stage-dictation-model.mjs" }, "dependencies": { diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 45a292c5b..3c93a2c25 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -109,12 +109,56 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -838,6 +882,46 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "clap" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.2", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.58" @@ -871,6 +955,12 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "colossus-api" version = "0.11.7" @@ -901,6 +991,37 @@ dependencies = [ "tonic-prost-build", ] +[[package]] +name = "colossus-cloud" +version = "0.11.7" +dependencies = [ + "colossus-cloud-protocol", + "colossus-contracts", + "colossus-ports", + "colossus-sdk", + "hex", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.19", + "uuid", +] + +[[package]] +name = "colossus-cloud-protocol" +version = "0.11.7" +dependencies = [ + "colossus-sdk", + "prost", + "protoc-bin-vendored", + "serde", + "serde_json", + "thiserror 2.0.19", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + [[package]] name = "colossus-codex-auth" version = "0.11.7" @@ -919,6 +1040,36 @@ dependencies = [ "zeroize", ] +[[package]] +name = "colossus-connector" +version = "0.11.7" +dependencies = [ + "async-trait", + "clap", + "colossus-cloud", + "colossus-cloud-protocol", + "colossus-contracts", + "colossus-credentials", + "colossus-home", + "colossus-ports", + "colossus-sdk", + "fs4", + "futures", + "hex", + "rcgen", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "tokio", + "tokio-stream", + "tonic", + "url", + "uuid", + "x509-parser", + "zeroize", +] + [[package]] name = "colossus-contracts" version = "0.11.7" @@ -968,6 +1119,7 @@ dependencies = [ "apple-native-keyring-store", "base64 0.22.1", "colossus-codex-auth", + "colossus-connector", "colossus-contracts", "colossus-credentials", "colossus-home", @@ -1139,6 +1291,7 @@ name = "colossus-native-credential-ui" version = "0.1.0" dependencies = [ "colossus-contracts", + "gtk", "objc2", "objc2-app-kit", "objc2-foundation", @@ -3164,6 +3317,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -4233,6 +4392,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -7443,6 +7608,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.24.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index e05a2e4ba..12ac759b3 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -49,6 +49,7 @@ sha2 = "0.10.9" tauri-build = { version = "=2.6.3", features = [] } [dependencies] +colossus-connector = { version = "=0.11.7", path = "../../../crates/colossus-connector" } anyhow = "1.0.104" base64 = "0.22.1" zip = { version = "4.6.1", default-features = false, features = ["deflate-flate2"] } diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 1c229101d..b5c855647 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -109,6 +109,12 @@ const COMMANDS: &[&str] = &[ "set_terminal_enabled", "connect_colossus", "connection_status", + "cloud_enroll", + "cloud_connect", + "cloud_status", + "cloud_disconnect", + "cloud_forget", + "cloud_revoke", "create_run", "choose_run_attachment", "read_artifact_content", @@ -204,6 +210,18 @@ fn export_release_trust_configuration() { ), _ => panic!("{CHANNEL_VARIABLE} must be stable, developer_preview, or validation_only"), } + } else if target_os == "linux" { + assert!( + team_id == "UNSIGNED", + "Linux packaging requires {TEAM_VARIABLE}=UNSIGNED" + ); + assert!( + matches!( + release_channel.as_str(), + "developer_preview" | "validation_only" + ), + "Linux packages require an explicit developer_preview or validation_only channel" + ); } else { let canonical_team = team_id.len() == 10 && team_id @@ -222,7 +240,9 @@ fn export_release_trust_configuration() { } } let signing_status = match (target_os.as_str(), release_channel.as_str()) { - ("windows", "validation_only") => "unsigned", + ("windows", "validation_only") | ("linux", "developer_preview" | "validation_only") => { + "unsigned" + } ("windows", "stable" | "developer_preview") | ("macos", "stable") => "verified", ("macos", "developer_preview" | "validation_only") => "ad_hoc", _ => "unsupported", diff --git a/apps/desktop/src-tauri/capabilities/main-chat.json b/apps/desktop/src-tauri/capabilities/main-chat.json index 4f09271e9..76b94b891 100644 --- a/apps/desktop/src-tauri/capabilities/main-chat.json +++ b/apps/desktop/src-tauri/capabilities/main-chat.json @@ -104,6 +104,12 @@ "allow-set-terminal-enabled", "allow-connect-colossus", "allow-connection-status", + "allow-cloud-enroll", + "allow-cloud-connect", + "allow-cloud-status", + "allow-cloud-disconnect", + "allow-cloud-forget", + "allow-cloud-revoke", "allow-create-run", "allow-choose-run-attachment", "allow-read-artifact-content", diff --git a/apps/desktop/src-tauri/src/bundle.rs b/apps/desktop/src-tauri/src/bundle.rs index 6dcc10ece..2e76a8894 100644 --- a/apps/desktop/src-tauri/src/bundle.rs +++ b/apps/desktop/src-tauri/src/bundle.rs @@ -28,11 +28,11 @@ const EXPECTED_RELEASE_TEAM_ID: &str = env!("COLOSSUS_DESKTOP_TEAM_ID"); const EXPECTED_RELEASE_CHANNEL: &str = env!("COLOSSUS_DESKTOP_RELEASE_CHANNEL"); #[cfg(all(not(debug_assertions), target_os = "macos"))] const DESKTOP_CODE_IDENTIFIER: &str = "com.obscuritylabs.colossus.desktop"; -#[cfg(not(debug_assertions))] +#[cfg(all(not(debug_assertions), any(target_os = "macos", windows)))] const SIDECAR_CODE_IDENTIFIER: &str = "com.obscuritylabs.colossus.desktop.sidecar"; -#[cfg(not(debug_assertions))] +#[cfg(all(not(debug_assertions), any(target_os = "macos", windows)))] const CLI_CODE_IDENTIFIER: &str = "com.obscuritylabs.colossus.desktop.cli"; -#[cfg(not(debug_assertions))] +#[cfg(all(not(debug_assertions), any(target_os = "macos", windows)))] const RIPGREP_CODE_IDENTIFIER: &str = "com.obscuritylabs.colossus.desktop.ripgrep"; #[derive(Debug, Deserialize)] @@ -344,9 +344,14 @@ fn release_app_root() -> Option { .and_then(|executable| executable.parent().map(Path::to_owned)) } +#[cfg(all(not(debug_assertions), target_os = "linux"))] +fn release_app_root() -> Option { + release_directory() +} + #[cfg(all( not(debug_assertions), - not(any(target_os = "macos", target_os = "windows")) + not(any(target_os = "macos", target_os = "windows", target_os = "linux")) ))] fn release_app_root() -> Option { None @@ -474,8 +479,24 @@ fn verify_outer_app_signature(app_root: &Path) -> Result<(), CommandErrorDto> { } } +#[cfg(all(target_os = "linux", not(debug_assertions)))] +fn verify_outer_app_signature(app_root: &Path) -> Result<(), CommandErrorDto> { + use std::os::unix::fs::MetadataExt as _; + // Linux preview distribution trust comes from the reviewed artifact and its + // checksum. The running ELF binds the manifest; it does not claim OS signing. + let metadata = std::fs::symlink_metadata(app_root).map_err(|_| integrity_error())?; + if EXPECTED_RELEASE_TEAM_ID != "UNSIGNED" + || expected_release_channel()? != ReleaseChannel::DeveloperPreview + || !metadata.is_dir() + || metadata.mode() & 0o022 != 0 + { + return Err(integrity_error()); + } + Ok(()) +} + #[cfg(all( - not(any(target_os = "macos", target_os = "windows")), + not(any(target_os = "macos", target_os = "windows", target_os = "linux")), not(debug_assertions) ))] fn verify_outer_app_signature(_app_root: &Path) -> Result<(), CommandErrorDto> { @@ -554,7 +575,7 @@ fn verify_release_code_identity( } #[cfg(all( - not(any(target_os = "macos", target_os = "windows")), + not(any(target_os = "macos", target_os = "windows", target_os = "linux")), not(debug_assertions) ))] fn verify_release_code_identity( diff --git a/apps/desktop/src-tauri/src/cloud_connector.rs b/apps/desktop/src-tauri/src/cloud_connector.rs new file mode 100644 index 000000000..b7f5178e5 --- /dev/null +++ b/apps/desktop/src-tauri/src/cloud_connector.rs @@ -0,0 +1,319 @@ +//! Narrow native cloud enrollment and lifecycle; renderer never receives runtime credentials. +use crate::{ + desktop_credentials::DesktopCredentials, desktop_settings::SettingsStore, dto::CommandErrorDto, + state::AppState, +}; +use colossus_connector::{ConnectionConfig, ConnectorStatus, EnrollmentStore, RuntimeConnector}; +use colossus_sdk::AgentRunClient; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tauri::{AppHandle, State}; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; +use tokio::sync::watch; +use zeroize::Zeroizing; + +pub(crate) struct CloudSession { + config: ConnectionConfig, + status: watch::Receiver, + shutdown: watch::Sender, + task: tauri::async_runtime::JoinHandle<()>, +} +impl Drop for CloudSession { + fn drop(&mut self) { + let _ = self.shutdown.send(true); + self.task.abort(); + } +} +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CloudStatus { + target_id: String, + status: ConnectorStatus, + node_id: Option, + project_id: Option, + endpoint: Option, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct EnrollInput { + target_id: String, + enrollment_url: String, + token: Zeroizing, +} +fn failure(message: &str) -> CommandErrorDto { + CommandErrorDto::invalid("cloud_connection", message) +} +fn store(state: &AppState, target: &str) -> Result, CommandErrorDto> { + let settings = SettingsStore::open_application()?; + let credentials = DesktopCredentials::for_settings(state, &settings)?; + EnrollmentStore::new(credentials.native_vault(), target) + .map(Arc::new) + .map_err(failure) +} +fn dto(target: String, config: Option<&ConnectionConfig>, status: ConnectorStatus) -> CloudStatus { + CloudStatus { + target_id: target, + status, + node_id: config.map(|config| config.node_id.clone()), + project_id: config.map(|config| config.project_id.clone()), + endpoint: config.map(|config| config.endpoint.clone()), + } +} +async fn confirm(app: &AppHandle, message: String) -> Result<(), CommandErrorDto> { + let app = app.clone(); + let accepted = tauri::async_runtime::spawn_blocking(move || { + app.dialog() + .message(message) + .title("Connect runtime to Colossus Cloud") + .buttons(MessageDialogButtons::OkCancelCustom( + "Connect runtime".into(), + "Cancel".into(), + )) + .blocking_show() + }) + .await + .map_err(|_| failure("Native connection confirmation is unavailable."))?; + if accepted { + Ok(()) + } else { + Err(failure("Cloud connection was cancelled.")) + } +} +async fn start( + state: &AppState, + target: String, + config: ConnectionConfig, + key: Zeroizing, + runs: Arc, +) -> Result { + let connector = RuntimeConnector::new(config.clone(), key, runs) + .map_err(failure)? + .with_enrollment_store((*store(state, &target)?).clone()); + let (shutdown, receiver) = watch::channel(false); + let (status, updates) = watch::channel(ConnectorStatus::Connecting); + let mut connections = state.cloud_connections.lock().await; + if let Some(existing) = connections.get(&target) { + if matches!( + *existing.status.borrow(), + ConnectorStatus::Connecting + | ConnectorStatus::Connected + | ConnectorStatus::Reconnecting + ) { + return Err(failure("Disconnect the existing cloud connection first.")); + } + connections.remove(&target); + } + if connections.len() >= 16 { + return Err(failure("Desktop cloud connection limit reached.")); + } + let task = tauri::async_runtime::spawn(async move { + if connector.run(receiver, status.clone()).await.is_err() + && *status.borrow() != ConnectorStatus::Revoked + { + status.send_replace(ConnectorStatus::Disconnected); + } + }); + let result = dto(target.clone(), Some(&config), ConnectorStatus::Connecting); + connections.insert( + target, + CloudSession { + config, + status: updates, + shutdown, + task, + }, + ); + Ok(result) +} +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn cloud_enroll( + app: AppHandle, + state: State<'_, AppState>, + request: EnrollInput, +) -> Result { + let _operation = state.cloud_operation.lock().await; + let lease = state + .selected_target(&request.target_id) + .await + .ok_or_else(|| failure("Select the connected runtime before enrolling it."))?; + let runs=lease.target.client.connector_runs().ok_or_else(||failure("This runtime has no dedicated cloud grant. Enroll a dedicated application with the CLI first."))?; + let instance = lease + .target + .client + .instance_id() + .ok_or_else(|| failure("Runtime identity is unavailable."))? + .to_string(); + let url = url::Url::parse(&request.enrollment_url) + .map_err(|_| failure("Enter a valid enrollment URL."))?; + confirm(&app,format!("Connect the selected runtime ({instance}) to {}? Project members with execution permission can submit tasks under this runtime's dedicated local cloud grant. Approvals remain exact runtime interactions. Work in Managed Local stops when Desktop closes.",url.origin().ascii_serialization())).await?; + let store = store(&state, &request.target_id)?; + let config = store + .enroll( + request.enrollment_url, + request.token, + instance, + lease + .target + .client + .capabilities() + .iter() + .map(str::to_owned) + .collect(), + cfg!(debug_assertions), + ) + .await + .map_err(failure)?; + let (_, key) = tokio::task::spawn_blocking(move || store.load()) + .await + .map_err(|_| failure("Enrollment vault unavailable."))? + .map_err(failure)? + .ok_or_else(|| failure("Enrollment was not persisted."))?; + start(&state, request.target_id, config, key, runs).await +} +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn cloud_connect( + app: AppHandle, + state: State<'_, AppState>, + target_id: String, +) -> Result { + let _operation = state.cloud_operation.lock().await; + let lease = state + .selected_target(&target_id) + .await + .ok_or_else(|| failure("Select the connected runtime before connecting cloud."))?; + let runs = lease + .target + .client + .connector_runs() + .ok_or_else(|| failure("A dedicated cloud runtime grant is required."))?; + let store = store(&state, &target_id)?; + let (config, key) = tokio::task::spawn_blocking(move || store.load()) + .await + .map_err(|_| failure("Enrollment vault unavailable."))? + .map_err(failure)? + .ok_or_else(|| failure("Enroll this runtime first."))?; + if lease + .target + .client + .instance_id() + .map(|id| id.to_string()) + .as_deref() + != Some(&config.instance_id) + { + return Err(failure("The enrolled local runtime identity has changed.")); + } + confirm( + &app, + format!( + "Reconnect runtime {} to {}?", + config.instance_id, config.endpoint + ), + ) + .await?; + start(&state, target_id, config, key, runs).await +} +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn cloud_status( + state: State<'_, AppState>, + target_id: String, +) -> Result { + if let Some(session) = state.cloud_connections.lock().await.get(&target_id) { + return Ok(dto( + target_id.clone(), + Some(&session.config), + *session.status.borrow(), + )); + } + let store = store(&state, &target_id)?; + let config = tokio::task::spawn_blocking(move || store.load()) + .await + .map_err(|_| failure("Enrollment vault unavailable."))? + .map_err(failure)? + .map(|(config, _)| config); + Ok(dto( + target_id, + config.as_ref(), + if config.as_ref().is_some_and(|config| config.revoked) { + ConnectorStatus::Revoked + } else { + ConnectorStatus::Disconnected + }, + )) +} +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn cloud_revoke( + app: AppHandle, + state: State<'_, AppState>, + target_id: String, +) -> Result { + let _operation = state.cloud_operation.lock().await; + let enrollment = store(&state, &target_id)?; + let reader = enrollment.clone(); + let (config, _) = tokio::task::spawn_blocking(move || reader.load()) + .await + .map_err(|_| failure("Enrollment vault unavailable."))? + .map_err(failure)? + .ok_or_else(|| failure("This runtime is not enrolled."))?; + let message = format!( + "Revoke runtime {} in project {} at {}? This removes its cloud authority. Accepted tasks continue under local runtime policy.", + config.node_id, config.project_id, config.endpoint + ); + let accepted = tauri::async_runtime::spawn_blocking(move || { + app.dialog() + .message(message) + .title("Revoke cloud enrollment") + .buttons(MessageDialogButtons::OkCancelCustom( + "Revoke enrollment".into(), + "Cancel".into(), + )) + .blocking_show() + }) + .await + .map_err(|_| failure("Native revocation confirmation is unavailable."))?; + if !accepted { + return Err(failure("Cloud revocation was cancelled.")); + } + state.cloud_connections.lock().await.remove(&target_id); + enrollment.revoke().await.map_err(failure)?; + let config = tokio::task::spawn_blocking(move || enrollment.load()) + .await + .map_err(|_| failure("Enrollment vault unavailable."))? + .map_err(failure)? + .map(|(config, _)| config); + Ok(dto(target_id, config.as_ref(), ConnectorStatus::Revoked)) +} +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn cloud_disconnect( + state: State<'_, AppState>, + target_id: String, +) -> Result { + let _operation = state.cloud_operation.lock().await; + let session = state.cloud_connections.lock().await.remove(&target_id); + drop(session); + let enrollment = store(&state, &target_id)?; + let config = tokio::task::spawn_blocking(move || enrollment.load()) + .await + .map_err(|_| failure("Enrollment vault unavailable."))? + .map_err(failure)? + .map(|(config, _)| config); + let status = if config.as_ref().is_some_and(|config| config.revoked) { + ConnectorStatus::Revoked + } else { + ConnectorStatus::Disconnected + }; + Ok(dto(target_id, config.as_ref(), status)) +} +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn cloud_forget( + state: State<'_, AppState>, + target_id: String, +) -> Result { + let _operation = state.cloud_operation.lock().await; + state.cloud_connections.lock().await.remove(&target_id); + let store = store(&state, &target_id)?; + tokio::task::spawn_blocking(move || store.forget()) + .await + .map_err(|_| failure("Enrollment vault unavailable."))? + .map_err(failure)?; + Ok(dto(target_id, None, ConnectorStatus::Disconnected)) +} diff --git a/apps/desktop/src-tauri/src/desktop_credentials.rs b/apps/desktop/src-tauri/src/desktop_credentials.rs index aefa7bf89..c4debc46d 100644 --- a/apps/desktop/src-tauri/src/desktop_credentials.rs +++ b/apps/desktop/src-tauri/src/desktop_credentials.rs @@ -44,6 +44,10 @@ impl CredentialAvailability { } impl DesktopCredentials { + pub(crate) fn native_vault(&self) -> Arc { + self.vault.clone() + } + /// Store a validated PEM identity in the native vault under an opaque ID. pub(crate) async fn write_client_identity( self: &Arc, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 09086b145..c09e82b60 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,3 +1,7 @@ +mod cloud_connector; +use cloud_connector::{ + cloud_connect, cloud_disconnect, cloud_enroll, cloud_forget, cloud_revoke, cloud_status, +}; mod app_context; mod approval_adapter; mod browser; @@ -299,6 +303,12 @@ pub fn run() { set_terminal_enabled, connect_colossus, connection_status, + cloud_enroll, + cloud_connect, + cloud_status, + cloud_disconnect, + cloud_forget, + cloud_revoke, create_run, choose_run_attachment, read_artifact_content, diff --git a/apps/desktop/src-tauri/src/managed_runtime.rs b/apps/desktop/src-tauri/src/managed_runtime.rs index fd2c8e24a..deacef547 100644 --- a/apps/desktop/src-tauri/src/managed_runtime.rs +++ b/apps/desktop/src-tauri/src/managed_runtime.rs @@ -920,6 +920,7 @@ fn managed_bootstrap( .with_colossus_home(paths.colossus_home)? .with_risk_auto_approvals() .with_approval_broker_grant(approval_broker_grant)? + .with_connector_grant(connector_application_grant(resolved.access_profile)?)? .with_host_credentials(host_credentials)? .with_worker_ipc_authentication(Secret::new(worker_authentication.to_vec())?)?; #[cfg(debug_assertions)] @@ -1317,6 +1318,20 @@ fn managed_worker_endpoint( } fn application_grant(profile: AccessProfileSetting) -> Result { + application_grant_for(profile, APPLICATION_ID, false) +} + +fn connector_application_grant( + profile: AccessProfileSetting, +) -> Result { + application_grant_for(profile, "app:colossus-desktop-cloud", true) +} + +fn application_grant_for( + profile: AccessProfileSetting, + application_id: &str, + cloud: bool, +) -> Result { let scopes = PRIMARY_SCOPES .into_iter() .map(ApiScope::new) @@ -1330,7 +1345,22 @@ fn application_grant(profile: AccessProfileSetting) -> Result, _>>() + .map_err(|_| SdkError::InvalidConfiguration("invalid cloud scope"))? + } else { + scopes + }; + SidecarApplicationGrant::new(application_id, scopes, ["primary".to_owned()], tools) } fn approval_broker_grant() -> Result { diff --git a/apps/desktop/src-tauri/src/state.rs b/apps/desktop/src-tauri/src/state.rs index 812639bdc..b14315399 100644 --- a/apps/desktop/src-tauri/src/state.rs +++ b/apps/desktop/src-tauri/src/state.rs @@ -252,6 +252,8 @@ pub(crate) struct AppState { >, >, pub(crate) browser: crate::browser::BrowserManager, + pub(crate) cloud_connections: Mutex>, + pub(crate) cloud_operation: Mutex<()>, pub(crate) credential_vault: StdMutex>>, pub(crate) mcp_health_history: @@ -418,6 +420,8 @@ impl Default for AppState { let (selection_updates, _) = watch::channel(0); Self { browser: crate::browser::BrowserManager::default(), + cloud_connections: Mutex::new(HashMap::new()), + cloud_operation: Mutex::new(()), configuration_updates: Mutex::new(HashMap::new()), targets: RwLock::new(HashMap::new()), credential_vault: StdMutex::new(None), diff --git a/apps/desktop/src-tauri/src/terminal.rs b/apps/desktop/src-tauri/src/terminal.rs index e9f3a4163..d2a443de8 100644 --- a/apps/desktop/src-tauri/src/terminal.rs +++ b/apps/desktop/src-tauri/src/terminal.rs @@ -303,6 +303,7 @@ pub(crate) enum TerminalError { InvalidWorkspace, InvalidConfiguration, ProgramUnavailable, + #[cfg(any(target_os = "macos", target_os = "windows"))] ShellUnavailable, InvalidSize, InputTooLarge, @@ -324,6 +325,7 @@ impl TerminalError { Self::InvalidWorkspace => "invalid_workspace", Self::InvalidConfiguration => "invalid_configuration", Self::ProgramUnavailable => "program_unavailable", + #[cfg(any(target_os = "macos", target_os = "windows"))] Self::ShellUnavailable => "shell_unavailable", Self::InvalidSize | Self::InputTooLarge => "invalid_argument", Self::InputBackpressure => "terminal_backpressure", @@ -345,6 +347,7 @@ impl TerminalError { Self::InvalidWorkspace => "The selected local workspace is unavailable.", Self::InvalidConfiguration => "The managed Colossus configuration is unavailable.", Self::ProgramUnavailable => PROGRAM_UNAVAILABLE_MESSAGE, + #[cfg(any(target_os = "macos", target_os = "windows"))] Self::ShellUnavailable => { "The operating system shell is unavailable. Check that it is installed and accessible." } diff --git a/apps/desktop/src/components/CloudConnectionPane.tsx b/apps/desktop/src/components/CloudConnectionPane.tsx new file mode 100644 index 000000000..3f962f8fa --- /dev/null +++ b/apps/desktop/src/components/CloudConnectionPane.tsx @@ -0,0 +1,241 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { + IconCloud, + IconPlugConnected, + IconPlugConnectedX, + IconRefresh, + IconShieldLock, +} from "@tabler/icons-react"; +import "./cloud-connection.css"; +interface CloudStatus { + targetId: string; + status: + "connecting" | "connected" | "reconnecting" | "disconnected" | "revoked"; + nodeId: string | null; + projectId: string | null; + endpoint: string | null; +} +export function CloudConnectionPane({ targetId }: { targetId: string }) { + const [status, setStatus] = useState(null), + [url, setUrl] = useState(""), + [token, setToken] = useState(""), + [busy, setBusy] = useState(false), + [error, setError] = useState(""); + const operation = useRef(0), + busyRef = useRef(false); + useEffect(() => { + let alive = true, + refreshing = false; + const refresh = () => { + if (refreshing || busyRef.current) return; + refreshing = true; + const generation = operation.current; + void invoke("cloud_status", { targetId }) + .then((status) => { + if (alive && generation === operation.current) setStatus(status); + }) + .catch((error) => { + if (alive && generation === operation.current) + setError( + typeof error === "object" && error !== null && "message" in error + ? String(error.message) + : "Cloud enrollment is unavailable.", + ); + }) + .finally(() => { + refreshing = false; + }); + }; + refresh(); + const timer = setInterval(refresh, 2000); + return () => { + alive = false; + operation.current++; + clearInterval(timer); + }; + }, [targetId]); + async function action( + command: string, + parameters: Record = { targetId }, + ) { + if (busyRef.current) return; + busyRef.current = true; + const generation = ++operation.current; + setBusy(true); + setError(""); + // The native command owns this one-use value as soon as it is submitted. + if (command === "cloud_enroll") setToken(""); + try { + const next = await invoke(command, parameters); + if (operation.current === generation) setStatus(next); + } catch (error) { + if (operation.current === generation) + setError( + typeof error === "object" && error !== null && "message" in error + ? String(error.message) + : "The cloud connection could not be changed.", + ); + } finally { + busyRef.current = false; + if (operation.current === generation) setBusy(false); + } + } + const active = + status && + ["connected", "connecting", "reconnecting"].includes(status.status); + return ( +
+
+
+ +
+
+

Colossus Cloud

+

+ Make this runtime available to your project's cloud control plane. +

+
+
+ {error && ( +

+ {error} +

+ )} +
+ + + {status + ? status.status[0]?.toUpperCase() + status.status.slice(1) + : error + ? "Enrollment unavailable" + : "Checking enrollment…"} + + {status?.projectId && {status.projectId}} +
+ {!status && error && ( + + )} + {status?.nodeId ? ( + <> +
+
Cloud endpoint
+
{status.endpoint}
+
Node identity
+
{status.nodeId}
+
+
+ {active ? ( + + ) : ( + + )} + + +
+

+ {status.status === "revoked" + ? "Forget this enrollment, then use a new invitation to connect again. Accepted tasks remain under local runtime policy." + : "Disconnecting leaves accepted tasks running locally. Revocation removes this runtime's cloud authority until it is enrolled again."} +

+ + ) : ( +
{ + event.preventDefault(); + void action("cloud_enroll", { + request: { targetId, enrollmentUrl: url, token }, + }); + }} + > + + + + {error && ( + + )} +
+ )} + +
+ ); +} diff --git a/apps/desktop/src/components/ManagedSettingsPane.tsx b/apps/desktop/src/components/ManagedSettingsPane.tsx index 725b1314d..243d6c349 100644 --- a/apps/desktop/src/components/ManagedSettingsPane.tsx +++ b/apps/desktop/src/components/ManagedSettingsPane.tsx @@ -1,3 +1,4 @@ +import { CloudConnectionPane } from "./CloudConnectionPane"; import { ModelFeatureControl } from "./ModelFeatureControl"; import type { ModelFeatureMode, ManagedModelCapabilities } from "../types"; import { RememberedCommands } from "./RememberedCommands"; @@ -230,6 +231,7 @@ type GlobalTab = | "defaults" | DesktopTab; type SpaceTab = + | "cloud" | "runtime" | "providers" | "mcp" @@ -382,6 +384,7 @@ const GLOBAL_TABS: ReadonlyArray<{ const SPACE_TABS: ReadonlyArray<{ id: SpaceTab; label: string }> = [ { id: "runtime", label: "Runtime" }, + { id: "cloud", label: "Cloud" }, { id: "providers", label: "Providers" }, { id: "mcp", label: "MCP" }, { id: "plugins", label: "Plugins" }, @@ -3068,79 +3071,90 @@ export function ManagedSettingsPane({ ) : ( <> {selectedSpace ? ( - { - setExpandedAdvancedSections((current) => { - if (current.has(section) === open) return current; - const next = new Set(current); - if (open) next.add(section); - else next.delete(section); - return next; - }); - if ( - !open && - descriptors.find(({ id }) => id === focusedFieldId) - ?.section === section - ) { - setFocusedFieldId(null); + spaceTab === "cloud" ? ( + + ) : ( + { + setExpandedAdvancedSections((current) => { + if (current.has(section) === open) return current; + const next = new Set(current); + if (open) next.add(section); + else next.delete(section); + return next; + }); + if ( + !open && + descriptors.find(({ id }) => id === focusedFieldId) + ?.section === section + ) { + setFocusedFieldId(null); + } + }} + busy={busy} + mcpDiagnostics={mcpDiagnostics} + mcpOauthStatuses={mcpOauthStatuses} + mcpOauthLogins={mcpOauthLogins} + mcpOauthCallbacks={mcpOauthCallbacks} + onMcpOauthCallback={(server, value) => + setMcpOauthCallbacks((current) => ({ + ...current, + [server]: value, + })) } - }} - busy={busy} - mcpDiagnostics={mcpDiagnostics} - mcpOauthStatuses={mcpOauthStatuses} - mcpOauthLogins={mcpOauthLogins} - mcpOauthCallbacks={mcpOauthCallbacks} - onMcpOauthCallback={(server, value) => - setMcpOauthCallbacks((current) => ({ - ...current, - [server]: value, - })) - } - onTestMcp={testMcpServer} - onLoadMcpOAuthStatus={loadMcpOAuthStatus} - onLoginMcpOAuth={loginMcpOAuth} - onCompleteMcpOAuth={completeMcpOAuth} - onLogoutMcpOAuth={logoutMcpOAuth} - runtimeDiagnostics={runtimeDiagnostics} - testingRuntimeProfile={testingRuntimeProfile} - runtimeDiagnosticErrors={runtimeDiagnosticErrors} - onTestRuntimeProfile={testRuntimeProfile} - onTestSearchRole={testSearchRole} - onTestTelemetry={testTelemetry} - extensionInventory={extensionInventory} - extensionInventoryBusy={extensionInventoryBusy} - onRefreshExtensionInventory={() => void loadExtensionInventory()} - /> + onTestMcp={testMcpServer} + onLoadMcpOAuthStatus={loadMcpOAuthStatus} + onLoginMcpOAuth={loginMcpOAuth} + onCompleteMcpOAuth={completeMcpOAuth} + onLogoutMcpOAuth={logoutMcpOAuth} + runtimeDiagnostics={runtimeDiagnostics} + testingRuntimeProfile={testingRuntimeProfile} + runtimeDiagnosticErrors={runtimeDiagnosticErrors} + onTestRuntimeProfile={testRuntimeProfile} + onTestSearchRole={testSearchRole} + onTestTelemetry={testTelemetry} + extensionInventory={extensionInventory} + extensionInventoryBusy={extensionInventoryBusy} + onRefreshExtensionInventory={() => + void loadExtensionInventory() + } + /> + ) ) : ( } title="No Workspace selected" /> )} - - selectedSpace && setSpace(spaceDraft(selectedSpace)) - } - onApply={() => - void (spaceDirty ? saveSpace() : applyPendingRevision()) - } - /> + {spaceTab !== "cloud" && ( + + selectedSpace && setSpace(spaceDraft(selectedSpace)) + } + onApply={() => + void (spaceDirty ? saveSpace() : applyPendingRevision()) + } + /> + )} {importProposal ? ( span:last-child:not(.cloud-state-dot) { + margin-left: auto; + color: var(--muted); +} +.cloud-state-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--faint); +} +.cloud-state-dot.connected { + background: var(--green); +} +.cloud-settings dl { + display: grid; + grid-template-columns: 130px minmax(0, 1fr); + gap: 14px; + margin: 22px 0; + font-size: 12px; +} +.cloud-settings dt { + color: var(--muted); +} +.cloud-settings dd { + margin: 0; + overflow-wrap: anywhere; + font-family: var(--font-mono); + font-size: 11px; +} +.cloud-settings-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.cloud-settings-note { + font-size: 11px; + line-height: 1.7; + color: var(--muted); + margin: 18px 0; +} +.cloud-settings form { + display: grid; + gap: 18px; + margin: 24px 0; +} +.cloud-settings label { + display: grid; + gap: 8px; + font-size: 12px; +} +.cloud-settings input { + background: var(--surface-control); + border: 1px solid var(--border); + border-radius: 7px; + color: var(--text); + padding: 11px 12px; + width: 100%; + font: inherit; +} +.cloud-settings input:focus-visible { + outline: 2px solid var(--blue); + outline-offset: 2px; +} +.cloud-settings form button { + justify-self: start; +} +.cloud-settings aside { + display: flex; + gap: 11px; + padding: 17px 18px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface-muted); + color: var(--muted); + margin-top: 26px; +} +.cloud-settings aside svg { + flex-shrink: 0; + margin-top: 2px; +} +.cloud-settings aside p { + font-size: 11px; + line-height: 1.8; + margin: 0; +} +.cloud-settings-error { + color: var(--red-text); + border: 1px solid var(--red-border); + background: var(--red-soft); + padding: 13px; + border-radius: 8px; + font-size: 12px; +} diff --git a/apps/desktop/src/dev/cloud-connection-preview.tsx b/apps/desktop/src/dev/cloud-connection-preview.tsx new file mode 100644 index 000000000..942a8e689 --- /dev/null +++ b/apps/desktop/src/dev/cloud-connection-preview.tsx @@ -0,0 +1,59 @@ +import { CloudConnectionPane } from "../components/CloudConnectionPane"; + +/** Visual fixture only: never replaces a native bridge or proves enrollment. */ +export default function CloudPreview() { + const host = window as unknown as { __TAURI_INTERNALS__?: unknown }; + const parameters = new URLSearchParams(location.search); + if ( + import.meta.env.DEV && + parameters.get("fixture") === "cloud" && + !host.__TAURI_INTERNALS__ + ) { + let enrolled = parameters.get("state") !== "new"; + let status = parameters.get("state") ?? "connected"; + const snapshot = () => ({ + targetId: "preview-workspace", + status, + nodeId: enrolled ? "01a10a7f40fb779191d88fe3e4f2f1b6" : null, + projectId: enrolled ? "Example project" : null, + endpoint: enrolled ? "https://cloud.example.com:443" : null, + }); + host.__TAURI_INTERNALS__ = { + invoke: async (command: string) => { + switch (command) { + case "cloud_status": + break; + case "cloud_enroll": + enrolled = true; + status = "connected"; + break; + case "cloud_connect": + status = "connected"; + break; + case "cloud_disconnect": + status = "disconnected"; + break; + case "cloud_revoke": + status = "revoked"; + break; + case "cloud_forget": + enrolled = false; + status = "disconnected"; + break; + default: + throw new Error("Unsupported visual fixture command"); + } + return snapshot(); + }, + }; + if (!enrolled) status = "disconnected"; + } + return ( +
+

+ Desktop visual preview · Native enrollment is tested separately. +

+ +
+ ); +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index a1504cce4..dba8dba5c 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -46,6 +46,19 @@ if ( ); }, ); +} else if ( + import.meta.env.DEV && + new URLSearchParams(window.location.search).get("fixture") === "cloud" +) { + void import("./dev/cloud-connection-preview").then( + ({ default: CloudPreview }) => { + createRoot(root).render( + + + , + ); + }, + ); } else if ( import.meta.env.DEV && new URLSearchParams(window.location.search).get("fixture") === "plugin-studio" diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/apps/web/.npmrc b/apps/web/.npmrc new file mode 100644 index 000000000..1c0a53b1a --- /dev/null +++ b/apps/web/.npmrc @@ -0,0 +1,2 @@ +install-links=true +legacy-peer-deps=true diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 000000000..aa3262381 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + Colossus Cloud + + +
+ + + diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json new file mode 100644 index 000000000..edd80b0fb --- /dev/null +++ b/apps/web/package-lock.json @@ -0,0 +1,1809 @@ +{ + "name": "@colossus/web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@colossus/web", + "version": "0.1.0", + "dependencies": { + "@colossus/ui": "file:../ui", + "@tabler/icons-react": "3.45.0", + "react": "19.2.7", + "react-dom": "19.2.7" + }, + "devDependencies": { + "@types/node": "26.1.1", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.3", + "prettier": "3.9.5", + "typescript": "7.0.2", + "vite": "8.1.5", + "vitest": "4.1.10" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@colossus/ui": { + "version": "0.1.0", + "resolved": "file:../ui", + "license": "Apache-2.0", + "dependencies": { + "@tabler/icons-react": "3.45.0" + }, + "peerDependencies": { + "react": "19.2.7", + "react-dom": "19.2.7" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tabler/icons": { + "version": "3.45.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.45.0.tgz", + "integrity": "sha512-jiATwV8+zGYLTZ7gMLGivCic+KtsMZXcDmufIG8umlLxoHhI6902hGYIEt0Oa9Y9SXblNzUlrisHm5jOFMxOQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.45.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.45.0.tgz", + "integrity": "sha512-t/AuKs7ALMDDTY+B9IvfZnlO0mbLlP/lJxP/HnGC49QkM8mCsTN38kYyxjXxgrb1+TeK53ioVBJqIV7n/eysXQ==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.45.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 000000000..4b2a09ddd --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,32 @@ +{ + "name": "@colossus/web", + "private": true, + "version": "0.1.0", + "type": "module", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "dev": "vite --host 127.0.0.1 --port 5180", + "build": "tsc --noEmit && vite build", + "test": "vitest run", + "check": "npm run build && npm test", + "format": "prettier --write ." + }, + "dependencies": { + "@colossus/ui": "file:../ui", + "@tabler/icons-react": "3.45.0", + "react": "19.2.7", + "react-dom": "19.2.7" + }, + "devDependencies": { + "@types/node": "26.1.1", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.3", + "prettier": "3.9.5", + "typescript": "7.0.2", + "vite": "8.1.5", + "vitest": "4.1.10" + } +} diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 000000000..a19b5c644 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 000000000..58d00181c --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,721 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ComposerInput, DropdownSelect } from "@colossus/ui"; +import { + IconActivity, + IconArrowRight, + IconCloud, + IconServer, + IconListDetails, + IconLogout, + IconPlus, + IconSearch, + IconShieldLock, + IconLoader2, + IconRefresh, + IconMoon, + IconSun, + IconSend2, +} from "@tabler/icons-react"; +import { + ApiFailure, + request, + projectPath, + taskTitle, + taskStatus, + statusLabel, + terminalStatuses, + type Membership, + type FleetNode, + type Task, +} from "./api"; +import { Fleet } from "./Fleet"; +import { TaskDetail } from "./TaskDetail"; +export function App() { + const [memberships, setMemberships] = useState(null), + [auth, setAuth] = useState<"loading" | "signed_out" | "ready">("loading"), + [project, setProject] = useState(""), + [surface, setSurface] = useState<"tasks" | "fleet">("tasks"), + [nodes, setNodes] = useState([]), + [tasks, setTasks] = useState([]), + [taskPages, setTaskPages] = useState(1), + [nodePages, setNodePages] = useState(1), + [selected, setSelected] = useState(null), + [error, setError] = useState(""), + [loading, setLoading] = useState(true), + [draft, setDraft] = useState(""), + [node, setNode] = useState(""), + [mode, setMode] = useState("execute"), + [role, setRole] = useState("primary"), + [busy, setBusy] = useState(false), + [query, setQuery] = useState(""), + [filter, setFilter] = useState("all"), + [light, setLight] = useState( + () => localStorage.getItem("colossus-cloud-theme") === "light", + ); + const composer = useRef(null), + attempt = useRef<{ signature: string; key: string } | null>(null); + const currentProject = useRef(project); + currentProject.current = project; + useEffect(() => { + document.documentElement.dataset.theme = light ? "light" : "dark"; + document.documentElement.dataset.palette = "neutral"; + localStorage.setItem("colossus-cloud-theme", light ? "light" : "dark"); + }, [light]); + useEffect(() => { + const abort = new AbortController(); + void request<{ memberships: Membership[] }>( + "/api/me", + undefined, + abort.signal, + ) + .then((response) => { + setMemberships(response.memberships); + setProject(response.memberships[0]?.project_id ?? ""); + setAuth("ready"); + }) + .catch((error) => { + if (!abort.signal.aborted) { + if (error instanceof ApiFailure && error.status === 403) + setAuth("signed_out"); + else { + setError( + error instanceof Error ? error.message : "Sign-in unavailable.", + ); + setAuth("signed_out"); + } + } + }); + return () => abort.abort(); + }, []); + const membership = memberships?.find( + (member) => member.project_id === project, + ), + permissions = membership?.permissions ?? []; + const refresh = useCallback( + async (signal?: AbortSignal) => { + if (!project) return; + async function pages( + kind: "tasks" | "nodes", + count: number, + id: (item: T) => string, + ) { + const items: T[] = []; + let after = ""; + for (let page = 0; page < count; page++) { + const response = await request>( + `${projectPath(project)}/${kind}?limit=100${after ? `&after=${encodeURIComponent(after)}` : ""}`, + undefined, + signal, + ); + const batch = response[kind] ?? []; + items.push(...batch); + if (batch.length < 100) break; + after = id(batch[batch.length - 1]!); + } + return items; + } + try { + const [fleet, work] = await Promise.all([ + pages("nodes", nodePages, (item) => item.node.node_id), + pages("tasks", taskPages, (item) => item.task_id), + ]); + if (signal?.aborted || currentProject.current !== project) return; + setNodes(fleet); + setTasks(work); + setLoading(false); + } catch (error) { + if (!signal?.aborted && currentProject.current === project) { + setLoading(false); + if (error instanceof ApiFailure && error.status === 403) + setAuth("signed_out"); + setError( + error instanceof Error + ? error.message + : "Control plane unavailable.", + ); + } + } + }, + [project, taskPages, nodePages], + ); + useEffect(() => { + if (auth !== "ready") return; + const abort = new AbortController(); + let refreshing = false; + const poll = async () => { + if (refreshing) return; + refreshing = true; + try { + await refresh(abort.signal); + } finally { + refreshing = false; + } + }; + void poll(); + const timer = setInterval(() => { + if (!document.hidden) void poll(); + }, 3000); + return () => { + abort.abort(); + clearInterval(timer); + }; + }, [refresh, auth]); + useEffect(() => { + setTasks([]); + setNodes([]); + setSelected(null); + setNode(""); + setLoading(true); + setTaskPages(1); + setNodePages(1); + }, [project]); + useEffect(() => { + if ( + !node || + !nodes.some((fleet) => fleet.node.node_id === node && !fleet.node.revoked) + ) { + setNode( + ( + nodes.find((fleet) => !fleet.node.revoked && fleet.presence?.ready) ?? + nodes.find((fleet) => !fleet.node.revoked) + )?.node.node_id ?? "", + ); + } + }, [nodes, node]); + const target = nodes.find((fleet) => fleet.node.node_id === node); + useEffect(() => { + if (target && !target.node.roles.includes(role)) + setRole(target.node.roles[0] ?? "primary"); + }, [target, role]); + async function create() { + if (!draft.trim() || !node || busy) return; + setBusy(true); + setError(""); + const signature = JSON.stringify({ draft, node, mode, role }); + if (attempt.current?.signature !== signature) + attempt.current = { signature, key: crypto.randomUUID() }; + try { + const response = await request<{ task: Task }>( + `${projectPath(project)}/tasks`, + { + node_id: node, + request: { + plugin_skill_ids: [], + input: [{ text: draft }], + session_id: null, + end_user_id: null, + role, + mode, + research_depth: null, + research_sources: [], + plan_action: null, + branch: null, + max_turns: 24, + idempotency_key: attempt.current.key, + }, + }, + ); + if (currentProject.current !== project) return; + setDraft(""); + attempt.current = null; + setSelected(response.task); + void refresh(); + } catch (error) { + if (currentProject.current === project) + setError( + error instanceof Error + ? error.message + : "Task submission failed. Retry to reconcile the same task.", + ); + } finally { + setBusy(false); + } + } + async function signOut() { + try { + await request("/auth/logout", {}); + setAuth("signed_out"); + setProject(""); + setMemberships(null); + setNodes([]); + setTasks([]); + setSelected(null); + } catch (error) { + setError(error instanceof Error ? error.message : "Sign-out failed."); + } + } + if (auth === "loading") + return ( +
+ + +

Connecting to Colossus Cloud…

+
+ ); + if (auth === "signed_out") + return ( +
+
+ ); + const live = nodes.filter( + (fleet) => fleet.presence?.ready && !fleet.node.revoked, + ).length, + active = tasks.filter( + (task) => !terminalStatuses.has(taskStatus(task)), + ).length; + const shown = tasks + .filter( + (task) => + (filter === "all" || + (filter === "active" + ? !terminalStatuses.has(taskStatus(task)) + : terminalStatuses.has(taskStatus(task)))) && + taskTitle(task).toLowerCase().includes(query.toLowerCase()), + ) + .sort((a, b) => + (b.snapshot?.run.created_at ?? b.task_id).localeCompare( + a.snapshot?.run.created_at ?? a.task_id, + ), + ); + return ( +
+ +
+
+
+ {project} + / + + {selected + ? "Task detail" + : surface === "fleet" + ? "Runtime fleet" + : "Tasks"} + +
+
+ + {live} runtime{live === 1 ? "" : "s"} online + +
+
+
+ {error && ( +
+ {error} + +
+ )} + {selected ? ( + fleet.node.node_id === selected.node_id) + ?.node.label ?? selected.node_id + } + onBack={() => setSelected(null)} + /> + ) : surface === "fleet" ? ( + void refresh()} + onError={setError} + hasMore={nodes.length === nodePages * 100} + onMore={() => setNodePages((pages) => pages + 1)} + /> + ) : ( + <> +
+
+
PROJECT WORKSPACE
+

Your agent work, in motion.

+

+ Start a task on any enrolled runtime and follow it through. +

+
+ +
+
+
+ + + Active tasks{active} + +
+
+ + + Runtimes online + + {live} + + {" "} + / {nodes.filter((fleet) => !fleet.node.revoked).length} + + + +
+
+ + + Project access + + {permissions.includes("administer") + ? "Administrator" + : permissions.includes("execute") + ? "Contributor" + : "Observer"} + + +
+
+
+ setDraft(event.target.value)} + disabled={busy || !node || !permissions.includes("execute")} + onKeyDown={(event) => { + if ( + (event.metaKey || event.ctrlKey) && + event.key === "Enter" + ) { + event.preventDefault(); + void create(); + } + }} + /> +
+ setNode(event.target.value)} + disabled={busy} + > + {nodes + .filter((fleet) => !fleet.node.revoked) + .map((fleet) => ( + + ))} + + setMode(event.target.value)} + disabled={busy} + > + + + + setRole(event.target.value)} + disabled={busy} + > + {(target?.node.roles ?? []).map((role) => ( + + ))} + + ⌘ / Ctrl ↵ + +
+
+
+
+
+ {["all", "active", "finished"].map((value) => ( + + ))} +
+
+ + setQuery(event.target.value)} + /> +
+
+ {loading ? ( +
+ +

Loading project tasks…

+
+ ) : shown.length === 0 ? ( +
+
+ +
+

+ {query || filter !== "all" + ? "No matching tasks" + : "Make room for your next idea"} +

+

+ {query || filter !== "all" + ? "Try another search or task filter." + : node + ? "Describe a task above. Your agent will handle it on the selected runtime." + : "Start by enrolling a runtime, then send your first task."} +

+ {!node && ( + + )} +
+ ) : ( +
+
+ TASK + RUNTIME + STATUS + CREATED + +
+ {shown.map((task) => ( + + ))} +
+ )} + {tasks.length === taskPages * 100 && ( + + )} +
+ + )} +
+
+ + Colossus runtime authority + + Cloud control plane · v1alpha1 +
+
+
+ ); +} diff --git a/apps/web/src/Fleet.tsx b/apps/web/src/Fleet.tsx new file mode 100644 index 000000000..a9802e269 --- /dev/null +++ b/apps/web/src/Fleet.tsx @@ -0,0 +1,371 @@ +import { useEffect, useRef, useState } from "react"; +import { + IconServer, + IconPlus, + IconShieldX, + IconCopy, + IconCheck, + IconX, + IconArrowUpRight, +} from "@tabler/icons-react"; +import { request, projectPath, type FleetNode, type Permission } from "./api"; +export function Fleet({ + nodes, + project, + permissions, + onRefresh, + onError, + hasMore, + onMore, +}: { + nodes: FleetNode[]; + project: string; + permissions: Permission[]; + onRefresh: () => void; + onError: (error: string) => void; + hasMore: boolean; + onMore: () => void; +}) { + const [enrolling, setEnrolling] = useState(false), + [label, setLabel] = useState(""), + [roles, setRoles] = useState("primary"), + [busy, setBusy] = useState(false), + [copied, setCopied] = useState(false), + [invitation, setInvitation] = useState<{ + token: string; + node_id: string; + enrollment_url: string; + expires_in: number; + } | null>(null); + const input = useRef(null), + dialog = useRef(null), + alive = useRef(true), + enrollmentAttempt = useRef(0); + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + }; + }, []); + useEffect(() => { + if (enrolling) { + dialog.current?.showModal(); + input.current?.focus(); + } else dialog.current?.close(); + }, [enrolling]); + function beginEnrollment() { + enrollmentAttempt.current++; + setInvitation(null); + setBusy(false); + setCopied(false); + setLabel(""); + setRoles("primary"); + setEnrolling(true); + } + function closeEnrollment() { + enrollmentAttempt.current++; + setEnrolling(false); + setInvitation(null); + setBusy(false); + } + async function invite() { + if (busy) return; + const attempt = ++enrollmentAttempt.current; + setBusy(true); + try { + const response = await request<{ + token: string; + node_id: string; + enrollment_url: string; + expires_in: number; + }>(`${projectPath(project)}/invitations`, { + label, + roles: roles + .split(",") + .map((role) => role.trim()) + .filter(Boolean), + }); + if (alive.current && attempt === enrollmentAttempt.current) { + setInvitation(response); + setCopied(false); + } + } catch (error) { + if (alive.current && attempt === enrollmentAttempt.current) + onError( + error instanceof Error + ? error.message + : "Enrollment could not be created.", + ); + } finally { + if (alive.current && attempt === enrollmentAttempt.current) + setBusy(false); + } + } + async function revoke(fleet: FleetNode) { + if ( + !confirm( + `Revoke ${fleet.node.label}? Its cloud connection will close. Accepted runs continue locally and remain assigned to this runtime.`, + ) + ) + return; + try { + await request( + `${projectPath(project)}/nodes/${fleet.node.node_id}/revoke`, + { revision: fleet.node.revision }, + ); + if (alive.current) onRefresh(); + } catch (error) { + if (alive.current) + onError(error instanceof Error ? error.message : "Revocation failed."); + } + } + return ( + <> +
+
+
EXECUTION INFRASTRUCTURE
+

Runtime fleet

+

+ Bring your machines. Keep execution under their local authority. +

+
+ +
+ {nodes.length === 0 ? ( +
+
+ +
+

Your first runtime starts here

+

+ Enroll a CLI daemon or a Desktop runtime to give this project a + place to execute tasks. +

+ +
+ ) : ( +
+ {nodes.map((fleet) => { + const status = fleet.node.revoked + ? "revoked" + : fleet.presence?.ready + ? "connected" + : fleet.presence + ? "starting" + : "offline"; + return ( +
+
+
+ +
+ + + {status[0]?.toUpperCase() + status.slice(1)} + +
+

{fleet.node.label}

+

+ {fleet.node.node_id} +

+
+
+
Local instance
+
+ {fleet.node.instance_id} +
+
Allowed roles
+
+ {fleet.node.roles.map((role) => ( + + {role} + + ))} +
+
Connection
+
Outbound · mutual TLS
+
+
+ + {fleet.presence?.capabilities.length ?? 0} runtime + capabilities + +
    + {fleet.presence?.capabilities.map((capability) => ( +
  • + {capability} +
  • + ))} +
+
+
+ + {status === "connected" + ? "Ready for tasks" + : status === "revoked" + ? "Enrollment revoked" + : "Tasks wait for reconnect"} + + +
+
+ ); + })} +
+ )} + {hasMore && ( + + )} + { + if (!dialog.current?.open) closeEnrollment(); + }} + className="enrollment-dialog" + > +
+
+ +
+ +
+

{invitation ? "Connect your runtime" : "Enroll a runtime"}

+

+ {invitation + ? "This invitation expires in ten minutes and can enroll one runtime." + : "Choose a name and an additional role ceiling for this project's execution target."} +

+ {invitation ? ( + <> + + + +
+

From Desktop

+

+ Open Workspace settings → Cloud. Paste the URL and invitation, + then confirm the native connection dialog. +

+

From the CLI

+ + colossus cloud enroll --enrollment-url URL --local-config + LOCAL.json + +

+ Paste the invitation when prompted. Start the persistent + connector with{" "} + colossus cloud run --local-config LOCAL.json. +

+
+ + + ) : ( +
{ + event.preventDefault(); + void invite(); + }} + > + + + +
+ )} +
+ + ); +} diff --git a/apps/web/src/Interactions.tsx b/apps/web/src/Interactions.tsx new file mode 100644 index 000000000..a60864587 --- /dev/null +++ b/apps/web/src/Interactions.tsx @@ -0,0 +1,262 @@ +import { useRef, useState } from "react"; +import { IconShieldCheck, IconMessageQuestion } from "@tabler/icons-react"; +import { + request, + projectPath, + awaitReceipt, + type CommandReceipt, + type Interaction, + type Permission, +} from "./api"; +export function Interactions({ + interactions, + project, + task, + permissions, + onUpdate, + onError, +}: { + interactions: Interaction[]; + project: string; + task: string; + permissions: Permission[]; + onUpdate: () => void; + onError: (message: string) => void; +}) { + const [busy, setBusy] = useState(null); + const [notice, setNotice] = useState(""); + const attempts = useRef( + new Map(), + ); + const [answers, setAnswers] = useState>({}); + async function respond(interaction: Interaction, response: unknown) { + const signature = JSON.stringify([ + interaction.interaction_id, + interaction.etag, + response, + ]); + let attempt = attempts.current.get(signature); + if (!attempt) { + attempt = { + mutation: crypto.randomUUID(), + idempotency: crypto.randomUUID(), + }; + attempts.current.set(signature, attempt); + } + setBusy(interaction.interaction_id); + setNotice(""); + try { + const path = `${projectPath(project)}/tasks/${task}`; + const { command } = await request<{ command: CommandReceipt }>( + `${path}/respond`, + { + mutation_id: attempt.mutation, + request: { + run_id: interaction.run_id, + interaction_id: interaction.interaction_id, + etag: interaction.etag, + idempotency_key: attempt.idempotency, + response, + }, + }, + ); + const settled = await awaitReceipt(path, command); + setNotice( + settled + ? "Response confirmed by the runtime." + : "Response queued. It will be delivered when the runtime reconnects; retrying uses this same response.", + ); + onUpdate(); + } catch (error) { + onError(error instanceof Error ? error.message : "Response failed."); + } finally { + setBusy(null); + } + } + return ( + <> + {notice && ( +

+ {notice} +

+ )} + {interactions + .filter((interaction) => interaction.status === "pending") + .map((interaction) => { + const approval = interaction.content.approval, + prompt = interaction.content.user_prompt; + const allowed = + interaction.respondable_by_caller && + permissions.includes(approval ? "approve" : "control"); + return ( +
+
+ {approval ? ( + + ) : ( + + )} +

+ {approval ? "Approval required" : "Your input is needed"} +

+
+ {approval ? ( + <> +

{approval.reason}

+ {approval.command_context && ( +
+

{approval.command_context.justification}

+
+
Executable
+
+ {approval.command_context.executable} +
+
Arguments
+
+
    + {approval.command_context.arguments.map( + (argument, index) => ( +
  1. + {JSON.stringify(argument)} +
  2. + ), + )} +
+
+
Working directory
+
+ + {approval.command_context.working_directory} + +
+
+ {approval.command_context.redacted && ( +

+ Credentials are redacted from these prepared command + details. +

+ )} +
+ )} +
+
Action
+
{approval.action}
+
Resource
+
{approval.resource}
+
Risk
+
{approval.risk ?? "Unspecified"}
+
+
+ + +
+ + ) : prompt ? ( + <> +

{prompt.question}

+
+ {prompt.choices.map((choice) => ( + + ))} +
+ {prompt.allow_free_form && ( +
{ + event.preventDefault(); + void respond(interaction, { + prompt: { + free_form: + answers[interaction.interaction_id] ?? "", + }, + }); + }} + > + + + setAnswers({ + ...answers, + [interaction.interaction_id]: event.target.value, + }) + } + maxLength={65536} + disabled={!allowed || busy !== null} + placeholder="Write your answer…" + /> + +
+ )} + + ) : null} + {!allowed && ( +

+ Your project permissions or local runtime grant do not allow + this response. +

+ )} +
+ ); + })} + + ); +} diff --git a/apps/web/src/TaskDetail.tsx b/apps/web/src/TaskDetail.tsx new file mode 100644 index 000000000..94030f630 --- /dev/null +++ b/apps/web/src/TaskDetail.tsx @@ -0,0 +1,235 @@ +import { useEffect, useRef, useState } from "react"; +import { + IconArrowLeft, + IconPlayerStop, + IconTerminal2, + IconLoader2, +} from "@tabler/icons-react"; +import { + request, + awaitReceipt, + projectPath, + taskTitle, + taskStatus, + statusLabel, + terminalStatuses, + visibleOutput, + type Task, + type Update, + type Permission, + type CommandReceipt, +} from "./api"; +import { Interactions } from "./Interactions"; +export function TaskDetail({ + initial, + project, + permissions, + nodeLabel, + onBack, +}: { + initial: Task; + project: string; + permissions: Permission[]; + nodeLabel: string; + onBack: () => void; +}) { + const [task, setTask] = useState(initial), + [updates, setUpdates] = useState([]), + [error, setError] = useState(""), + [connected, setConnected] = useState(false), + [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(""); + const path = `${projectPath(project)}/tasks/${initial.task_id}`; + const cancellation = useRef(crypto.randomUUID()); + async function refresh() { + try { + const response = await request<{ task: Task }>(path); + setTask(response.task); + } catch (error) { + setError(error instanceof Error ? error.message : "Task refresh failed."); + } + } + useEffect(() => { + const abort = new AbortController(); + let alive = true; + const refresh = () => + void request<{ task: Task }>(path, undefined, abort.signal) + .then((response) => { + if (alive) setTask(response.task); + }) + .catch((error) => { + if (alive) + setError( + error instanceof Error ? error.message : "Task refresh failed.", + ); + }); + refresh(); + const timer = setInterval(refresh, 2000); + return () => { + alive = false; + abort.abort(); + clearInterval(timer); + }; + }, [path]); + useEffect(() => { + const source = new EventSource(`${path}/events?after=0`); + source.onopen = () => setConnected(true); + source.onerror = () => setConnected(false); + source.addEventListener("run_update", (event) => { + try { + const update = JSON.parse( + (event as MessageEvent).data, + ) as Update; + setUpdates((current) => + current.some((existing) => existing.sequence === update.sequence) + ? current + : current.concat(update).sort((a, b) => a.sequence - b.sequence), + ); + } catch { + setError("The event stream returned an invalid update."); + source.close(); + } + }); + return () => source.close(); + }, [path]); + const status = taskStatus(task), + finished = terminalStatuses.has(status), + output = visibleOutput(updates); + async function cancel() { + setBusy(true); + setError(""); + setNotice(""); + try { + const { command } = await request<{ command: CommandReceipt }>( + `${path}/cancel`, + { mutation_id: cancellation.current }, + ); + const settled = await awaitReceipt(path, command); + setNotice( + settled + ? "Cancellation confirmed by the runtime." + : "Cancellation queued. It will be delivered when the runtime reconnects; retrying uses the same request.", + ); + await refresh(); + } catch (error) { + setError(error instanceof Error ? error.message : "Cancellation failed."); + } finally { + setBusy(false); + } + } + return ( +
+ +
+
+
+ {nodeLabel} / {task.request.mode} +
+

{taskTitle(task)}

+
+ {statusLabel(status)} +
+
+ + + {connected ? "Live event stream" : "Reconnecting event stream"} + + {task.request.role} + Task {task.task_id.slice(0, 8)} + +
+ {error && ( +
+ {error} + +
+ )} + {notice && ( +

+ {notice} +

+ )} + {task.dispatch_error && ( +
+ {task.dispatch_error.message} +
+ )} + {task.output_limited && ( +
+ This task reached the cloud detail limit. Its status and controls + remain available. Earlier output is retained here; open the run + locally for complete output or any interactions too large to display + here. +
+ )} + void refresh()} + onError={setError} + /> +
+
+ +

Run output

+ {updates.length} events +
+ {output ? ( +
{output}
+ ) : ( +
+ {finished ? ( + + ) : ( + + )} +

+ {task.run_id + ? finished + ? "No text output was released" + : "The runtime is working" + : "Waiting for the runtime"} +

+

+ {task.run_id + ? "Released output will appear here as the task progresses." + : "This task stays assigned to its selected runtime and starts when the connection is ready."} +

+
+ )} +
+ + Event activity {updates.length} + +
    + {updates.map((update) => ( +
  1. + {update.sequence} + + {statusLabel(Object.keys(update.update)[0] ?? "update")} + + +
  2. + ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts new file mode 100644 index 000000000..4bf595d2a --- /dev/null +++ b/apps/web/src/api.test.ts @@ -0,0 +1,103 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ApiFailure, + awaitReceipt, + projectPath, + request, + taskStatus, + terminalStatuses, + visibleOutput, + type Task, +} from "./api"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); +describe("browser authority and released data", () => { + it("reports a runtime rejection even when HTTP accepted the queued action", async () => { + await expect( + awaitReceipt("/api/projects/p/tasks/t", { + command_id: "c", + reply: { + kind: "failed", + error: { code: "conflict", message: "The interaction changed." }, + }, + }), + ).rejects.toThrow("The interaction changed."); + }); + it("waits for the exact receipt without submitting a second mutation", async () => { + vi.useFakeTimers(); + const fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + command: { command_id: "c", reply: { kind: "responded" } }, + }), + ), + ); + vi.stubGlobal("fetch", fetch); + const settled = awaitReceipt("/api/projects/p/tasks/t", { + command_id: "c", + reply: null, + }); + await vi.advanceTimersByTimeAsync(300); + expect(await settled).toBe(true); + expect(fetch.mock.calls).toHaveLength(1); + expect(fetch.mock.calls[0]?.[0]).toBe("/api/projects/p/tasks/t/commands/c"); + expect(fetch.mock.calls[0]?.[1].method).toBe("GET"); + }); + it("uses same-origin cookies and explicit CSRF protection for mutations", async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response("{}", { status: 200 })); + vi.stubGlobal("fetch", fetch); + await request("/api/projects/engineering/tasks", { node_id: "node-a" }); + expect(fetch).toHaveBeenCalledWith("/api/projects/engineering/tasks", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json", "X-Colossus-CSRF": "1" }, + body: '{"node_id":"node-a"}', + }); + expect(JSON.stringify(fetch.mock.calls)).not.toContain("Authorization"); + }); + it("preserves abort signals and treats permission failures as failures", async () => { + const abort = new AbortController(); + const fetch = vi + .fn() + .mockResolvedValue( + new Response('{"error":"permission_denied"}', { status: 403 }), + ); + vi.stubGlobal("fetch", fetch); + await expect( + request("/api/me", undefined, abort.signal), + ).rejects.toBeInstanceOf(ApiFailure); + expect(fetch.mock.calls[0]?.[1].signal).toBe(abort.signal); + expect(fetch.mock.calls[0]?.[1].method).toBe("GET"); + }); + it("never interpolates a project into a foreign URL or extra path", () => { + expect(projectPath("../foreign?x=1")).toBe( + "/api/projects/..%2Fforeign%3Fx%3D1", + ); + }); + it("keeps an unknown allocation explicit instead of suggesting a rerun", () => { + const task = { + dispatch_error: { code: "outcome_unknown", message: "reconcile" }, + snapshot: null, + } as Task; + expect(taskStatus(task)).toBe("outcome_unknown"); + expect(terminalStatuses.has(taskStatus(task))).toBe(true); + }); + it("renders only released output and replaces deltas with the authoritative result", () => { + const events = [ + { update: { output_delta: "hel" } }, + { update: { provider_private: "do not display", output_delta: "lo" } }, + { update: { result: { output: "hello, completed" } } }, + ].map((event, index) => ({ + ...event, + run_id: "run-a", + sequence: index + 1, + created_at: "2026-10-04T00:00:00Z", + })); + expect(visibleOutput(events)).toBe("hello, completed"); + }); +}); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts new file mode 100644 index 000000000..326fe26cb --- /dev/null +++ b/apps/web/src/api.ts @@ -0,0 +1,204 @@ +export type Permission = + "read" | "execute" | "control" | "approve" | "administer"; +export interface Membership { + subject: string; + project_id: string; + permissions: Permission[]; +} +export interface Node { + node_id: string; + project_id: string; + instance_id: string; + label: string; + certificate_sha256: string; + roles: string[]; + revoked: boolean; + revision: number; +} +export interface FleetNode { + node: Node; + presence: { + ready: boolean; + capabilities: string[]; + connection_id: string; + } | null; +} +export interface Interaction { + interaction_id: string; + run_id: string; + kind: "approval" | "user_prompt"; + status: string; + respondable_by_caller: boolean; + etag: string; + content: { + approval?: { + action: string; + resource: string; + reason: string; + risk: string | null; + request_hash: string; + command_context: { + justification: string; + executable: string; + arguments: string[]; + working_directory: string; + redacted: boolean; + } | null; + }; + user_prompt?: { + question: string; + choices: { choice_id: string; label: string }[]; + allow_free_form: boolean; + }; + }; +} +export interface Run { + run_id: string; + title: string; + status: string; + created_at: string; + updated_at: string; + role: string; + mode: string; + last_sequence: number; + terminal: unknown; +} +export interface Task { + task_id: string; + project_id: string; + node_id: string; + subject: string; + request: { input: { text: string }[]; mode: string; role: string }; + run_id: string | null; + snapshot: { run: Run; pending_interactions: Interaction[] } | null; + last_sequence: number; + revision: number; + dispatch_error?: { message: string; code: string } | null; + output_limited?: boolean; +} +export interface Update { + run_id: string; + sequence: number; + created_at: string; + update: Record; +} +export class ApiFailure extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + } +} +export interface CommandReceipt { + command_id: string; + reply: { kind: string; error?: { code: string; message: string } } | null; +} + +// A successful HTTP mutation acknowledges the queue. Only the runtime receipt +// confirms the operation; an offline runtime leaves it durably queued. +export async function awaitReceipt( + taskPath: string, + command: CommandReceipt, + signal?: AbortSignal, +): Promise { + const deadline = Date.now() + 10000; + for (;;) { + if (command.reply) { + if (command.reply.kind === "failed") + throw new Error( + command.reply.error?.message ?? "The runtime rejected this action.", + ); + return true; + } + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, 300)); + command = ( + await request<{ command: CommandReceipt }>( + `${taskPath}/commands/${encodeURIComponent(command.command_id)}`, + undefined, + signal, + ) + ).command; + } +} +const messages: Record = { + permission_denied: + "Your session expired or this project permission is unavailable.", + conflict: + "The state changed. Refresh and review the current task before retrying.", + storage: + "The control plane is temporarily unavailable. Your accepted work remains on its runtime.", + resource_exhausted: + "A resource limit was reached. Wait for active work to finish before retrying.", + invalid_argument: "Check the request fields and try again.", + not_found: "This resource is no longer available in the selected project.", +}; +export async function request( + path: string, + body?: unknown, + signal?: AbortSignal, +): Promise { + const response = await fetch(path, { + method: body === undefined ? "GET" : "POST", + credentials: "same-origin", + headers: + body === undefined + ? {} + : { "Content-Type": "application/json", "X-Colossus-CSRF": "1" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + ...(signal ? { signal } : {}), + }); + if (!response.ok) { + const error = (await response + .json() + .catch(() => ({ error: "storage" }))) as { error?: string }; + throw new ApiFailure( + response.status, + messages[error.error ?? "storage"] ?? + "The request could not be completed.", + ); + } + if (response.status === 204) return undefined as T; + return (await response.json()) as T; +} +export function projectPath(project: string) { + return `/api/projects/${encodeURIComponent(project)}`; +} +export function taskTitle(task: Task) { + return ( + task.snapshot?.run.title || + task.request.input.map((part) => part.text).join(" ") || + "Untitled task" + ); +} +export function taskStatus(task: Task) { + return task.dispatch_error?.code === "outcome_unknown" + ? "outcome_unknown" + : task.dispatch_error + ? "failed" + : task.snapshot?.run.status || "queued"; +} +export const terminalStatuses = new Set([ + "completed", + "failed", + "cancelled", + "interrupted", + "outcome_unknown", +]); +export function statusLabel(value: string) { + return value + .split("_") + .map((word) => word[0]?.toUpperCase() + word.slice(1)) + .join(" "); +} +export function visibleOutput(updates: Update[]): string { + let output = ""; + for (const event of updates) { + if (typeof event.update.output_delta === "string") + output += event.update.output_delta; + const result = event.update.result as { output?: string } | undefined; + if (result?.output) output = result.output; + } + return output; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 000000000..e48e904f2 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "@colossus/ui/styles/theme.css"; +import "@colossus/ui/styles/composer.css"; +import "@colossus/ui/styles/select.css"; +import "./style.css"; +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/apps/web/src/style.css b/apps/web/src/style.css new file mode 100644 index 000000000..a1be1d8c4 --- /dev/null +++ b/apps/web/src/style.css @@ -0,0 +1,1387 @@ +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--canvas); + color: var(--text); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; +} +button, +input, +textarea { + font: inherit; +} +button, +a { + -webkit-tap-highlight-color: transparent; +} +button { + border: 1px solid transparent; + background: var(--blue); + color: var(--text-on-accent); + border-radius: 8px; + min-height: 38px; + padding: 8px 14px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-weight: 600; + cursor: pointer; + transition: + background 0.15s, + border-color 0.15s; +} +button:hover:not(:disabled) { + filter: brightness(1.08); +} +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} +button.secondary { + background: var(--surface); + border-color: var(--border); + color: var(--text); +} +button.text-button, +button.icon-button { + background: transparent; + color: var(--muted); + padding: 7px; + min-height: 32px; + font-weight: 500; +} +button.text-button:hover, +button.icon-button:hover { + background: var(--surface-raised); + color: var(--text); +} +button.icon-button { + width: 34px; +} +button.danger { + color: var(--red); +} +button.small { + font-size: 12px; + min-height: 32px; + padding: 5px 10px; +} +.full-width { + width: 100%; +} +input, +textarea { + color: var(--text); + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: 7px; + padding: 10px 12px; + width: 100%; + min-width: 0; +} +input::placeholder, +textarea::placeholder { + color: var(--faint); +} +input:focus, +textarea:focus { + outline: 2px solid var(--blue); + outline-offset: 2px; +} +button:focus-visible, +a:focus-visible, +summary:focus-visible { + outline: 2px solid var(--blue); + outline-offset: 3px; +} +a { + color: inherit; + text-decoration: none; +} +h1, +h2, +h3, +p { + margin: 0; +} +h1 { + font-size: 30px; + letter-spacing: -1px; + line-height: 1.2; + font-weight: 600; + color: var(--text-strong); +} +h2 { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.3px; + color: var(--text-strong); +} +h3 { + font-size: 14px; + font-weight: 600; +} +.muted { + color: var(--muted); +} +.mono, +code { + font-family: var(--font-mono); + font-size: 12px; +} +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.eyebrow { + color: var(--muted); + font-size: 10px; + font-weight: 600; + letter-spacing: 1.25px; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); +} +.shell { + display: flex; + min-height: 100vh; +} +.rail { + width: 238px; + position: fixed; + inset: 0 auto 0 0; + background: var(--rail); + border-right: 1px solid var(--border); + padding: 28px 18px 16px; + display: flex; + flex-direction: column; + z-index: 10; +} +.brand { + display: flex; + align-items: center; + gap: 9px; + color: var(--text-strong); + padding: 0 8px; + white-space: nowrap; +} +.brand strong { + font-size: 20px; + font-weight: 650; + letter-spacing: -0.7px; +} +.brand > span { + font-size: 8px; + letter-spacing: 1.3px; + color: var(--muted); + border: 1px solid var(--border-strong); + padding: 2px 5px; + border-radius: 4px; + margin-left: 2px; +} +.project-picker { + margin: 40px 0 24px; + display: grid; + gap: 8px; + padding: 0 6px; +} +.project-picker .dropdown-select { + width: 100%; +} +.rail nav { + display: grid; + gap: 5px; +} +.rail nav button { + justify-content: flex-start; + background: transparent; + color: var(--muted); + padding: 10px 13px; + min-height: 42px; + font-weight: 500; +} +.rail nav button.nav-active { + background: var(--surface-raised); + color: var(--text-strong); + border-color: var(--border); +} +.nav-count { + margin-left: auto; + font-size: 11px; + color: var(--muted); +} +.rail-bottom { + margin-top: auto; +} +.authority-note { + display: flex; + gap: 10px; + padding: 20px 9px; + color: var(--muted); + font-size: 11px; + border-top: 1px solid var(--border); +} +.authority-note > svg { + flex-shrink: 0; + margin-top: 1px; +} +.authority-note strong { + font-size: 11px; + color: var(--text); + font-weight: 500; +} +.authority-note p { + margin-top: 4px; + line-height: 1.6; +} +.rail-footer { + display: flex; + justify-content: space-between; + border-top: 1px solid var(--border); + padding-top: 14px; +} +.workspace { + margin-left: 238px; + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} +.topbar { + height: 67px; + border-bottom: 1px solid var(--border); + padding: 0 42px; + display: flex; + align-items: center; + justify-content: space-between; + background: var(--canvas); + font-size: 12px; +} +.topbar strong { + font-weight: 500; +} +.slash { + margin: 0 16px; + color: var(--faint); +} +.connection-indicator { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 11px; +} +.dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--faint); + flex-shrink: 0; +} +.dot.live { + background: var(--green); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 10%, transparent); +} +.content { + padding: 44px 42px 58px; + max-width: 1450px; + width: 100%; + margin: 0 auto; + flex: 1; +} +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-bottom: 32px; +} +.section-heading .eyebrow { + margin-bottom: 12px; +} +.section-heading p { + color: var(--muted); + font-size: 13px; + margin-top: 10px; +} +.section-heading > button { + flex-shrink: 0; +} +.stats-strip { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + margin-bottom: 24px; + background: var(--surface-muted); +} +.stats-strip > div { + display: flex; + align-items: flex-start; + gap: 13px; + padding: 20px 24px; + border-right: 1px solid var(--border); + color: var(--muted); +} +.stats-strip > div:last-child { + border: 0; +} +.stats-strip svg { + margin-top: 4px; +} +.stats-strip span { + font-size: 11px; + display: grid; + gap: 4px; +} +.stats-strip strong { + font-size: 26px; + line-height: 1.3; + color: var(--text-strong); + font-weight: 500; +} +.stats-strip small { + font-size: 15px; + font-weight: 400; + color: var(--muted); +} +.stats-strip .stat-text { + font-size: 16px; + padding-top: 7px; +} +.new-task-panel { + border: 1px solid var(--border-strong); + border-radius: 12px; + background: var(--surface); + overflow: hidden; + margin-bottom: 38px; + box-shadow: var(--shadow); +} +.new-task-panel:focus-within { + border-color: var(--blue); +} +.new-task-panel .composer-input { + border: 0; + outline: none !important; + background: transparent; + padding: 22px 24px; + min-height: 100px; + font-size: 14px; + resize: none; +} +.composer-controls { + display: flex; + align-items: center; + gap: 8px; + border-top: 1px solid var(--border); + padding: 12px 16px; + background: var(--surface-muted); +} +.composer-controls .dropdown-select { + max-width: 230px; +} +.composer-controls .dropdown-select-trigger { + font-size: 11px; +} +.composer-hint { + margin-left: auto; + color: var(--faint); + font-size: 10px; + white-space: nowrap; +} +.composer-controls > button { + font-size: 12px; + min-height: 34px; + padding: 6px 11px; + flex-shrink: 0; +} +.tasks-panel { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-muted); + overflow: hidden; +} +.tasks-toolbar { + display: flex; + justify-content: space-between; + gap: 20px; + padding: 17px 20px; + border-bottom: 1px solid var(--border); +} +.filter-group { + display: flex; + gap: 4px; +} +.filter-group > button { + font-size: 12px; + min-height: 30px; + padding: 4px 11px; + background: transparent; + color: var(--muted); + font-weight: 500; +} +.filter-group > button.selected { + background: var(--surface-raised); + border-color: var(--border); + color: var(--text); +} +.search { + display: flex; + align-items: center; + gap: 8px; + color: var(--faint); + max-width: 200px; +} +.search input { + border: 0; + background: transparent; + padding: 5px; + font-size: 12px; +} +.search input:focus { + outline-offset: 0; +} +.table-labels, +.task-row { + display: grid; + grid-template-columns: + minmax(180px, 2.5fr) minmax(120px, 1.4fr) + 110px 90px 18px; + gap: 20px; + align-items: center; +} +.table-labels { + padding: 14px 22px; + color: var(--faint); + font-size: 9px; + letter-spacing: 1px; + border-bottom: 1px solid var(--border); +} +.task-row { + width: 100%; + background: transparent; + color: var(--text); + padding: 19px 22px; + border-radius: 0; + border-bottom: 1px solid var(--border); + text-align: left; + font-weight: 400; + font-size: 12px; +} +.task-row:last-child { + border: 0; +} +.task-row:hover:not(:disabled) { + background: var(--surface-raised); + filter: none; +} +.task-title { + min-width: 0; +} +.task-title strong { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; +} +.task-title > span { + font-size: 10px; + color: var(--faint); + display: block; + margin-top: 5px; +} +.runtime-name { + display: flex; + gap: 7px; + align-items: center; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--muted); +} +.task-row time { + font-size: 11px; + color: var(--muted); +} +.task-row > svg { + color: var(--faint); +} +.status { + display: inline-flex; + align-items: center; + gap: 6px; + justify-self: start; + border: 1px solid var(--border); + border-radius: 5px; + font-size: 10px; + padding: 3px 7px; + color: var(--muted); + background: var(--surface); + white-space: nowrap; +} +.status-connected, +.status-completed { + color: var(--green); + border-color: color-mix(in srgb, var(--green) 25%, var(--border)); + background: color-mix(in srgb, var(--green) 7%, var(--surface)); +} +.status-running, +.status-starting { + color: var(--blue); + border-color: color-mix(in srgb, var(--blue) 25%, var(--border)); +} +.status-waiting, +.status-outcome_unknown { + color: var(--amber); + border-color: color-mix(in srgb, var(--amber) 25%, var(--border)); +} +.status-failed, +.status-revoked { + color: var(--red); + border-color: color-mix(in srgb, var(--red) 25%, var(--border)); +} +.empty-state { + display: flex; + align-items: center; + flex-direction: column; + text-align: center; + padding: 64px 24px; + gap: 10px; +} +.empty-icon { + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + padding: 15px; + display: flex; + align-items: center; + margin-bottom: 8px; + color: var(--muted); +} +.empty-state h2 { + font-size: 17px; +} +.empty-state p { + max-width: 390px; + color: var(--muted); + font-size: 12px; + line-height: 1.8; +} +.empty-state button { + margin-top: 12px; + font-size: 12px; +} +.empty-state.compact { + padding: 35px; +} +.workspace-footer { + display: flex; + justify-content: space-between; + padding: 14px 42px; + border-top: 1px solid var(--border); + font-size: 9px; + letter-spacing: 0.25px; + color: var(--muted); +} +.workspace-footer span { + display: flex; + align-items: center; + gap: 6px; +} +.alert { + border: 1px solid color-mix(in srgb, var(--red) 30%, var(--border)); + background: color-mix(in srgb, var(--red) 7%, var(--surface)); + padding: 13px 16px; + color: var(--text); + border-radius: 8px; + font-size: 12px; + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 22px; + gap: 16px; +} +.alert button { + background: transparent; + color: var(--muted); + font-size: 19px; + padding: 0; + min-height: 20px; +} +.fleet-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 18px; +} +.node-card { + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-muted); + padding: 23px; +} +.node-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 21px; +} +.node-icon { + background: var(--surface-raised); + border: 1px solid var(--border); + border-radius: 9px; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); +} +.node-id { + font-size: 10px; + margin-top: 7px; + overflow: hidden; + text-overflow: ellipsis; +} +.node-divider { + height: 1px; + background: var(--border); + margin: 21px 0 17px; +} +dl { + display: grid; + grid-template-columns: 110px minmax(0, 1fr); + gap: 12px 10px; + font-size: 11px; + margin: 0; +} +dt { + color: var(--muted); +} +dd { + margin: 0; + color: var(--text); +} +.tag { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + padding: 2px 6px; + display: inline-block; + margin-right: 3px; + font-size: 10px; +} +.node-capabilities { + margin-top: 20px; + color: var(--muted); + font-size: 11px; +} +.node-capabilities summary { + cursor: pointer; +} +.node-capabilities ul { + padding: 0 0 0 15px; + max-height: 140px; + overflow: auto; +} +.node-capabilities li { + margin: 7px 0; + font-size: 10px; + overflow-wrap: anywhere; +} +.node-card footer { + display: flex; + align-items: center; + justify-content: space-between; + border-top: 1px solid var(--border); + margin-top: 18px; + padding-top: 12px; + font-size: 10px; +} +.enrollment-dialog { + color: var(--text); + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: 14px; + width: min(510px, calc(100vw - 32px)); + padding: 28px; + max-height: calc(100vh - 40px); + overflow: auto; + box-shadow: var(--shadow); +} +.enrollment-dialog::backdrop { + background: color-mix(in srgb, var(--canvas) 75%, transparent); + backdrop-filter: blur(5px); +} +.dialog-heading { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} +.enrollment-dialog h2 { + font-size: 22px; + margin-bottom: 8px; +} +.enrollment-dialog > p { + font-size: 12px; + margin-bottom: 22px; +} +.enrollment-dialog label { + font-size: 12px; + font-weight: 500; + display: grid; + gap: 7px; + margin-bottom: 18px; +} +.enrollment-dialog label small { + font-size: 11px; + font-weight: 400; + color: var(--muted); +} +.enrollment-dialog form > button { + margin-top: 5px; +} +.enrollment-instructions { + border: 1px solid var(--border); + border-radius: 8px; + padding: 17px; + margin: 20px 0; + background: var(--surface-muted); +} +.enrollment-instructions h3 { + font-size: 12px; + margin-bottom: 6px; +} +.enrollment-instructions p { + font-size: 11px; + color: var(--muted); + line-height: 1.8; + margin-bottom: 15px; +} +.enrollment-instructions p:last-child { + margin-bottom: 0; +} +.enrollment-instructions code { + font-size: 10px; + overflow-wrap: anywhere; +} +.detail .back { + padding-left: 0; + font-size: 12px; + margin-bottom: 24px; +} +.detail-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; +} +.detail-header h1 { + font-size: 27px; + max-width: 850px; + overflow-wrap: anywhere; + margin-top: 12px; +} +.detail-header .eyebrow { + letter-spacing: 0.5px; +} +.detail-header .status { + font-size: 11px; +} +.detail-meta { + display: flex; + align-items: center; + gap: 20px; + color: var(--muted); + font-size: 11px; + padding: 22px 0 30px; +} +.detail-meta > span:first-child { + display: flex; + align-items: center; + gap: 7px; +} +.detail-meta > button { + margin-left: auto; +} +.output-panel { + border: 1px solid var(--border); + border-radius: 11px; + background: var(--surface-muted); + overflow: hidden; +} +.panel-heading { + display: flex; + align-items: center; + gap: 9px; + border-bottom: 1px solid var(--border); + padding: 17px 21px; + color: var(--muted); +} +.panel-heading h2 { + font-size: 13px; +} +.panel-heading > span { + font-size: 10px; + margin-left: auto; +} +.run-output { + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.8; + white-space: pre-wrap; + overflow-wrap: anywhere; + padding: 30px; + margin: 0; + min-height: 240px; +} +.output-empty { + text-align: center; + padding: 75px 24px; + color: var(--muted); +} +.output-empty h3 { + margin: 14px 0 7px; + color: var(--text); +} +.output-empty p { + font-size: 12px; + max-width: 480px; + margin: auto; +} +.event-log { + border-top: 1px solid var(--border); + font-size: 11px; +} +.event-log summary { + padding: 15px 22px; + cursor: pointer; + color: var(--muted); +} +.event-log summary > span { + margin-left: 10px; + color: var(--faint); +} +.event-log ol { + list-style: none; + padding: 0 22px; + margin: 0 0 20px; + max-height: 260px; + overflow: auto; +} +.event-log li { + display: flex; + gap: 16px; + border-top: 1px solid var(--border); + padding: 11px 0; + color: var(--muted); +} +.event-log strong { + font-weight: 400; + color: var(--text); +} +.event-log time { + margin-left: auto; + font-size: 10px; +} +.interaction { + background: color-mix(in srgb, var(--amber) 4%, var(--surface)); + border: 1px solid color-mix(in srgb, var(--amber) 35%, var(--border)); + border-radius: 10px; + padding: 23px; + margin-bottom: 22px; +} +.interaction-heading { + display: flex; + align-items: center; + gap: 9px; + color: var(--amber); + margin-bottom: 13px; +} +.interaction p { + font-size: 12px; + line-height: 1.7; + margin-bottom: 17px; +} +.interaction dl { + margin: 18px 0; +} +.interaction dd { + overflow-wrap: anywhere; +} +.interaction .actions, +.choice-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.interaction form { + display: flex; + gap: 10px; + margin-top: 16px; +} +.interaction form button { + flex-shrink: 0; +} +.interaction .muted { + margin: 13px 0 0; + font-size: 11px; +} +.startup { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 18px; + min-height: 100vh; + color: var(--muted); + font-size: 12px; +} +.sign-in { + display: grid; + grid-template-columns: 1.1fr 1fr; + min-height: 100vh; + overflow: hidden; +} +.sign-in-art { + position: relative; + background: var(--rail); + border-right: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} +.cloud-mark { + display: flex; + align-items: center; + justify-content: center; + width: 140px; + height: 140px; + border: 1px solid var(--border-strong); + background: var(--surface); + border-radius: 28px; + z-index: 1; + color: var(--text-strong); + box-shadow: 0 0 90px color-mix(in srgb, var(--blue) 7%, transparent); +} +.orbit { + position: absolute; + border: 1px solid var(--border); + border-radius: 50%; + width: 300px; + height: 300px; +} +.orbit-two { + width: 520px; + height: 520px; +} +.orbit-three { + width: 750px; + height: 750px; + border-style: dashed; + opacity: 0.5; +} +.orbit-node { + position: absolute; + border: 1px solid var(--border-strong); + background: var(--surface); + border-radius: 13px; + width: 54px; + height: 54px; + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + z-index: 1; +} +.node-one { + transform: translate(-178px, -190px); +} +.node-two { + transform: translate(234px, -104px); +} +.node-three { + transform: translate(94px, 237px); +} +.sign-in-copy { + display: flex; + flex-direction: column; + justify-content: center; + padding: 80px; + max-width: 720px; +} +.sign-in-copy .brand { + padding: 0; + margin-bottom: 68px; +} +.sign-in-copy .eyebrow { + font-size: 9px; + margin-bottom: 16px; +} +.sign-in-copy h1 { + font-size: 46px; + letter-spacing: -2px; + line-height: 1.13; + margin-bottom: 22px; +} +.sign-in-copy > p:not(.eyebrow) { + font-size: 14px; + line-height: 1.8; + color: var(--muted); + max-width: 380px; +} +.primary-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + background: var(--blue); + color: var(--text-on-accent); + padding: 14px 18px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + max-width: 380px; + margin-top: 38px; +} +.sign-in-foot { + display: flex; + gap: 7px; + align-items: center; + color: var(--faint); + font-size: 10px; + margin-top: 20px; +} +.spin { + animation: spin 1.2s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +@media (prefers-reduced-motion: reduce) { + .spin { + animation: none; + } + button { + transition: none; + } +} +@media (min-width: 1600px) { + .content { + padding-top: 58px; + } +} +@media (max-width: 1150px) { + .rail { + width: 210px; + } + .workspace { + margin-left: 210px; + } + .content { + padding: 32px 28px; + } + .topbar { + padding: 0 28px; + } + .table-labels, + .task-row { + grid-template-columns: minmax(160px, 2fr) 130px 105px 18px; + gap: 14px; + } + .table-labels span:nth-child(4), + .task-row > time { + display: none; + } + .composer-hint { + display: none; + } + .composer-controls > button { + margin-left: auto; + } + .stats-strip > div { + padding: 18px; + } + .sign-in-copy { + padding: 45px; + } + .sign-in-copy h1 { + font-size: 40px; + } +} +@media (max-width: 800px) { + .rail { + width: 76px; + padding: 24px 10px; + } + .rail .brand { + padding: 0; + justify-content: center; + } + .rail .brand strong, + .rail .brand > span, + .project-picker, + .rail nav button:not(.nav-active) .nav-count, + .rail nav .nav-count, + .authority-note, + .rail-footer .text-button { + display: none; + } + .rail nav { + margin-top: 40px; + } + .rail nav button { + font-size: 0; + gap: 0; + justify-content: center; + padding: 12px; + } + .rail nav svg { + width: 21px; + height: 21px; + } + .rail-footer { + justify-content: center; + } + .workspace { + margin-left: 76px; + } + .topbar { + padding: 0 20px; + } + .content { + padding: 30px 20px; + } + .workspace-footer { + padding: 14px 20px; + } + .section-heading h1 { + font-size: 25px; + } + .section-heading > button { + font-size: 0; + padding: 10px; + } + .section-heading > button svg { + width: 18px; + height: 18px; + } + .stats-strip > div { + padding: 16px 12px; + gap: 8px; + } + .stats-strip > div > svg { + display: none; + } + .stats-strip span { + font-size: 10px; + } + .stats-strip strong { + font-size: 22px; + } + .stats-strip .stat-text { + font-size: 13px; + } + .composer-controls { + flex-wrap: wrap; + } + .composer-controls .dropdown-select:first-child { + max-width: none; + flex: 1 0 100%; + } + .composer-controls > button { + margin-left: auto; + } + .table-labels, + .task-row { + grid-template-columns: minmax(150px, 1fr) 100px 18px; + gap: 10px; + } + .table-labels > span:nth-child(2), + .task-row > .runtime-name { + display: none; + } + .connection-indicator { + font-size: 10px; + } + .sign-in { + grid-template-columns: 1fr; + } + .sign-in-art { + display: none; + } + .sign-in-copy { + margin: auto; + padding: 45px 26px; + max-width: 480px; + } + .sign-in-copy .brand { + margin-bottom: 60px; + } + .sign-in-copy h1 { + font-size: 42px; + } + .detail-header h1 { + font-size: 23px; + } + .detail-meta { + flex-wrap: wrap; + gap: 12px; + } + .detail-meta > button { + margin-left: 0; + } + .run-output { + padding: 22px; + } + .tasks-toolbar { + padding: 13px; + gap: 8px; + } + .filter-group { + gap: 1px; + } + .filter-group > button { + padding: 4px 8px; + font-size: 10px; + } + .search { + max-width: 140px; + } + .search input { + font-size: 10px; + } + .detail-header { + align-items: flex-start; + } + .fleet-grid { + grid-template-columns: 1fr; + } +} +@media (max-width: 450px) { + .rail { + position: static; + width: 100%; + height: 60px; + flex-direction: row; + padding: 10px 14px; + align-items: center; + border-right: 0; + border-bottom: 1px solid var(--border); + } + .rail .brand { + margin-right: 20px; + } + .rail nav { + display: flex; + margin: 0; + gap: 6px; + } + .rail nav button { + padding: 8px 14px; + min-height: 34px; + } + .rail-bottom { + margin: 0 0 0 auto; + } + .rail-footer { + border: 0; + padding: 0; + } + .shell { + flex-direction: column; + } + .workspace { + margin-left: 0; + } + .topbar { + height: 50px; + font-size: 10px; + } + .topbar .slash { + margin: 0 8px; + } + .connection-indicator { + font-size: 9px; + } + .content { + padding: 26px 16px; + } + .section-heading p { + font-size: 11px; + } + .section-heading h1 { + font-size: 24px; + } + .section-heading { + margin-bottom: 24px; + gap: 12px; + } + .stats-strip { + margin-bottom: 18px; + } + .stats-strip .stat-text { + font-size: 11px; + } + .new-task-panel .composer-input { + padding: 17px; + min-height: 90px; + } + .composer-controls { + padding: 10px; + } + .workspace-footer { + font-size: 8px; + padding: 12px 16px; + } + .task-row { + padding: 16px; + } + .table-labels { + padding: 13px 16px; + } + .tasks-toolbar { + flex-wrap: wrap; + } + .search { + max-width: none; + flex: 1 0 100%; + border-top: 1px solid var(--border); + padding-top: 8px; + } + .empty-state { + padding: 42px 18px; + } + .sign-in-copy h1 { + font-size: 38px; + } + .interaction form { + flex-direction: column; + } + .detail-header { + flex-direction: column; + } + .detail-meta { + padding-bottom: 22px; + } + .node-card { + padding: 20px; + } +} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 000000000..26337f93c --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true + }, + "include": ["src"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 000000000..4208125a2 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +export default defineConfig({ + plugins: [react()], + server: { + strictPort: true, + proxy: { + "/api": "http://127.0.0.1:8090", + "/auth": "http://127.0.0.1:8090", + "/health": "http://127.0.0.1:8090", + }, + }, + build: { target: "es2022" }, +}); diff --git a/crates/colossus-cli/Cargo.toml b/crates/colossus-cli/Cargo.toml index ae54d565c..aa0cb04bb 100644 --- a/crates/colossus-cli/Cargo.toml +++ b/crates/colossus-cli/Cargo.toml @@ -14,6 +14,8 @@ path = "src/main.rs" dictation = ["dep:colossus-native-dictation"] [dependencies] +colossus-connector.workspace = true +colossus-credentials.workspace = true colossus-native-dictation = { version = "=0.1.0", path = "../colossus-native-dictation", optional = true, features = ["download"] } agent-client-protocol.workspace = true shell-words.workspace = true diff --git a/crates/colossus-cli/src/commands.rs b/crates/colossus-cli/src/commands.rs index 9b0777666..819c5723c 100644 --- a/crates/colossus-cli/src/commands.rs +++ b/crates/colossus-cli/src/commands.rs @@ -2,6 +2,9 @@ use super::*; #[derive(Subcommand)] pub(super) enum Command { + /// Enroll and manage an outbound cloud connector with its dedicated local application grant. + #[command(subcommand)] + Cloud(colossus_connector::ConnectorCommand), /// Check or apply an install-aware stable Colossus update. Update(UpdateCommand), /// Create or inspect fresh YAML configuration. @@ -192,6 +195,10 @@ pub(super) struct WorkerCommand { /// Absolute current-user 0700 directory for public API discovery. #[arg(long, value_name = "ABS_OWNER_PRIVATE_DIR")] pub(super) public_api_dir: Option, + /// Explicit headless wrapping-key reference for sealed public API credentials. + /// The bearer itself is never supplied in environment or argv. + #[arg(long, requires = "public_api_dir")] + pub(super) public_api_vault_key_variable: Option, /// Enroll an application offline and write its bearer directly to a keyring. #[arg( long, diff --git a/crates/colossus-cli/src/entrypoint.rs b/crates/colossus-cli/src/entrypoint.rs index d5fc0b2bb..29a92d39f 100644 --- a/crates/colossus-cli/src/entrypoint.rs +++ b/crates/colossus-cli/src/entrypoint.rs @@ -69,6 +69,9 @@ pub(super) async fn runtime_main() -> Result<(), Box> { Err(error) => error.exit(), }; set_output_mode(cli.output); + if let Command::Cloud(command) = cli.command { + return colossus_connector::run_cli(command).await; + } let home = ColossusHome::resolve_and_ensure()?; if let Command::Update(update) = &cli.command { match update.command.as_ref() { @@ -235,20 +238,23 @@ pub(super) async fn runtime_main() -> Result<(), Box> { }) }) }); - let (server, public_environment) = - if let Some(directory) = worker.public_api_dir.as_deref() { - let environment = PublicApiEnvironment::open(directory, &OsCredentialStore)?; - let credentials = environment.credential_manager(&server); - let options = environment.host_options(&credentials)?; - let server = server.enable_public_api(options).await?; - eprintln!( - "public API discovery published in {}", - environment.directory().display() - ); - (server, Some(environment)) - } else { - (server, None) - }; + let (server, public_environment) = if let Some(directory) = + worker.public_api_dir.as_deref() + { + let store = + credential_store(directory, worker.public_api_vault_key_variable.as_deref())?; + let environment = PublicApiEnvironment::open(directory, store.as_ref())?; + let credentials = environment.credential_manager(&server); + let options = environment.host_options(&credentials)?; + let server = server.enable_public_api(options).await?; + eprintln!( + "public API discovery published in {}", + environment.directory().display() + ); + (server, Some(environment)) + } else { + (server, None) + }; eprintln!("worker listening on {}", server.endpoint()); let result = server.serve().await; drop(public_environment); @@ -290,7 +296,9 @@ pub(super) async fn runtime_main() -> Result<(), Box> { .public_api_dir .as_deref() .ok_or(PublicApiAdminError::InvalidDirectory)?; - let environment = PublicApiEnvironment::open(directory, &OsCredentialStore)?; + let store = + credential_store(directory, worker.public_api_vault_key_variable.as_deref())?; + let environment = PublicApiEnvironment::open(directory, store.as_ref())?; if let Some(application_id) = worker.enroll_application.as_deref() { let destination_service = worker .credential_keyring_service @@ -313,7 +321,7 @@ pub(super) async fn runtime_main() -> Result<(), Box> { let metadata = enroll_application( &environment, &server, - &OsCredentialStore, + store.as_ref(), EnrollmentRequest { application_id, scopes: &worker.scope, @@ -417,6 +425,7 @@ pub(super) async fn runtime_main() -> Result<(), Box> { Arc::clone(&runtime), &acp_workspace, acp_approvals.as_ref().ok_or("ACP approval bridge is unavailable")?.clone(), ).await?, + Command::Cloud(_) => unreachable!("handled before runtime construction"), Command::Update(_) => unreachable!("handled before runtime construction"), Command::Config(ConfigCommand { command: ConfigAction::Effective, diff --git a/crates/colossus-cli/src/public_api_admin.rs b/crates/colossus-cli/src/public_api_admin.rs index e95cd30eb..22b573ebd 100644 --- a/crates/colossus-cli/src/public_api_admin.rs +++ b/crates/colossus-cli/src/public_api_admin.rs @@ -1,19 +1,17 @@ use colossus_api::{ApiScope, ApplicationKind, scopes}; use colossus_grpc::ApplicationGrant; -#[cfg(unix)] use colossus_grpc::{TlsIdentity, TlsKeySeed}; use colossus_worker::{ PublicApiAuthenticationKey, PublicApiCredentialManager, PublicApiHostOptions, WorkerServer, }; -#[cfg(unix)] use fs4::fs_std::FileExt as _; use serde::Serialize; -#[cfg(unix)] use sha2::{Digest as _, Sha256}; use std::{ error::Error, fmt, fs::File, + net::{Ipv4Addr, SocketAddr}, path::{Path, PathBuf}, }; use uuid::Uuid; @@ -22,7 +20,6 @@ use zeroize::Zeroizing; #[cfg(unix)] use std::{ fs, - net::{Ipv4Addr, SocketAddr}, os::unix::fs::{DirBuilderExt as _, MetadataExt as _}, }; @@ -30,23 +27,15 @@ use std::{ const DIRECTORY_MODE: u32 = 0o700; #[cfg(unix)] const LOCK_FILE_MODE: u32 = 0o600; -#[cfg(unix)] const DESCRIPTOR_FILENAME: &str = "endpoint.json"; -#[cfg(unix)] const CERTIFICATE_FILENAME: &str = "certificate.pem"; -#[cfg(unix)] const LOCK_FILENAME: &str = ".public-api.lock"; -#[cfg(unix)] const KEYRING_SERVICE_PREFIX: &str = "dev.obscuritylabs.colossus.public-api"; const DESKTOP_EXTERNAL_KEYRING_SERVICE: &str = "com.obscuritylabs.colossus.desktop.external"; const DESKTOP_BOUND_ACCOUNT_REQUEST: &str = "auto"; -#[cfg(unix)] const AUTHENTICATION_ROOT_ACCOUNT: &str = "authentication-root-v1"; -#[cfg(unix)] const TLS_SEED_ACCOUNT: &str = "tls-seed-v1"; -#[cfg(unix)] const INSTANCE_SEED_ACCOUNT: &str = "instance-identity-seed-v1"; -#[cfg(unix)] const INSTANCE_ID_DOMAIN: &[u8] = b"colossus-public-api-instance-id-v1\0"; const KNOWN_SCOPES: [&str; 5] = [ @@ -65,13 +54,11 @@ const UNSUPPORTED_PUBLIC_TOOLS: [&str; 1] = ["agent.delegate"]; /// material. #[derive(Clone, Debug, Eq, PartialEq)] pub(super) enum PublicApiAdminError { - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] UnsupportedPlatform, InvalidDirectory, - #[cfg(unix)] DirectoryBusy, SecretStoreUnavailable, - #[cfg(unix)] SecretStoreValueInvalid, InvalidKeyringIdentifier, ReservedKeyringNamespace, @@ -106,17 +93,15 @@ pub(super) enum PublicApiAdminError { impl fmt::Display for PublicApiAdminError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let message = match self { - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] Self::UnsupportedPlatform => { "native owner-only public API administration is unsupported on this platform" } Self::InvalidDirectory => { "public API directory must be an absolute current-user 0700 directory" } - #[cfg(unix)] Self::DirectoryBusy => "public API directory is already owned by another process", Self::SecretStoreUnavailable => "OS credential store operation failed", - #[cfg(unix)] Self::SecretStoreValueInvalid => { "OS credential store contains invalid public API key material" } @@ -241,30 +226,132 @@ impl SecretStore for OsCredentialStore { } } +struct HeadlessSecretStore(colossus_credentials::EnvironmentKeyStore); +impl SecretStore for HeadlessSecretStore { + fn read( + &self, + service: &str, + account: &str, + ) -> Result>>, PublicApiAdminError> { + use colossus_credentials::PlatformKeyStore as _; + self.0 + .read(&colossus_connector::headless_credential_account( + service, account, + )) + .map_err(|_| PublicApiAdminError::SecretStoreUnavailable) + } + fn write( + &self, + service: &str, + account: &str, + secret: &[u8], + ) -> Result<(), PublicApiAdminError> { + use colossus_credentials::PlatformKeyStore as _; + self.0 + .write( + &colossus_connector::headless_credential_account(service, account), + secret, + ) + .map_err(|_| PublicApiAdminError::SecretStoreUnavailable) + } + fn delete(&self, service: &str, account: &str) -> Result<(), PublicApiAdminError> { + use colossus_credentials::PlatformKeyStore as _; + self.0 + .delete(&colossus_connector::headless_credential_account( + service, account, + )) + .map_err(|_| PublicApiAdminError::SecretStoreUnavailable) + } +} + +pub(super) fn credential_store( + directory: &Path, + variable: Option<&str>, +) -> Result, PublicApiAdminError> { + match variable { + None => Ok(Box::new(OsCredentialStore)), + Some(variable) => { + let root = colossus_home::ConfinedRoot::bind(directory) + .map_err(|_| PublicApiAdminError::InvalidDirectory)?; + let private = root + .prepare_directory(Path::new("headless-credentials")) + .map_err(|_| PublicApiAdminError::InvalidDirectory)?; + let root = colossus_home::ConfinedRoot::bind(private) + .map_err(|_| PublicApiAdminError::InvalidDirectory)?; + Ok(Box::new(HeadlessSecretStore( + colossus_credentials::EnvironmentKeyStore::new(root, variable.into()) + .map_err(|_| PublicApiAdminError::SecretStoreUnavailable)?, + ))) + } + } +} + /// Locked, canonical owner-private public API directory and its stable secret material. pub(super) struct PublicApiEnvironment { directory: PathBuf, namespace_service: String, authentication_root: Zeroizing<[u8; 32]>, - #[cfg(unix)] tls_seed: Zeroizing<[u8; 32]>, - #[cfg(unix)] instance_seed: Zeroizing<[u8; 32]>, _lease: File, #[cfg(unix)] directory_device: u64, #[cfg(unix)] directory_inode: u64, + #[cfg(windows)] + confined_root: colossus_home::ConfinedRoot, } impl PublicApiEnvironment { pub(super) fn open(path: &Path, store: &dyn SecretStore) -> Result { - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] { let _ = (path, store); Err(PublicApiAdminError::UnsupportedPlatform) } + #[cfg(windows)] + { + let confined_root = colossus_home::ConfinedRoot::bind(path) + .map_err(|_| PublicApiAdminError::InvalidDirectory)?; + let directory = confined_root.path().to_owned(); + let checked = confined_root + .open_file(Path::new(LOCK_FILENAME)) + .map_err(|_| PublicApiAdminError::InvalidDirectory)?; + if !checked + .file() + .try_lock_exclusive() + .map_err(|_| PublicApiAdminError::DirectoryBusy)? + { + return Err(PublicApiAdminError::DirectoryBusy); + } + checked + .revalidate(&confined_root) + .map_err(|_| PublicApiAdminError::InvalidDirectory)?; + let lease = checked + .file() + .try_clone() + .map_err(|_| PublicApiAdminError::InvalidDirectory)?; + let namespace_service = namespace_service(&directory); + let authentication_root = + load_or_create_exact_key(store, &namespace_service, AUTHENTICATION_ROOT_ACCOUNT)?; + let tls_seed = load_or_create_exact_key(store, &namespace_service, TLS_SEED_ACCOUNT)?; + let instance_seed = + load_or_create_exact_key(store, &namespace_service, INSTANCE_SEED_ACCOUNT)?; + confined_root + .revalidate() + .map_err(|_| PublicApiAdminError::InvalidDirectory)?; + Ok(Self { + directory, + namespace_service, + authentication_root, + tls_seed, + instance_seed, + _lease: lease, + confined_root, + }) + } + #[cfg(unix)] { let directory = prepare_owner_private_directory(path)?; @@ -302,12 +389,28 @@ impl PublicApiEnvironment { &self, credentials: &PublicApiCredentialManager, ) -> Result { - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] { let _ = credentials; Err(PublicApiAdminError::UnsupportedPlatform) } + #[cfg(windows)] + { + self.confined_root + .revalidate() + .map_err(|_| PublicApiAdminError::InvalidDirectory)?; + PublicApiHostOptions::new( + SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), + stable_instance_id(&self.instance_seed), + self.directory.join(DESCRIPTOR_FILENAME), + self.directory.join(CERTIFICATE_FILENAME), + self.tls_identity()?, + credentials, + ) + .map_err(|_| PublicApiAdminError::WorkerUnavailable) + } + #[cfg(unix)] { revalidate_exact_directory( @@ -329,12 +432,12 @@ impl PublicApiEnvironment { } fn public_identity(&self) -> Result<(Uuid, String), PublicApiAdminError> { - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] { Err(PublicApiAdminError::UnsupportedPlatform) } - #[cfg(unix)] + #[cfg(any(unix, windows))] { let tls = self.tls_identity()?; Ok(( @@ -344,7 +447,6 @@ impl PublicApiEnvironment { } } - #[cfg(unix)] fn tls_identity(&self) -> Result { TlsIdentity::from_seed(TlsKeySeed::new(*self.tls_seed)) .map_err(|_| PublicApiAdminError::WorkerUnavailable) @@ -870,7 +972,6 @@ fn normalize_scopes(scopes: &[String]) -> Result, PublicApiAdminEr Ok(scopes) } -#[cfg(unix)] fn load_or_create_exact_key( store: &dyn SecretStore, service: &str, @@ -893,7 +994,6 @@ fn load_or_create_exact_key( Ok(stored) } -#[cfg(unix)] fn exact_key(value: Zeroizing>) -> Result, PublicApiAdminError> { if value.len() != 32 { return Err(PublicApiAdminError::SecretStoreValueInvalid); @@ -903,13 +1003,11 @@ fn exact_key(value: Zeroizing>) -> Result, PublicApi Ok(key) } -#[cfg(unix)] fn namespace_service(directory: &Path) -> String { let digest = Sha256::digest(directory.as_os_str().as_encoded_bytes()); format!("{KEYRING_SERVICE_PREFIX}.{}", lowercase_hex(&digest)) } -#[cfg(unix)] fn stable_instance_id(seed: &[u8; 32]) -> Uuid { let mut hasher = Sha256::new(); hasher.update(INSTANCE_ID_DOMAIN); @@ -923,7 +1021,6 @@ fn stable_instance_id(seed: &[u8; 32]) -> Uuid { Uuid::from_bytes(bytes) } -#[cfg(unix)] fn lowercase_hex(bytes: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut encoded = String::with_capacity(bytes.len() * 2); diff --git a/crates/colossus-cli/src/worker_dispatch.rs b/crates/colossus-cli/src/worker_dispatch.rs index c1f3d05db..96e9c239d 100644 --- a/crates/colossus-cli/src/worker_dispatch.rs +++ b/crates/colossus-cli/src/worker_dispatch.rs @@ -1238,7 +1238,8 @@ pub(super) async fn dispatch_to_worker_if_active( | Command::Worker(_) | Command::Config(_) | Command::Codex(_) - | Command::SandboxHelper => Ok(false), + | Command::SandboxHelper + | Command::Cloud(_) => Ok(false), } } diff --git a/crates/colossus-cli/tests/bootstrap_install_smoke.rs b/crates/colossus-cli/tests/bootstrap_install_smoke.rs index 7c53de4b9..0c01948ae 100644 --- a/crates/colossus-cli/tests/bootstrap_install_smoke.rs +++ b/crates/colossus-cli/tests/bootstrap_install_smoke.rs @@ -56,6 +56,15 @@ impl Fixture { fs::create_dir(&stage).expect("package stage"); let packaged_binary = stage.join("colossus"); fs::copy(env!("CARGO_BIN_EXE_colossus"), &packaged_binary).expect("package binary"); + // Match release packaging: debug symbols otherwise exhaust the installer's + // expanded-archive bound as the runtime and its adapters grow. + assert!( + Command::new("strip") + .arg(&packaged_binary) + .status() + .expect("strip packaged fixture") + .success() + ); fs::set_permissions(&packaged_binary, fs::Permissions::from_mode(0o755)) .expect("binary permissions"); let tools = stage.join("tools"); diff --git a/crates/colossus-cli/tests/bundle_distribution_smoke.rs b/crates/colossus-cli/tests/bundle_distribution_smoke.rs index d240a1e80..805359df7 100644 --- a/crates/colossus-cli/tests/bundle_distribution_smoke.rs +++ b/crates/colossus-cli/tests/bundle_distribution_smoke.rs @@ -82,6 +82,16 @@ fn trusted_bundle_is_built_verified_installed_and_executed_without_network() { let artifact = artifact_path(&staged, target); fs::create_dir_all(artifact.parent().expect("artifact parent")).expect("artifact directory"); fs::copy(source_binary, &artifact).expect("stage binary"); + // Bundles ship stripped Unix release executables. Keep the real test-built + // program while removing debug sections that exceed the distribution bound. + #[cfg(unix)] + assert!( + Command::new("strip") + .arg(&artifact) + .status() + .expect("strip packaged fixture") + .success() + ); fs::write(staged.join("LICENSE"), b"Apache-2.0\n").expect("stage license"); fs::write( root.join("config.yaml"), diff --git a/crates/colossus-cli/tests/ci_contract.rs b/crates/colossus-cli/tests/ci_contract.rs index 92c311311..fcbbf7101 100644 --- a/crates/colossus-cli/tests/ci_contract.rs +++ b/crates/colossus-cli/tests/ci_contract.rs @@ -593,6 +593,7 @@ fn premerge_requires_an_authorized_label_and_representative_platforms() { let gate_needs = strings(field(job(jobs, "gate"), "needs"), "pre-merge gate needs"); for name in [ "linux-rust-integration", + "linux-desktop", "macos-desktop-acceptance", "macos-desktop-bundle", "windows-runtime", @@ -605,6 +606,7 @@ fn premerge_requires_an_authorized_label_and_representative_platforms() { } for name in [ "linux-rust-integration", + "linux-desktop", "macos-native", "macos-desktop-acceptance", "macos-desktop-bundle", diff --git a/crates/colossus-cli/tests/release_contract.rs b/crates/colossus-cli/tests/release_contract.rs index 45ab5cc05..1424239f2 100644 --- a/crates/colossus-cli/tests/release_contract.rs +++ b/crates/colossus-cli/tests/release_contract.rs @@ -664,8 +664,8 @@ fn temporary_lru_advisory_exception_is_exact_documented_and_consistent() { ); } assert!( - deny.contains("[advisories]\nignore = []"), - "cargo-deny must retain its fail-closed empty advisory ignore list" + !deny.contains(ADVISORY), + "cargo-deny must not ignore the informational lru advisory" ); for required in ["LruCache", "Tantivy PR #3034", "`lru` 0.18.2"] { assert!( @@ -677,6 +677,32 @@ fn temporary_lru_advisory_exception_is_exact_documented_and_consistent() { assert!(!lock.contains("name = \"lru\"\nversion = \"0.18.1\"")); } +#[test] +fn oidc_public_verification_exception_is_exact_and_scoped() { + const ADVISORY: &str = "RUSTSEC-2023-0071"; + let root = repository_root(); + let deny = fs::read_to_string(root.join("deny.toml")).unwrap(); + assert_eq!( + deny.matches("RUSTSEC-").count(), + 1, + "the policy permits only the reviewed RSA exception" + ); + assert!(deny.contains(ADVISORY)); + assert!(deny.contains("no application RSA private-key operations")); + for path in [ + "xtask/src/checks/surfaces.rs", + "release/verify-release-readiness.sh", + "docs/develop/adr/0006-cloud-control-plane.md", + ] { + assert!( + fs::read_to_string(root.join(path)) + .unwrap() + .contains(ADVISORY), + "{path} must document the exact exception" + ); + } +} + #[test] fn linux_profile_and_release_package_remain_hardened() { let template = fs::read_to_string(repository_root().join("release/colossus.apparmor.in")) diff --git a/crates/colossus-cloud-protocol/Cargo.toml b/crates/colossus-cloud-protocol/Cargo.toml new file mode 100644 index 000000000..cd88abe66 --- /dev/null +++ b/crates/colossus-cloud-protocol/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "colossus-cloud-protocol" +publish = false +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +colossus-sdk = { workspace = true, features = ["serialization"] } +prost.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tonic.workspace = true +tonic-prost.workspace = true + +[build-dependencies] +protoc-bin-vendored.workspace = true +tonic-prost-build.workspace = true + +[lints] +workspace = true diff --git a/crates/colossus-cloud-protocol/build.rs b/crates/colossus-cloud-protocol/build.rs new file mode 100644 index 000000000..5caaee82e --- /dev/null +++ b/crates/colossus-cloud-protocol/build.rs @@ -0,0 +1,12 @@ +//! Generate the independently versioned outbound connection contract. + +fn main() -> Result<(), Box> { + let proto = "proto/colossus/cloud/v1alpha1/connector.proto"; + println!("cargo:rerun-if-changed={proto}"); + let mut config = tonic_prost_build::Config::new(); + config.protoc_executable(protoc_bin_vendored::protoc_bin_path()?); + tonic_prost_build::configure() + .build_transport(false) + .compile_with_config(config, &[proto], &["proto"])?; + Ok(()) +} diff --git a/crates/colossus-cloud-protocol/proto/colossus/cloud/v1alpha1/connector.proto b/crates/colossus-cloud-protocol/proto/colossus/cloud/v1alpha1/connector.proto new file mode 100644 index 000000000..e29a5b3cf --- /dev/null +++ b/crates/colossus-cloud-protocol/proto/colossus/cloud/v1alpha1/connector.proto @@ -0,0 +1,97 @@ +syntax = "proto3"; +package colossus.cloud.v1alpha1; + +// Connect is initiated by the machine. Both directions are authenticated and +// bounded; stream loss never cancels a run or permits reassignment of a task. +service RuntimeConnection { + rpc Connect(stream RuntimeFrame) returns (stream ControlFrame); + // Existing client certificate authorizes exact fixed-node certificate rotation. + rpc RenewCertificate(CertificateRenewal) returns (RenewedCertificate); + // A native enrollment can reduce only its own authority using its pinned leaf. + rpc RevokeEnrollment(EnrollmentIdentity) returns (EnrollmentRevoked); +} + +message EnrollmentIdentity { + string project_id = 1; + string node_id = 2; + string instance_id = 3; +} +message EnrollmentRevoked {} + +message CertificateRenewal { + string project_id = 1; + string node_id = 2; + string instance_id = 3; + string renewal_id = 4; + string csr_pem = 5; +} +message RenewedCertificate { + string certificate_pem = 1; +} + +message RuntimeFrame { + oneof body { + RuntimeHello hello = 1; + CommandReceipt receipt = 2; + ReleasedRunEvent event = 3; + RuntimeHeartbeat heartbeat = 4; + RuntimeSnapshot snapshot = 5; + ReleasedOutputLimit output_limit = 6; + } +} +// The local SDK released an event larger than the cloud frame budget. The host +// retains its current exclusive cursor and continues snapshots/control only. +message ReleasedOutputLimit { + string task_id = 1; + string run_id = 2; + uint64 after_sequence = 3; +} +message RuntimeHello { + uint32 protocol_major = 1; + string node_id = 2; + string instance_id = 3; + repeated string capabilities = 4; + string project_id = 5; +} +message RuntimeSnapshot { + string task_id = 1; + bytes snapshot_json = 2; +} +message RuntimeHeartbeat { + bool ready = 1; +} +message CommandReceipt { + string command_id = 1; + string task_id = 2; + // Versioned, strict JSON CloudReply. No credentials or unreleased output. + bytes reply_json = 3; +} +message ReleasedRunEvent { + string task_id = 1; + string run_id = 2; + uint64 sequence = 3; + // Versioned, strict JSON of the SDK's policy-released RunUpdate. + bytes update_json = 4; +} +message ControlFrame { + oneof body { + ConnectionWelcome welcome = 1; + CloudCommand command = 2; + EventAcknowledgement acknowledgement = 3; + } +} +message ConnectionWelcome { + uint32 protocol_major = 1; + string connection_id = 2; +} +message CloudCommand { + string command_id = 1; + string task_id = 2; + // Versioned, closed Command enum, never an arbitrary RPC or executable. + bytes command_json = 3; + uint64 after_sequence = 4; +} +message EventAcknowledgement { + string task_id = 1; + uint64 sequence = 2; +} diff --git a/crates/colossus-cloud-protocol/src/lib.rs b/crates/colossus-cloud-protocol/src/lib.rs new file mode 100644 index 000000000..6565c5e3d --- /dev/null +++ b/crates/colossus-cloud-protocol/src/lib.rs @@ -0,0 +1,13 @@ +//! Independently versioned, bounded cloud connection messages and released DTOs. + +mod messages; +pub use messages::*; + +/// Generated Protobuf connection envelope. Authentication is outside the payload. +pub mod v1alpha1 { + #![allow(missing_docs)] + tonic::include_proto!("colossus.cloud.v1alpha1"); +} + +#[cfg(test)] +mod tests; diff --git a/crates/colossus-cloud-protocol/src/messages.rs b/crates/colossus-cloud-protocol/src/messages.rs new file mode 100644 index 000000000..af78c0316 --- /dev/null +++ b/crates/colossus-cloud-protocol/src/messages.rs @@ -0,0 +1,102 @@ +use colossus_sdk::{ + ApiError, CancelRunResponse, CreateRunRequest, GetRunResponse, RespondInteractionRequest, + RespondInteractionResponse, RunUpdate, +}; +use serde::{Deserialize, Serialize}; + +/// Supported major of the closed JSON and Protobuf connection contract. +pub const PROTOCOL_MAJOR: u32 = 1; +/// Maximum command, receipt, or released update payload. +pub const MAX_PAYLOAD_BYTES: usize = 2 * 1024 * 1024; +/// Maximum frames queued in either direction before applying backpressure. +pub const MAX_QUEUED_FRAMES: usize = 32; +/// Maximum concurrently watched tasks on one enrolled runtime. +pub const MAX_ACTIVE_TASKS: usize = 16; + +/// Closed runtime operation set. The server assigns all durable idempotency keys. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] +pub enum Command { + /// Create or reconcile one exact caller-bound request, then watch its run. + Create { + /// Complete SDK request beneath the dedicated local application grant. + request: Box, + }, + /// Resume observing the exact run already assigned to this node. + Watch { + /// Local runtime run identity. + run_id: String, + /// Observe state/control only after the host's released-output retention bound. + #[serde(default)] + snapshot_only: bool, + }, + /// Cooperatively cancel the assigned run, using a stable mutation identity. + Cancel { + /// Local runtime run identity. + run_id: String, + /// Cloud-assigned, caller-scoped idempotency key. + idempotency_key: colossus_sdk::IdempotencyKey, + }, + /// Answer an exact pending interaction beneath explicit response authority. + Respond { + /// Runtime-bound interaction, revision, response, and idempotency key. + request: Box, + }, +} + +/// Receipt for a command. Success is sent only after the local durable result exists. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum CloudReply { + /// Full caller-visible snapshot, including the exact run identity and revision. + Run { + /// Released local run state. + run: Box, + }, + /// Durable cooperative cancellation result. + Cancelled { + /// Released cancellation state. + response: CancelRunResponse, + }, + /// Durable one-use interaction response. + Responded { + /// Released response receipt. + response: RespondInteractionResponse, + }, + /// Sanitized SDK error; unknown outcomes require reconciliation. + Failed { + /// Released categorical error without transport/provider diagnostics. + error: ApiError, + }, +} + +/// Reject empty, oversized, malformed, or unknown-operation payloads before dispatch. +pub fn decode(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > MAX_PAYLOAD_BYTES { + return Err(PayloadError); + } + serde_json::from_slice(bytes).map_err(|_| PayloadError) +} + +/// Encode one bounded typed payload, never private worker frames or credentials. +pub fn encode(value: &T) -> Result, PayloadError> { + let bytes = serde_json::to_vec(value).map_err(|_| PayloadError)?; + if bytes.is_empty() || bytes.len() > MAX_PAYLOAD_BYTES { + return Err(PayloadError); + } + Ok(bytes) +} + +/// Verify both identities/cursors before accepting a released runtime update. +pub fn decode_update(bytes: &[u8], run_id: &str, sequence: u64) -> Result { + let update: RunUpdate = decode(bytes)?; + if sequence == 0 || update.run_id != run_id || update.sequence != sequence { + return Err(PayloadError); + } + Ok(update) +} + +/// A categorical error that intentionally does not include untrusted input bytes. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +#[error("cloud payload is invalid or exceeds its bound")] +pub struct PayloadError; diff --git a/crates/colossus-cloud-protocol/src/tests.rs b/crates/colossus-cloud-protocol/src/tests.rs new file mode 100644 index 000000000..840926811 --- /dev/null +++ b/crates/colossus-cloud-protocol/src/tests.rs @@ -0,0 +1,19 @@ +use super::*; + +#[test] +fn unknown_operations_and_fields_are_rejected() { + assert!(decode::(br#"{"operation":"shell","program":"sh"}"#).is_err()); + assert!( + decode::(br#"{"operation":"watch","run_id":"run-1","actor":"admin"}"#).is_err() + ); +} + +#[test] +fn empty_and_oversized_payloads_are_rejected_without_echoing_input() { + assert!(decode::(&[]).is_err()); + let oversized = vec![b'x'; MAX_PAYLOAD_BYTES + 1]; + assert_eq!( + decode::(&oversized).unwrap_err().to_string(), + "cloud payload is invalid or exceeds its bound" + ); +} diff --git a/crates/colossus-cloud-server/Cargo.toml b/crates/colossus-cloud-server/Cargo.toml new file mode 100644 index 000000000..305fb5b24 --- /dev/null +++ b/crates/colossus-cloud-server/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "colossus-cloud-server" +publish = false +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +axum.workspace = true +chrono.workspace = true +async-trait.workspace = true +colossus-cloud.workspace = true +colossus-cloud-protocol.workspace = true +colossus-ports.workspace = true +colossus-home.workspace = true +colossus-journal-redb.workspace = true +colossus-journal-postgres.workspace = true +colossus-sdk = { workspace = true, features = ["serialization"] } +futures.workspace = true +hex.workspace = true +openidconnect.workspace = true +rcgen = { workspace = true, features = ["x509-parser"] } +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tokio.workspace = true +tokio-rustls.workspace = true +rustls.workspace = true +tower-http = { version = "0.6.11", features = ["fs", "set-header"] } +tokio-stream = { workspace = true, features = ["net"] } +tonic = { workspace = true, features = ["transport", "tls-ring"] } +getrandom.workspace = true +time.workspace = true +url.workspace = true +uuid.workspace = true +zeroize.workspace = true + +[dev-dependencies] +colossus-contracts.workspace = true +tempfile.workspace = true +tower.workspace = true +colossus-connector.workspace = true +colossus-testkit.workspace = true +colossus-runtime.workspace = true +serde-saphyr.workspace = true +base64.workspace = true +p256.workspace = true +colossus-api-runtime.workspace = true +colossus-api.workspace = true +colossus-policy.workspace = true +colossus-credentials.workspace = true +colossus-sdk = { workspace = true, features = ["embedded", "serialization", "sidecar"] } +keyring.workspace = true + +[lints] +workspace = true diff --git a/crates/colossus-cloud-server/examples/init_local.rs b/crates/colossus-cloud-server/examples/init_local.rs new file mode 100644 index 000000000..56bb67f0e --- /dev/null +++ b/crates/colossus-cloud-server/examples/init_local.rs @@ -0,0 +1,91 @@ +//! Generate owner-private local TLS and OIDC configuration without production secrets. +use colossus_cloud::CloudPermission; +use colossus_cloud_server::config::{Config, Membership, OidcConfig, Storage}; +use colossus_home::ColossusHome; +use rcgen::{ + BasicConstraints, CertificateParams, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, + KeyUsagePurpose, +}; +use std::{ + collections::BTreeSet, + io::Write, + path::{Path, PathBuf}, +}; +fn main() -> Result<(), Box> { + let path = PathBuf::from( + std::env::args_os() + .nth(1) + .ok_or("usage: init_local ABSOLUTE_PRIVATE_DIR")?, + ); + let home = ColossusHome::ensure_at(path)?; + let root = home.confined_root(); + if root.path().join("cloud.json").exists() { + return Err("local cloud configuration already exists".into()); + } + let ca_key = KeyPair::generate()?; + let mut ca = CertificateParams::default(); + ca.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::CrlSign, + ]; + let ca_cert = ca.self_signed(&ca_key)?; + let issuer = Issuer::new(ca, ca_key); + let server_key = KeyPair::generate()?; + let mut server = CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])?; + server.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + let server_cert = server.signed_by(&server_key, &issuer)?; + for (name, bytes) in [ + ("ca.pem", ca_cert.pem()), + ("ca-key.pem", issuer.key().serialize_pem()), + ("server.pem", server_cert.pem()), + ("server-key.pem", server_key.serialize_pem()), + ] { + let file = root.open_file(Path::new(name))?; + file.file().write_all(bytes.as_bytes())?; + file.file().sync_all()?; + } + let config = Config { + http_bind: "127.0.0.1:8090".parse()?, + grpc_bind: "127.0.0.1:8443".parse()?, + public_origin: "http://127.0.0.1:5180".into(), + grpc_endpoint: "https://localhost:8443".into(), + ca_certificate: root.path().join("ca.pem"), + ca_key: root.path().join("ca-key.pem"), + server_certificate: root.path().join("server.pem"), + server_key: root.path().join("server-key.pem"), + oidc: OidcConfig { + issuer: "http://127.0.0.1:8180/realms/colossus".into(), + client_id: "colossus-cloud".into(), + client_secret_file: None, + }, + memberships: vec![Membership { + subject: "11111111-1111-4111-8111-111111111111".into(), + project_id: "local-project".into(), + permissions: BTreeSet::from([ + CloudPermission::Read, + CloudPermission::Execute, + CloudPermission::Control, + CloudPermission::Approve, + CloudPermission::Administer, + ]), + }], + web_root: std::env::current_dir()?.join("apps/web/dist"), + storage: Storage::Redb { + path: root.path().join("cloud.redb"), + key_variable: None, + }, + local_development: true, + signing_key_variable: None, + }; + let file = root.open_file(Path::new("cloud.json"))?; + serde_json::to_writer_pretty(file.file(), &config)?; + file.file().sync_all()?; + root.sync_directory()?; + println!( + "Local cloud configuration is ready at {}", + root.path().join("cloud.json").display() + ); + Ok(()) +} diff --git a/crates/colossus-cloud-server/examples/init_runtime.rs b/crates/colossus-cloud-server/examples/init_runtime.rs new file mode 100644 index 000000000..b533e9ecc --- /dev/null +++ b/crates/colossus-cloud-server/examples/init_runtime.rs @@ -0,0 +1,27 @@ +//! Create an isolated, credential-free Echo runtime for local cloud acceptance. +use colossus_home::ColossusHome; +use colossus_runtime::RuntimeConfig; +use std::{io::Write, path::Path}; + +fn main() -> Result<(), Box> { + let directory = std::env::args_os() + .nth(1) + .ok_or("usage: init_runtime ABSOLUTE_PRIVATE_DIR")?; + let home = ColossusHome::ensure_at(directory)?; + let root = home.confined_root(); + if root.path().join("config.yaml").exists() { + return Err("runtime configuration already exists".into()); + } + let workflows = root.prepare_directory(Path::new("workflows"))?; + root.prepare_directory(Path::new("api"))?; + let mut config = RuntimeConfig::offline_template(root.path().join("state.redb")); + config.workflows.repository = workflows.clone(); + config.workflows.user = workflows; + let yaml = serde_saphyr::to_string(&config)?; + let file = root.open_file(Path::new("config.yaml"))?; + file.file().write_all(yaml.as_bytes())?; + file.file().sync_all()?; + root.sync_directory()?; + println!("Isolated Echo runtime ready at {}", root.path().display()); + Ok(()) +} diff --git a/crates/colossus-cloud-server/src/auth.rs b/crates/colossus-cloud-server/src/auth.rs new file mode 100644 index 000000000..2663ec38c --- /dev/null +++ b/crates/colossus-cloud-server/src/auth.rs @@ -0,0 +1,297 @@ +use crate::config::Config; +use axum::http::{HeaderMap, HeaderValue}; +use colossus_cloud::{CloudCaller, CloudError, CloudResult}; +use openidconnect::{ + AccessTokenHash, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, + EndpointNotSet, EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, + PkceCodeVerifier, RedirectUrl, TokenResponse, + core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata}, +}; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashMap, + sync::Mutex, + time::{Duration, Instant}, +}; +use zeroize::Zeroizing; +#[cfg(test)] +mod tests; + +type Client = CoreClient< + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointMaybeSet, + EndpointMaybeSet, +>; +struct Flow { + verifier: PkceCodeVerifier, + nonce: Nonce, + expires: Instant, +} +struct Session { + subject: String, + expires: Instant, +} +pub(crate) struct Authentication { + client: Client, + client_secret: Option, + http: reqwest::Client, + config: Config, + flows: Mutex>, + sessions: Mutex>, +} +fn hash(value: &str) -> String { + hex::encode(Sha256::digest(value.as_bytes())) +} +fn cookie(headers: &HeaderMap, name: &str) -> Option { + headers + .get("cookie")? + .to_str() + .ok()? + .split(';') + .find_map(|part| { + part.trim() + .strip_prefix(&format!("{name}=")) + .map(str::to_owned) + }) +} +impl Authentication { + pub async fn new(config: Config) -> Result { + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(15)) + .build() + .map_err(|_| "OIDC HTTP configuration failed")?; + let metadata = CoreProviderMetadata::discover_async( + IssuerUrl::new(config.oidc.issuer.clone()).map_err(|_| "invalid issuer")?, + &http, + ) + .await + .map_err(|_| "OIDC discovery failed")?; + let secret = config + .oidc + .client_secret_file + .as_ref() + .map(|path| { + std::fs::read_to_string(path) + .map(Zeroizing::new) + .map_err(|_| "cannot load OIDC secret") + }) + .transpose()? + .map(|secret| ClientSecret::new(secret.trim().to_owned())); + let client = CoreClient::from_provider_metadata( + metadata, + ClientId::new(config.oidc.client_id.clone()), + secret.clone(), + ) + .set_redirect_uri( + RedirectUrl::new(format!( + "{}/auth/callback", + config.public_origin.trim_end_matches('/') + )) + .map_err(|_| "invalid callback")?, + ); + Ok(Self { + client, + client_secret: secret, + http, + config, + flows: Mutex::new(HashMap::new()), + sessions: Mutex::new(HashMap::new()), + }) + } + pub fn login(&self) -> CloudResult<(String, HeaderValue)> { + let (challenge, verifier) = PkceCodeChallenge::new_random_sha256(); + let (url, state, nonce) = self + .client + .authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .set_pkce_challenge(challenge) + .url(); + let mut flows = self.flows.lock().map_err(|_| CloudError::Storage)?; + flows.retain(|_, flow| flow.expires > Instant::now()); + if flows.len() >= 128 { + return Err(CloudError::ResourceExhausted); + } + flows.insert( + hash(state.secret()), + Flow { + verifier, + nonce, + expires: Instant::now() + Duration::from_secs(300), + }, + ); + let header = self.set_cookie("colossus_flow", state.secret(), 300)?; + Ok((url.to_string(), header)) + } + pub async fn callback( + &self, + headers: &HeaderMap, + state: &str, + code: String, + ) -> CloudResult { + if state.len() > 256 + || code.len() > 4096 + || cookie(headers, "colossus_flow").as_deref().map(hash) != Some(hash(state)) + { + return Err(CloudError::PermissionDenied); + } + let flow = self + .flows + .lock() + .map_err(|_| CloudError::Storage)? + .remove(&hash(state)) + .filter(|flow| flow.expires > Instant::now()) + .ok_or(CloudError::PermissionDenied)?; + let token = self + .client + .exchange_code(AuthorizationCode::new(code)) + .map_err(|_| CloudError::PermissionDenied)? + .set_pkce_verifier(flow.verifier) + .request_async(&self.http) + .await + .map_err(|_| CloudError::PermissionDenied)?; + let id = token.id_token().ok_or(CloudError::PermissionDenied)?; + // Refresh issuer-owned keys for each new session. An IdP key rotation + // must neither require a host restart nor keep a retired key trusted. + let metadata = CoreProviderMetadata::discover_async( + IssuerUrl::new(self.config.oidc.issuer.clone()) + .map_err(|_| CloudError::PermissionDenied)?, + &self.http, + ) + .await + .map_err(|_| CloudError::PermissionDenied)?; + let verification_client = CoreClient::from_provider_metadata( + metadata, + ClientId::new(self.config.oidc.client_id.clone()), + self.client_secret.clone(), + ); + let verifier = verification_client.id_token_verifier(); + let claims = id + .claims(&verifier, &flow.nonce) + .map_err(|_| CloudError::PermissionDenied)?; + if let Some(expected) = claims.access_token_hash() { + let actual = AccessTokenHash::from_token( + token.access_token(), + id.signing_alg().map_err(|_| CloudError::PermissionDenied)?, + id.signing_key(&verifier) + .map_err(|_| CloudError::PermissionDenied)?, + ) + .map_err(|_| CloudError::PermissionDenied)?; + if actual != *expected { + return Err(CloudError::PermissionDenied); + } + } + let subject = claims.subject().as_str().to_owned(); + if !self + .config + .memberships + .iter() + .any(|member| member.subject == subject) + { + return Err(CloudError::PermissionDenied); + } + let seconds = (claims.expiration() - chrono_now()) + .num_seconds() + .clamp(0, 8 * 60 * 60) as u64; + if seconds == 0 { + return Err(CloudError::PermissionDenied); + } + let session = Nonce::new_random().secret().to_owned(); + let mut sessions = self.sessions.lock().map_err(|_| CloudError::Storage)?; + sessions.retain(|_, session| session.expires > Instant::now()); + if sessions.len() >= 1024 { + return Err(CloudError::ResourceExhausted); + } + sessions.insert( + hash(&session), + Session { + subject, + expires: Instant::now() + Duration::from_secs(seconds), + }, + ); + self.set_cookie("colossus_session", &session, seconds) + } + pub fn caller( + &self, + headers: &HeaderMap, + project: &str, + mutation: bool, + ) -> CloudResult { + if mutation + && (headers.get("origin").and_then(|value| value.to_str().ok()) + != Some(self.config.public_origin.trim_end_matches('/')) + || headers + .get("x-colossus-csrf") + .and_then(|value| value.to_str().ok()) + != Some("1")) + { + return Err(CloudError::PermissionDenied); + } + let token = cookie(headers, "colossus_session").ok_or(CloudError::PermissionDenied)?; + let sessions = self.sessions.lock().map_err(|_| CloudError::Storage)?; + let session = sessions + .get(&hash(&token)) + .filter(|session| session.expires > Instant::now()) + .ok_or(CloudError::PermissionDenied)?; + let member = self + .config + .memberships + .iter() + .find(|member| member.subject == session.subject && member.project_id == project) + .ok_or(CloudError::PermissionDenied)?; + CloudCaller::new( + member.subject.clone(), + member.project_id.clone(), + member.permissions.clone(), + ) + } + pub fn memberships(&self, headers: &HeaderMap) -> CloudResult> { + let token = cookie(headers, "colossus_session").ok_or(CloudError::PermissionDenied)?; + let sessions = self.sessions.lock().map_err(|_| CloudError::Storage)?; + let session = sessions + .get(&hash(&token)) + .filter(|session| session.expires > Instant::now()) + .ok_or(CloudError::PermissionDenied)?; + Ok(self + .config + .memberships + .iter() + .filter(|member| member.subject == session.subject) + .cloned() + .collect()) + } + pub fn logout(&self, headers: &HeaderMap) -> CloudResult { + if headers.get("origin").and_then(|value| value.to_str().ok()) + != Some(self.config.public_origin.trim_end_matches('/')) + { + return Err(CloudError::PermissionDenied); + } + if let Some(token) = cookie(headers, "colossus_session") { + self.sessions + .lock() + .map_err(|_| CloudError::Storage)? + .remove(&hash(&token)); + } + self.set_cookie("colossus_session", "", 0) + } + fn set_cookie(&self, name: &str, value: &str, seconds: u64) -> CloudResult { + HeaderValue::from_str(&format!( + "{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={seconds}{}", + if self.config.local_development { + "" + } else { + "; Secure" + } + )) + .map_err(|_| CloudError::Storage) + } +} +fn chrono_now() -> chrono::DateTime { + chrono::Utc::now() +} diff --git a/crates/colossus-cloud-server/src/auth/tests.rs b/crates/colossus-cloud-server/src/auth/tests.rs new file mode 100644 index 000000000..c9e6ffd5e --- /dev/null +++ b/crates/colossus-cloud-server/src/auth/tests.rs @@ -0,0 +1,226 @@ +use super::*; +use crate::config::{Membership, OidcConfig, Storage}; +use axum::{ + Form, Json, Router, + extract::State, + routing::{get, post}, +}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use colossus_cloud::CloudPermission; +use p256::ecdsa::{Signature, SigningKey, signature::Signer as _}; +use serde_json::{Value, json}; +use std::{collections::BTreeSet, sync::Arc}; +mod transport; + +struct IssuerState { + issuer: String, + key: SigningKey, + nonce: Mutex, + challenge: Mutex, + invalid: Mutex<&'static str>, +} +async fn discovery(State(state): State>) -> Json { + Json( + json!({"issuer":state.issuer,"authorization_endpoint":format!("{}/authorize",state.issuer),"token_endpoint":format!("{}/token",state.issuer),"jwks_uri":format!("{}/jwks",state.issuer),"response_types_supported":["code"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["ES256"],"code_challenge_methods_supported":["S256"]}), + ) +} +async fn jwks(State(state): State>) -> Json { + let point = signing_key(&state).verifying_key().to_encoded_point(false); + Json( + json!({"keys":[{"kty":"EC","crv":"P-256","kid":"test","use":"sig","alg":"ES256","x":URL_SAFE_NO_PAD.encode(point.x().unwrap()),"y":URL_SAFE_NO_PAD.encode(point.y().unwrap())}]}), + ) +} +async fn token( + State(state): State>, + Form(form): Form>, +) -> Json { + assert_eq!( + URL_SAFE_NO_PAD.encode(Sha256::digest(form["code_verifier"].as_bytes())), + *state.challenge.lock().unwrap() + ); + assert_eq!(form["grant_type"], "authorization_code"); + let invalid = *state.invalid.lock().unwrap(); + let now = crate::http::now(); + let mut claims = json!({"iss":state.issuer,"sub":"alice","aud":"colossus","exp":now+300,"iat":now,"nonce":state.nonce.lock().unwrap().clone(),"at_hash":URL_SAFE_NO_PAD.encode(&Sha256::digest(b"access")[..16])}); + match invalid { + "nonce" => claims["nonce"] = json!("wrong"), + "audience" => claims["aud"] = json!("other-client"), + "issuer" => claims["iss"] = json!("https://other.invalid"), + "expiry" => claims["exp"] = json!(now - 60), + "subject" => claims["sub"] = json!("not-a-member"), + "access-hash" => claims["at_hash"] = json!("incorrect"), + _ => {} + } + let header = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json!({"alg":"ES256","kid":"test"})).unwrap()); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); + let message = format!("{header}.{payload}"); + let signature: Signature = signing_key(&state).sign(message.as_bytes()); + let signature = if invalid == "signature" { + vec![0u8; 64] + } else { + signature.to_bytes().to_vec() + }; + Json( + json!({"access_token":"access","token_type":"Bearer","expires_in":300,"id_token":format!("{message}.{}",URL_SAFE_NO_PAD.encode(signature))}), + ) +} +fn signing_key(state: &IssuerState) -> SigningKey { + if *state.invalid.lock().unwrap() == "rotated-key" { + SigningKey::from_bytes((&[8u8; 32]).into()).unwrap() + } else { + state.key.clone() + } +} + +#[tokio::test] +async fn oidc_key_rotation_is_observed_without_host_restart() { + let (auth, issuer, server) = fixture().await; + *issuer.invalid.lock().unwrap() = "rotated-key"; + let (headers, state) = flow(&auth, &issuer); + let cookie = auth + .callback(&headers, &state, "code".into()) + .await + .unwrap(); + assert!(cookie.to_str().unwrap().starts_with("colossus_session=")); + server.abort(); +} +async fn fixture() -> ( + Authentication, + Arc, + tokio::task::JoinHandle<()>, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let issuer = format!("http://{}", listener.local_addr().unwrap()); + let state = Arc::new(IssuerState { + issuer: issuer.clone(), + key: SigningKey::from_bytes((&[7u8; 32]).into()).unwrap(), + nonce: Mutex::new(String::new()), + challenge: Mutex::new(String::new()), + invalid: Mutex::new(""), + }); + let router = Router::new() + .route("/.well-known/openid-configuration", get(discovery)) + .route("/jwks", get(jwks)) + .route("/token", post(token)) + .with_state(state.clone()); + let task = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let auth = Authentication::new(Config { + http_bind: "127.0.0.1:0".parse().unwrap(), + grpc_bind: "127.0.0.1:0".parse().unwrap(), + public_origin: "http://127.0.0.1:5180".into(), + grpc_endpoint: "https://localhost:8443".into(), + ca_certificate: "ca.pem".into(), + ca_key: "ca-key.pem".into(), + server_certificate: "server.pem".into(), + server_key: "server-key.pem".into(), + web_root: "dist".into(), + oidc: OidcConfig { + issuer, + client_id: "colossus".into(), + client_secret_file: None, + }, + memberships: vec![Membership { + subject: "alice".into(), + project_id: "project-a".into(), + permissions: BTreeSet::from([CloudPermission::Read, CloudPermission::Execute]), + }], + storage: Storage::Redb { + path: "cloud.redb".into(), + key_variable: None, + }, + local_development: true, + signing_key_variable: None, + }) + .await + .unwrap(); + (auth, state, task) +} +fn flow(auth: &Authentication, issuer: &IssuerState) -> (HeaderMap, String) { + let (url, cookie) = auth.login().unwrap(); + let url = url::Url::parse(&url).unwrap(); + let query: HashMap<_, _> = url.query_pairs().into_owned().collect(); + assert_eq!(query["code_challenge_method"], "S256"); + *issuer.challenge.lock().unwrap() = query["code_challenge"].clone(); + *issuer.nonce.lock().unwrap() = query["nonce"].clone(); + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + HeaderValue::from_str(cookie.to_str().unwrap().split(';').next().unwrap()).unwrap(), + ); + (headers, query["state"].clone()) +} +#[tokio::test] +async fn oidc_sign_in_binds_pkce_nonce_session_project_and_csrf() { + let (auth, issuer, server) = fixture().await; + let (flow_headers, state) = flow(&auth, &issuer); + assert_eq!( + auth.callback(&HeaderMap::new(), &state, "code".into()) + .await + .unwrap_err(), + CloudError::PermissionDenied + ); + let cookie = auth + .callback(&flow_headers, &state, "code".into()) + .await + .unwrap(); + assert!(cookie.to_str().unwrap().contains("HttpOnly; SameSite=Lax")); + assert_eq!( + auth.callback(&flow_headers, &state, "code".into()) + .await + .unwrap_err(), + CloudError::PermissionDenied + ); + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + HeaderValue::from_str(cookie.to_str().unwrap().split(';').next().unwrap()).unwrap(), + ); + assert_eq!( + auth.caller(&headers, "other-project", false).unwrap_err(), + CloudError::PermissionDenied + ); + assert_eq!( + auth.caller(&headers, "project-a", true).unwrap_err(), + CloudError::PermissionDenied + ); + headers.insert("origin", HeaderValue::from_static("http://127.0.0.1:5180")); + headers.insert("x-colossus-csrf", HeaderValue::from_static("1")); + assert_eq!( + auth.caller(&headers, "project-a", true).unwrap().subject(), + "alice" + ); + auth.logout(&headers).unwrap(); + assert_eq!( + auth.caller(&headers, "project-a", false).unwrap_err(), + CloudError::PermissionDenied + ); + server.abort(); +} +#[tokio::test] +async fn invalid_oidc_tokens_never_create_sessions() { + let (auth, issuer, server) = fixture().await; + for invalid in [ + "nonce", + "audience", + "issuer", + "expiry", + "subject", + "access-hash", + "signature", + ] { + *issuer.invalid.lock().unwrap() = invalid; + let (headers, state) = flow(&auth, &issuer); + assert_eq!( + auth.callback(&headers, &state, "code".into()) + .await + .unwrap_err(), + CloudError::PermissionDenied, + "{invalid}" + ); + } + assert!(auth.sessions.lock().unwrap().is_empty()); + server.abort(); +} diff --git a/crates/colossus-cloud-server/src/auth/tests/transport.rs b/crates/colossus-cloud-server/src/auth/tests/transport.rs new file mode 100644 index 000000000..1b7fac346 --- /dev/null +++ b/crates/colossus-cloud-server/src/auth/tests/transport.rs @@ -0,0 +1,675 @@ +//! Real OIDC, HTTP/SSE, encrypted enrollment, mTLS, connector, runtime, prompt, +//! approval, cancellation, rotation and host-restart acceptance. No model credentials. +use super::*; +use colossus_api::{ + ApiScope, ApplicationKind, ApplicationPrincipal, CallerContext, RequestId, scopes, +}; +use colossus_api_runtime::{PublicInteractionRouter, RuntimeAgentRunApi}; +use colossus_cloud::CloudTask; +use colossus_connector::{ConnectorStatus, EnrollmentStore, RuntimeConnector}; +use colossus_contracts::VaultRecord; +use colossus_credentials::{PlatformCredentialVault, PlatformKeyStore}; +use colossus_home::ColossusHome; +use colossus_policy::DenyApproval; +use colossus_ports::{CredentialError, CredentialKey, CredentialVault}; +use colossus_runtime::{Runtime, RuntimeConfig, RuntimeOpenOptions}; +use colossus_sdk::{ContextBoundAgentRunClient, InteractionContent, InteractionStatus, RunStatus}; +use rcgen::{ + BasicConstraints, CertificateParams, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, + KeyUsagePurpose, +}; +use std::{ + path::Path, + sync::atomic::{AtomicUsize, Ordering}, +}; +use tokio::sync::watch; +use zeroize::Zeroizing; + +mod managed; + +#[derive(Default)] +struct TestKeys(Mutex>>>); +impl PlatformKeyStore for TestKeys { + fn read( + &self, + account: &str, + ) -> Result>>, colossus_ports::CredentialError> { + Ok(self.0.lock().unwrap().get(account).cloned()) + } + fn write(&self, account: &str, bytes: &[u8]) -> Result<(), colossus_ports::CredentialError> { + self.0 + .lock() + .unwrap() + .insert(account.into(), Zeroizing::new(bytes.to_vec())); + Ok(()) + } +} +struct FaultVault { + inner: PlatformCredentialVault, + fail_write: AtomicUsize, +} +impl CredentialVault for FaultVault { + fn read(&self, key: &CredentialKey) -> Result, CredentialError> { + self.inner.read(key) + } + fn write(&self, key: &CredentialKey, record: &VaultRecord) -> Result<(), CredentialError> { + if self + .fail_write + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + == Ok(1) + { + return Err(CredentialError::Io); + } + self.inner.write(key, record) + } + fn delete(&self, key: &CredentialKey) -> Result<(), CredentialError> { + self.inner.delete(key) + } +} +fn tls_files(root: &Path, config: &mut Config) { + let key = KeyPair::generate().unwrap(); + let mut params = CertificateParams::default(); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::DigitalSignature, + ]; + let ca = params.self_signed(&key).unwrap(); + let issuer = Issuer::new(params, key); + let server_key = KeyPair::generate().unwrap(); + let mut params = CertificateParams::new(vec!["localhost".into()]).unwrap(); + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + let server = params.signed_by(&server_key, &issuer).unwrap(); + for (name, value) in [ + ("ca.pem", ca.pem()), + ("ca-key.pem", issuer.key().serialize_pem()), + ("server.pem", server.pem()), + ("server-key.pem", server_key.serialize_pem()), + ] { + std::fs::write(root.join(name), value).unwrap(); + } + config.ca_certificate = root.join("ca.pem"); + config.ca_key = root.join("ca-key.pem"); + config.server_certificate = root.join("server.pem"); + config.server_key = root.join("server-key.pem"); +} +async fn sign_in(client: &reqwest::Client, origin: &str, issuer: &IssuerState) -> HeaderMap { + let login = client + .get(format!("{origin}/auth/login")) + .send() + .await + .unwrap(); + let url = url::Url::parse(login.headers()["location"].to_str().unwrap()).unwrap(); + let query: HashMap<_, _> = url.query_pairs().into_owned().collect(); + *issuer.challenge.lock().unwrap() = query["code_challenge"].clone(); + *issuer.nonce.lock().unwrap() = query["nonce"].clone(); + let callback = client + .get(format!("{origin}/auth/callback")) + .query(&[("state", query["state"].as_str()), ("code", "fixture-code")]) + .header( + "cookie", + login.headers()["set-cookie"] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap(), + ) + .send() + .await + .unwrap(); + assert_eq!(callback.status(), 303); + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + HeaderValue::from_str( + callback.headers()["set-cookie"] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap(), + ) + .unwrap(), + ); + headers.insert("origin", HeaderValue::from_str(origin).unwrap()); + headers.insert("x-colossus-csrf", HeaderValue::from_static("1")); + headers +} +async fn post( + client: &reqwest::Client, + origin: &str, + headers: &HeaderMap, + path: &str, + value: Value, +) -> Value { + let response = client + .post(format!("{origin}{path}")) + .headers(headers.clone()) + .json(&value) + .send() + .await + .unwrap(); + assert!(response.status().is_success(), "{}", response.status()); + response.json().await.unwrap() +} +async fn wait_task( + client: &reqwest::Client, + origin: &str, + headers: &HeaderMap, + id: &str, + predicate: impl Fn(&CloudTask) -> bool, +) -> CloudTask { + let mut last = None; + let result = tokio::time::timeout(Duration::from_secs(25), async { + loop { + let response: Value = client + .get(format!("{origin}/api/projects/project-a/tasks/{id}")) + .headers(headers.clone()) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let task: CloudTask = serde_json::from_value(response["task"].clone()).unwrap(); + last = Some(task.clone()); + if predicate(&task) { + return task; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await; + result.unwrap_or_else(|_| panic!("cloud task deadline: {last:?}")) +} +async fn provider( + axum::extract::State(turn): axum::extract::State>, +) -> impl axum::response::IntoResponse { + let turn = turn.fetch_add(1, Ordering::SeqCst); + let choice = match turn { + 1 => { + json!({"delta":{"tool_calls":[{"index":0,"id":"write","type":"function","function":{"name":"filesystem_write","arguments":json!({"path":"approved.txt","content":"cloud-approved","mode":"create"}).to_string()}}]},"finish_reason":"tool_calls"}) + } + 2 => json!({"delta":{"content":"Cloud approval completed."},"finish_reason":"stop"}), + _ => { + json!({"delta":{"tool_calls":[{"index":0,"id":format!("ask-{turn}"),"type":"function","function":{"name":"user_ask","arguments":json!({"question":"Continue the isolated acceptance task?","choices":["Continue"],"allow_free_form":true}).to_string()}}]},"finish_reason":"tool_calls"}) + } + }; + let event = json!({"id":"cloud-fixture","choices":[{"index":0,"delta":choice["delta"],"finish_reason":choice["finish_reason"]}]}); + ( + [("content-type", "text/event-stream")], + format!("data: {event}\n\ndata: [DONE]\n\n"), + ) +} + +#[tokio::test] +async fn oidc_to_runtime_prompt_approval_cancel_rotate_and_recover() { + acceptance(false).await; +} + +#[tokio::test] +#[ignore = "operator-owned: local PostgreSQL plus COLOSSUS_CLOUD_TEST_DATABASE, JOURNAL_KEY and SIGNING_KEY"] +async fn postgres_oidc_to_runtime_and_recover() { + acceptance(true).await; +} + +async fn acceptance(postgres: bool) { + let (auth, issuer, oidc) = fixture().await; + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().canonicalize().unwrap(); + let home = ColossusHome::ensure_at(root.join("native")).unwrap(); + let browser = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let grpc = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut config = auth.config.clone(); + config.http_bind = browser.local_addr().unwrap(); + config.grpc_bind = grpc.local_addr().unwrap(); + config.public_origin = format!("http://{}", config.http_bind); + config.grpc_endpoint = format!("https://localhost:{}", config.grpc_bind.port()); + config.storage = Storage::Redb { + path: root.join("cloud.redb"), + key_variable: None, + }; + if postgres { + config.storage = Storage::Postgres { + config: colossus_journal_postgres::PostgresJournalConfig::new( + "COLOSSUS_CLOUD_TEST_DATABASE", + format!("cloud_e2e_{}", uuid::Uuid::now_v7().simple()), + colossus_journal_postgres::PostgresTlsConfig::Disabled, + ) + .unwrap(), + key_variable: "COLOSSUS_CLOUD_TEST_JOURNAL_KEY".into(), + anchor_path: root.join("cloud.anchor"), + }; + config.signing_key_variable = Some("COLOSSUS_CLOUD_TEST_SIGNING_KEY".into()); + } + config.memberships[0].permissions = BTreeSet::from([ + CloudPermission::Read, + CloudPermission::Execute, + CloudPermission::Control, + CloudPermission::Approve, + CloudPermission::Administer, + ]); + tls_files(&root, &mut config); + drop(auth); + let origin = config.public_origin.clone(); + let server = crate::server::CloudServer::open(config.clone()) + .await + .unwrap(); + let (stop, shutdown) = watch::channel(false); + let host = tokio::spawn(server.serve_listeners(browser, grpc, shutdown)); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + let failed_sign_in = client + .get(format!("{origin}/auth/callback")) + .query(&[("state", "untrusted-state"), ("code", "untrusted-code")]) + .send() + .await + .unwrap(); + assert_eq!(failed_sign_in.status(), 403); + assert!( + failed_sign_in.headers()["content-type"] + .to_str() + .unwrap() + .starts_with("text/html") + ); + assert!(!failed_sign_in.headers().contains_key("set-cookie")); + let failure_page = failed_sign_in.text().await.unwrap(); + assert!(failure_page.contains("Return to Colossus")); + assert!(!failure_page.contains("untrusted-state")); + assert!(!failure_page.contains("untrusted-code")); + let cancelled_sign_in = client + .get(format!("{origin}/auth/callback")) + .query(&[ + ("error", "access_denied"), + ("error_description", "untrusted-description"), + ]) + .send() + .await + .unwrap(); + assert_eq!(cancelled_sign_in.status(), 403); + assert!(!cancelled_sign_in.headers().contains_key("set-cookie")); + let cancelled_page = cancelled_sign_in.text().await.unwrap(); + assert!(cancelled_page.contains("Return to Colossus")); + assert!(!cancelled_page.contains("untrusted-description")); + let headers = sign_in(&client, &origin, &issuer).await; + assert_eq!( + client + .get(format!("{origin}/api/projects/foreign/tasks")) + .headers(headers.clone()) + .send() + .await + .unwrap() + .status(), + 403 + ); + let mut no_csrf = headers.clone(); + no_csrf.remove("x-colossus-csrf"); + assert_eq!( + client + .post(format!("{origin}/api/projects/project-a/invitations")) + .headers(no_csrf) + .json(&json!({"label":"test","roles":["primary"]})) + .send() + .await + .unwrap() + .status(), + 403 + ); + let invite = post( + &client, + &origin, + &headers, + "/api/projects/project-a/invitations", + json!({"label":"Acceptance runtime","roles":["primary"]}), + ) + .await; + let vault = Arc::new(FaultVault { + inner: PlatformCredentialVault::with_key_store( + home.confined_root().clone(), + "cloud-acceptance", + Arc::new(TestKeys::default()), + ) + .unwrap(), + fail_write: AtomicUsize::new(0), + }); + let enrollment = EnrollmentStore::new(vault.clone(), "node-a").unwrap(); + let connection = enrollment + .enroll( + invite["enrollment_url"].as_str().unwrap().into(), + Zeroizing::new(invite["token"].as_str().unwrap().into()), + "runtime-a".into(), + vec![], + true, + ) + .await + .unwrap(); + let (_, key) = enrollment.load().unwrap().unwrap(); + + let model_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let model_origin = format!("http://{}", model_listener.local_addr().unwrap()); + let turns = Arc::new(AtomicUsize::new(0)); + let model_router = Router::new() + .route("/v1/chat/completions", axum::routing::post(provider)) + .with_state(turns.clone()); + let model = + tokio::spawn(async move { axum::serve(model_listener, model_router).await.unwrap() }); + let workspace = root.join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let mut runtime_config = RuntimeConfig::offline_template(workspace.join("runtime.redb")); + runtime_config.workflows.repository = workspace.join("workflows"); + runtime_config.workflows.user = workspace.join("workflows"); + std::fs::create_dir(&runtime_config.workflows.repository).unwrap(); + runtime_config.access=serde_json::from_value(json!({"profile":"pinned","tools":{"include":["user.ask","filesystem.write"]},"actions":{"allow":["provider.openai.chat"],"requireApproval":["filesystem.write"]}})).unwrap(); + runtime_config.providers=serde_json::from_value(json!({"profiles":{"fixture":{"kind":"open_ai_compatible","baseUrl":format!("{model_origin}/v1"),"timeoutMs":5000}}})).unwrap(); + runtime_config.models=serde_json::from_value(json!({"profiles":{"fixture":{"providerProfile":"fixture","model":"fixture","contextWindowTokens":32768,"maxOutputTokens":4096,"capabilities":{"toolCalls":true,"streaming":true}}},"roles":{"primary":"fixture"}})).unwrap(); + runtime_config.sandbox.network_destinations = vec![model_origin]; + runtime_config.sandbox.filesystem = + serde_json::from_value(json!([{"root":workspace,"mode":"write"}])).unwrap(); + let interactions = Arc::new(PublicInteractionRouter::new(Arc::new(DenyApproval), None)); + let runtime = Arc::new( + Runtime::open_with_options( + &runtime_config, + interactions.clone(), + Some(interactions.clone()), + RuntimeOpenOptions::for_workspace(&workspace).unwrap(), + ) + .unwrap(), + ); + let api = Arc::new(RuntimeAgentRunApi::new( + runtime.clone(), + interactions, + "primary", + "Perform the acceptance task.", + )); + let principal = ApplicationPrincipal::authenticated( + "app:cloud-acceptance", + "credential-acceptance", + ApplicationKind::Enrolled, + [ + scopes::RUNS_EXECUTE, + scopes::RUNS_READ, + scopes::RUNS_CONTROL, + scopes::PROMPTS_RESPOND, + scopes::APPROVALS_RESPOND, + ] + .into_iter() + .map(|scope| ApiScope::new(scope).unwrap()), + ["primary".into()], + ["user.ask".into(), "filesystem.write".into()], + ) + .unwrap(); + let runs = Arc::new(ContextBoundAgentRunClient::new( + api, + CallerContext::authenticated(principal, RequestId::new("cloud-request").unwrap()), + )); + let (disconnect, close) = watch::channel(false); + let (status, mut online) = watch::channel(ConnectorStatus::Connecting); + let connector = RuntimeConnector::new(connection.clone(), key, runs.clone()) + .unwrap() + .with_enrollment_store(enrollment.clone()); + let connected = tokio::spawn(connector.run(close, status)); + tokio::time::timeout(Duration::from_secs(5), async { + while *online.borrow() != ConnectorStatus::Connected { + online.changed().await.unwrap(); + } + }) + .await + .unwrap(); + let create = |id: &str| json!({"node_id":connection.node_id,"request":{"plugin_skill_ids":[],"input":[{"text":"Acceptance task"}],"session_id":null,"end_user_id":null,"role":"primary","mode":"execute","research_depth":null,"research_sources":[],"plan_action":null,"branch":null,"max_turns":8,"idempotency_key":id}}); + let allocated = post( + &client, + &origin, + &headers, + "/api/projects/project-a/tasks", + create("once"), + ) + .await; + let id = allocated["task"]["task_id"].as_str().unwrap(); + assert_eq!( + post( + &client, + &origin, + &headers, + "/api/projects/project-a/tasks", + create("once") + ) + .await["task"]["task_id"], + id + ); + for approval in [false, true] { + let task = wait_task(&client, &origin, &headers, id, |task| { + task.snapshot.as_ref().is_some_and(|snapshot| { + snapshot.pending_interactions.iter().any(|item| { + item.status == InteractionStatus::Pending + && matches!(item.content, InteractionContent::Approval(_)) == approval + }) + }) + }) + .await; + let interaction = task + .snapshot + .unwrap() + .pending_interactions + .into_iter() + .find(|item| { + item.status == InteractionStatus::Pending + && matches!(item.content, InteractionContent::Approval(_)) == approval + }) + .unwrap(); + assert!(!workspace.join("approved.txt").exists()); + let answer = match &interaction.content { + InteractionContent::UserPrompt(prompt) => { + assert!(!approval); + json!({"prompt":{"choice":{"choice_id":prompt.choices[0].choice_id,"label":prompt.choices[0].label}}}) + } + InteractionContent::Approval(content) => { + assert!(approval); + json!({"approval":{"approved":true,"request_hash":content.request_hash}}) + } + }; + post(&client,&origin,&headers,&format!("/api/projects/project-a/tasks/{id}/respond"),json!({"mutation_id":format!("answer-{approval}"),"request":{"run_id":interaction.run_id,"interaction_id":interaction.interaction_id,"etag":interaction.etag,"response":answer,"idempotency_key":format!("answer-{approval}")}})).await; + } + let completed = wait_task(&client, &origin, &headers, id, |task| { + task.snapshot.as_ref().is_some_and(|snapshot| { + snapshot.run.status == RunStatus::Completed + && task.last_sequence == snapshot.run.last_sequence + }) + }) + .await; + assert_eq!( + std::fs::read_to_string(workspace.join("approved.txt")).unwrap(), + "cloud-approved" + ); + let mut events = client + .get(format!( + "{origin}/api/projects/project-a/tasks/{id}/events?after=0" + )) + .headers(headers.clone()) + .send() + .await + .unwrap(); + let chunk = tokio::time::timeout(Duration::from_secs(3), events.chunk()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(String::from_utf8_lossy(&chunk).contains("id: 1")); + drop(events); + let cancelled = post( + &client, + &origin, + &headers, + "/api/projects/project-a/tasks", + create("cancel-once"), + ) + .await; + let cancel_id = cancelled["task"]["task_id"].as_str().unwrap(); + wait_task(&client, &origin, &headers, cancel_id, |task| { + task.snapshot + .as_ref() + .is_some_and(|snapshot| !snapshot.pending_interactions.is_empty()) + }) + .await; + post( + &client, + &origin, + &headers, + &format!("/api/projects/project-a/tasks/{cancel_id}/cancel"), + json!({"mutation_id":"cancel-once"}), + ) + .await; + wait_task(&client, &origin, &headers, cancel_id, |task| { + task.snapshot + .as_ref() + .is_some_and(|snapshot| snapshot.run.status == RunStatus::Cancelled) + }) + .await; + disconnect.send_replace(true); + connected.await.unwrap().unwrap(); + // The host commits rotation, but saving the returned leaf fails. Restart + // with the old leaf and persisted pending CSR; automatic renewal must replay. + vault.fail_write.store(2, Ordering::SeqCst); + assert!(enrollment.renew(true).await.is_err()); + let (rotated, key) = enrollment.load().unwrap().unwrap(); + assert_eq!(rotated.certificate_pem, connection.certificate_pem); + stop.send_replace(true); + tokio::time::timeout(Duration::from_secs(5), host) + .await + .unwrap() + .unwrap() + .unwrap(); + let server = crate::server::CloudServer::open(config.clone()) + .await + .unwrap(); + let (stop, close) = watch::channel(false); + let browser = tokio::net::TcpListener::bind(config.http_bind) + .await + .unwrap(); + let grpc = tokio::net::TcpListener::bind(config.grpc_bind) + .await + .unwrap(); + let host = tokio::spawn(server.serve_listeners(browser, grpc, close)); + let headers = sign_in(&client, &origin, &issuer).await; + let recovered = wait_task(&client, &origin, &headers, id, |task| { + task.last_sequence == completed.last_sequence + }) + .await; + assert_eq!(recovered.run_id, completed.run_id); + let (disconnect, close) = watch::channel(false); + let (status, mut online) = watch::channel(ConnectorStatus::Connecting); + let connected = tokio::spawn( + RuntimeConnector::new(rotated.clone(), key, runs) + .unwrap() + .with_enrollment_store(enrollment.clone()) + .run(close, status), + ); + tokio::time::timeout(Duration::from_secs(5), async { + while *online.borrow() != ConnectorStatus::Connected { + online.changed().await.unwrap(); + } + }) + .await + .unwrap(); + assert_ne!( + enrollment.load().unwrap().unwrap().0.certificate_pem, + connection.certificate_pem + ); + // Keep this connector alive through a host restart. An authenticated peer + // that never completes HTTP/2 must not defeat the channel startup deadline. + stop.send_replace(true); + tokio::time::timeout(Duration::from_secs(5), host) + .await + .unwrap() + .unwrap() + .unwrap(); + let listener = tokio::net::TcpListener::bind(config.grpc_bind) + .await + .unwrap(); + let stalled_config = config.clone(); + let (accepted, peer_accepted) = tokio::sync::oneshot::channel(); + let stalled = tokio::spawn(async move { + use futures::StreamExt as _; + let ca = std::fs::read_to_string(&stalled_config.ca_certificate).unwrap(); + let mut incoming = Box::pin(crate::tls::incoming(listener, &stalled_config, &ca).unwrap()); + let peer = incoming.next().await.unwrap().unwrap(); + accepted.send(()).unwrap(); + std::future::pending::<()>().await; + drop(peer); + }); + tokio::time::timeout(Duration::from_secs(10), peer_accepted) + .await + .unwrap() + .unwrap(); + drop(online.borrow_and_update()); + tokio::time::timeout(Duration::from_secs(12), online.changed()) + .await + .expect("authenticated HTTP/2 stall must leave channel startup") + .unwrap(); + assert_eq!(*online.borrow(), ConnectorStatus::Reconnecting); + stalled.abort(); + assert!(stalled.await.unwrap_err().is_cancelled()); + let server = crate::server::CloudServer::open(config.clone()) + .await + .unwrap(); + let (stop, close) = watch::channel(false); + let browser = tokio::net::TcpListener::bind(config.http_bind) + .await + .unwrap(); + let grpc = tokio::net::TcpListener::bind(config.grpc_bind) + .await + .unwrap(); + let host = tokio::spawn(server.serve_listeners(browser, grpc, close)); + tokio::time::timeout(Duration::from_secs(10), async { + while *online.borrow() != ConnectorStatus::Connected { + online.changed().await.unwrap(); + } + }) + .await + .expect("same connector recovers after channel startup timeout"); + let headers = sign_in(&client, &origin, &issuer).await; + let retained = wait_task(&client, &origin, &headers, id, |_| true).await; + assert_eq!(retained.run_id, completed.run_id); + assert_eq!(retained.last_sequence, completed.last_sequence); + let fleet: Value = client + .get(format!("{origin}/api/projects/project-a/nodes")) + .headers(headers.clone()) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let node = &fleet["nodes"][0]["node"]; + post( + &client, + &origin, + &headers, + &format!("/api/projects/project-a/nodes/{}/revoke", rotated.node_id), + json!({"revision":node["revision"]}), + ) + .await; + assert!( + tokio::time::timeout(Duration::from_secs(5), connected) + .await + .unwrap() + .unwrap() + .is_err() + ); + assert_eq!(*online.borrow(), ConnectorStatus::Revoked); + // A native acknowledgement lost after host revocation remains replayable. + enrollment.revoke().await.unwrap(); + enrollment.revoke().await.unwrap(); + assert!(enrollment.load().unwrap().unwrap().0.revoked); + drop(disconnect); + stop.send_replace(true); + host.await.unwrap().unwrap(); + model.abort(); + oidc.abort(); +} diff --git a/crates/colossus-cloud-server/src/auth/tests/transport/managed.rs b/crates/colossus-cloud-server/src/auth/tests/transport/managed.rs new file mode 100644 index 000000000..9133295c2 --- /dev/null +++ b/crates/colossus-cloud-server/src/auth/tests/transport/managed.rs @@ -0,0 +1,260 @@ +//! Browser-to-native-sidecar acceptance and application-exit connection semantics. +use super::*; +use colossus_sdk::{ + ApiMajor, AppPrivateInstanceDir, Colossus, GetRunRequest, InstanceId, ManagedAccessProfile, + ManagedRuntimeConfig, NativeSidecarLifecycle, Sha256Digest, SidecarApplicationGrant, + SidecarApprovalBrokerGrant, SidecarBootstrapConfig, SidecarOptions, VerifiedExecutable, +}; +use std::{fs::File, io::Read as _}; + +struct NativeKeys(uuid::Uuid); +impl Drop for NativeKeys { + fn drop(&mut self) { + for account in [ + format!("journal-key:journal-{}", self.0), + format!("signing-key:checkpoint-{}", self.0), + format!("journal-anchor:journal-{}", self.0), + ] { + if let Ok(entry) = + keyring::Entry::new("com.obscuritylabs.colossus.managed-runtime", &account) + { + let _ = entry.delete_credential(); + } + } + } +} + +fn digest(path: &Path) -> [u8; 32] { + let mut file = File::open(path).unwrap(); + let mut hash = Sha256::new(); + let mut buffer = [0; 65_536]; + loop { + let count = file.read(&mut buffer).unwrap(); + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + hash.finalize().into() +} + +#[tokio::test] +#[ignore = "native credential store, loopback and COLOSSUS_ACCEPTANCE_SIDECAR required"] +async fn managed_sidecar_cloud_run_isolated_and_application_exit_disconnects() { + let sidecar = std::fs::canonicalize( + std::env::var_os("COLOSSUS_ACCEPTANCE_SIDECAR").expect("prepared verified native sidecar"), + ) + .unwrap(); + let (auth, issuer, oidc) = fixture().await; + #[cfg(windows)] + let directory = tempfile::tempdir_in(std::env::var_os("USERPROFILE").unwrap()).unwrap(); + #[cfg(not(windows))] + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().canonicalize().unwrap(); + let home = ColossusHome::ensure_at(root.join("native")).unwrap(); + let workspace = root.join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let instance_home = + ColossusHome::ensure_at(root.join("managed-local").join("a".repeat(64))).unwrap(); + let instance = uuid::Uuid::now_v7(); + let _keys = NativeKeys(instance); + let grant = |application, scopes: &[&str]| { + SidecarApplicationGrant::new( + application, + scopes.iter().map(|scope| ApiScope::new(*scope).unwrap()), + ["primary".into()], + Vec::::new(), + ) + .unwrap() + }; + let bootstrap = SidecarBootstrapConfig::new( + &workspace, + ManagedRuntimeConfig::echo(ManagedAccessProfile::Minimal), + grant( + "app:desktop-cloud-acceptance", + &[ + scopes::RUNS_EXECUTE, + scopes::RUNS_READ, + scopes::RUNS_CONTROL, + scopes::PROMPTS_RESPOND, + ], + ), + ) + .unwrap() + .with_colossus_home(home.root()) + .unwrap() + .with_approval_broker_grant( + SidecarApprovalBrokerGrant::new("app:desktop-cloud-acceptance", ["primary".into()]) + .unwrap(), + ) + .unwrap() + .with_connector_grant(grant( + "app:independent-cloud-acceptance", + &[ + scopes::RUNS_EXECUTE, + scopes::RUNS_READ, + scopes::RUNS_CONTROL, + scopes::PROMPTS_RESPOND, + scopes::APPROVALS_RESPOND, + ], + )) + .unwrap(); + let lifecycle = NativeSidecarLifecycle::new(bootstrap); + let desktop = Colossus::start_sidecar( + &lifecycle, + SidecarOptions::new( + InstanceId::from_uuid(instance), + AppPrivateInstanceDir::new(instance_home.root()).unwrap(), + VerifiedExecutable::new(&sidecar, Sha256Digest::from_bytes(digest(&sidecar))).unwrap(), + ApiMajor::new(1).unwrap(), + ) + .unwrap(), + ) + .await + .unwrap(); + let runs = desktop + .connector_runs() + .expect("separate native cloud grant"); + let browser = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let grpc = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut config = auth.config.clone(); + config.http_bind = browser.local_addr().unwrap(); + config.grpc_bind = grpc.local_addr().unwrap(); + config.public_origin = format!("http://{}", config.http_bind); + config.grpc_endpoint = format!("https://localhost:{}", config.grpc_bind.port()); + config.storage = Storage::Redb { + path: root.join("cloud.redb"), + key_variable: None, + }; + config.memberships[0] + .permissions + .insert(CloudPermission::Administer); + tls_files(&root, &mut config); + drop(auth); + let origin = config.public_origin.clone(); + let server = crate::server::CloudServer::open(config).await.unwrap(); + let (stop, shutdown) = watch::channel(false); + let host = tokio::spawn(server.serve_listeners(browser, grpc, shutdown)); + let browser_client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + let headers = sign_in(&browser_client, &origin, &issuer).await; + let invitation = post( + &browser_client, + &origin, + &headers, + "/api/projects/project-a/invitations", + json!({"label":"Managed Local", "roles":["primary"]}), + ) + .await; + let vault = Arc::new( + PlatformCredentialVault::with_key_store( + home.confined_root().clone(), + "managed-cloud-acceptance", + Arc::new(TestKeys::default()), + ) + .unwrap(), + ); + let enrollment = EnrollmentStore::new(vault, "managed-local").unwrap(); + let config = enrollment + .enroll( + invitation["enrollment_url"].as_str().unwrap().into(), + Zeroizing::new(invitation["token"].as_str().unwrap().into()), + desktop.instance_id().unwrap().to_string(), + vec![], + true, + ) + .await + .unwrap(); + let (_, key) = enrollment.load().unwrap().unwrap(); + let (_disconnect, close) = watch::channel(false); + let (status, mut online) = watch::channel(ConnectorStatus::Connecting); + let connector = tokio::spawn( + RuntimeConnector::new(config.clone(), key, runs) + .unwrap() + .with_enrollment_store(enrollment.clone()) + .run(close, status), + ); + tokio::time::timeout(Duration::from_secs(10), async { + while *online.borrow() != ConnectorStatus::Connected { + online.changed().await.unwrap(); + } + }) + .await + .unwrap(); + let allocated = post(&browser_client, &origin, &headers, "/api/projects/project-a/tasks", + json!({"node_id":config.node_id,"request":{"plugin_skill_ids":[],"input":[{"text":"Native Desktop cloud acceptance"}],"session_id":null,"end_user_id":null,"role":"primary","mode":"execute","research_depth":null,"research_sources":[],"plan_action":null,"branch":null,"max_turns":1,"idempotency_key":"native-desktop-cloud-task"}})).await; + let task_id = allocated["task"]["task_id"].as_str().unwrap(); + let completed = wait_task(&browser_client, &origin, &headers, task_id, |task| { + task.snapshot.as_ref().is_some_and(|snapshot| { + snapshot.run.status == RunStatus::Completed + && task.last_sequence == snapshot.run.last_sequence + }) + }) + .await; + let run_id = completed.run_id.unwrap(); + assert!( + completed.last_sequence > 0 && completed.released_bytes > 0, + "released native output reaches the browser" + ); + let mut events = browser_client + .get(format!( + "{origin}/api/projects/project-a/tasks/{task_id}/events?after=0" + )) + .headers(headers.clone()) + .send() + .await + .unwrap(); + let chunk = tokio::time::timeout(Duration::from_secs(3), events.chunk()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(String::from_utf8_lossy(&chunk).contains("id: 1")); + drop(events); + assert!( + desktop + .get_run(GetRunRequest { + run_id: run_id.clone() + }) + .await + .is_err(), + "Desktop primary cannot inherit cloud application runs" + ); + // Closing the application-owned SDK lifetime must take its cloud connection + // offline, even while the connector task and enrollment remain alive. + desktop.close().await.unwrap(); + tokio::time::timeout(Duration::from_secs(10), connector) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(*online.borrow(), ConnectorStatus::Disconnected); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let fleet: Value = browser_client + .get(format!("{origin}/api/projects/project-a/nodes")) + .headers(headers.clone()) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + if fleet["nodes"][0]["presence"].is_null() { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("application exit removes cloud readiness"); + let retained = wait_task(&browser_client, &origin, &headers, task_id, |_| true).await; + assert_eq!(retained.run_id.as_deref(), Some(run_id.as_str())); + enrollment.revoke().await.unwrap(); + enrollment.forget().unwrap(); + stop.send_replace(true); + host.await.unwrap().unwrap(); + oidc.abort(); +} diff --git a/crates/colossus-cloud-server/src/certificates.rs b/crates/colossus-cloud-server/src/certificates.rs new file mode 100644 index 000000000..946d21136 --- /dev/null +++ b/crates/colossus-cloud-server/src/certificates.rs @@ -0,0 +1,42 @@ +use crate::config::Config; +use rcgen::{ + CertificateParams, CertificateSigningRequestParams, ExtendedKeyUsagePurpose, Issuer, KeyPair, +}; +use sha2::{Digest, Sha256}; +use time::{Duration, OffsetDateTime}; +use zeroize::Zeroizing; + +pub(crate) struct CertificateAuthority { + issuer: Issuer<'static, KeyPair>, + pub pem: String, +} +impl CertificateAuthority { + pub fn load(config: &Config) -> Result { + let pem = std::fs::read_to_string(&config.ca_certificate) + .map_err(|_| "cannot load connector CA")?; + let key = Zeroizing::new( + std::fs::read_to_string(&config.ca_key).map_err(|_| "cannot load connector CA key")?, + ); + let issuer = + Issuer::from_ca_cert_pem(&pem, KeyPair::from_pem(&key).map_err(|_| "invalid CA key")?) + .map_err(|_| "invalid CA certificate")?; + Ok(Self { issuer, pem }) + } + pub fn sign(&self, csr: &str) -> Result<(String, String), &'static str> { + if csr.len() > 16384 { + return Err("invalid certificate request"); + } + let mut request = CertificateSigningRequestParams::from_pem(csr) + .map_err(|_| "invalid certificate request")?; + // The host supplies all extensions; an untrusted CSR cannot obtain CA authority. + let now = OffsetDateTime::now_utc(); + request.params = CertificateParams::default(); + request.params.not_before = now - Duration::minutes(5); + request.params.not_after = now + Duration::days(30); + request.params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth]; + let cert = request + .signed_by(&self.issuer) + .map_err(|_| "cannot issue client certificate")?; + Ok((cert.pem(), hex::encode(Sha256::digest(cert.der())))) + } +} diff --git a/crates/colossus-cloud-server/src/config.rs b/crates/colossus-cloud-server/src/config.rs new file mode 100644 index 000000000..eac150d3a --- /dev/null +++ b/crates/colossus-cloud-server/src/config.rs @@ -0,0 +1,171 @@ +use colossus_cloud::CloudPermission; +use colossus_journal_postgres::PostgresJournalConfig; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeSet, net::SocketAddr, path::PathBuf}; + +/// Secret-reference-only control-plane host configuration. +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + /// HTTP listener; production terminates HTTPS at its trusted ingress. + pub http_bind: SocketAddr, + /// End-to-end TLS gRPC listener. + pub grpc_bind: SocketAddr, + /// Exact browser origin, also used for CSRF and OIDC callbacks. + pub public_origin: String, + /// Public outbound connector endpoint, always HTTPS. + pub grpc_endpoint: String, + /// PEM connector certificate authority. + pub ca_certificate: PathBuf, + /// PEM authority private key mounted from a Kubernetes Secret. + pub ca_key: PathBuf, + /// PEM server leaf, including its public chain. + pub server_certificate: PathBuf, + /// PEM server private key. + pub server_key: PathBuf, + /// OIDC discovery and relying-party configuration. + pub oidc: OidcConfig, + /// Explicit issuer-bound subject/project memberships. + pub memberships: Vec, + /// Built web frontend directory served at the authenticated application origin. + pub web_root: PathBuf, + /// Canonical cloud journal adapter. + pub storage: Storage, + /// Independent 32-byte Ed25519 checkpoint seed reference, required in production. + pub signing_key_variable: Option, + /// Allow HTTP origins and plaintext state only on loopback for local acceptance. + #[serde(default)] + pub local_development: bool, +} +/// OIDC authorization-code/PKCE sign-in configuration. +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct OidcConfig { + /// Exact trusted issuer; discovered metadata must match it. + pub issuer: String, + /// Registered relying-party client identity. + pub client_id: String, + /// Optional mounted confidential-client secret. + pub client_secret_file: Option, +} +/// One explicitly configured authenticated project membership. +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Membership { + /// OIDC subject under the single configured issuer. + pub subject: String, + /// Project namespace. + pub project_id: String, + /// Independent cloud permissions. + pub permissions: BTreeSet, +} +/// Canonical storage selection, with credentials resolved only by its adapter. +#[derive(Clone, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Storage { + /// Owner-private local redb; plaintext requires local development opt-in. + Redb { + /// Owner-private canonical state path. + path: PathBuf, + /// Explicit injected journal encryption key reference. + key_variable: Option, + }, + /// Existing encrypted PostgreSQL journal for Kubernetes deployment. + Postgres { + /// Existing PostgreSQL adapter connection and TLS policy. + config: PostgresJournalConfig, + /// Injected journal encryption key reference. + key_variable: String, + /// Owner-private retained secure anchor path. + anchor_path: PathBuf, + }, +} +impl Config { + /// Validate origins and namespaces before network or credential access. + pub fn validate(&self) -> Result<(), &'static str> { + let origin = url::Url::parse(&self.public_origin).map_err(|_| "invalid public origin")?; + let endpoint = url::Url::parse(&self.grpc_endpoint).map_err(|_| "invalid gRPC endpoint")?; + let issuer = url::Url::parse(&self.oidc.issuer).map_err(|_| "invalid OIDC issuer")?; + let loopback = + |url: &url::Url| matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")); + if endpoint.scheme() != "https" + || endpoint.path() != "/" + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.query().is_some() + || endpoint.fragment().is_some() + || !issuer.username().is_empty() + || issuer.password().is_some() + || issuer.query().is_some() + || issuer.fragment().is_some() + || !matches!(origin.scheme(), "http" | "https") + || !matches!(issuer.scheme(), "http" | "https") + || origin.path() != "/" + || origin.query().is_some() + || origin.fragment().is_some() + || !origin.username().is_empty() + || origin.password().is_some() + { + return Err("invalid cloud origin"); + } + if self.local_development { + if !self.http_bind.ip().is_loopback() + || !self.grpc_bind.ip().is_loopback() + || !loopback(&origin) + || !loopback(&issuer) + || !loopback(&endpoint) + { + return Err("local development requires loopback endpoints"); + } + } else if origin.scheme() != "https" || issuer.scheme() != "https" { + return Err("production requires HTTPS and OIDC"); + } + if !self.local_development + && matches!(&self.storage, Storage::Postgres{config,..} if matches!(config.tls,colossus_journal_postgres::PostgresTlsConfig::Disabled)) + { + return Err("production PostgreSQL requires verified TLS"); + } + if self.memberships.is_empty() || self.memberships.len() > 1024 { + return Err("invalid project memberships"); + } + for member in &self.memberships { + colossus_cloud::CloudCaller::new( + member.subject.clone(), + member.project_id.clone(), + member.permissions.clone(), + ) + .map_err(|_| "invalid membership")?; + } + let unique: BTreeSet<_> = self + .memberships + .iter() + .map(|membership| (&membership.subject, &membership.project_id)) + .collect(); + if unique.len() != self.memberships.len() { + return Err("duplicate project membership"); + } + if matches!( + &self.storage, + Storage::Redb { + key_variable: None, + .. + } + ) && !self.local_development + { + return Err("production state requires encryption"); + } + if !self.local_development && self.signing_key_variable.is_none() { + return Err("production state requires checkpoint signing"); + } + if let Some(signing) = &self.signing_key_variable { + let journal = match &self.storage { + Storage::Redb { key_variable, .. } => key_variable.as_ref(), + Storage::Postgres { key_variable, .. } => Some(key_variable), + }; + if Some(signing) == journal { + return Err("journal and signing keys must be independent"); + } + } + Ok(()) + } +} diff --git a/crates/colossus-cloud-server/src/connection.rs b/crates/colossus-cloud-server/src/connection.rs new file mode 100644 index 000000000..423dbcbe2 --- /dev/null +++ b/crates/colossus-cloud-server/src/connection.rs @@ -0,0 +1,291 @@ +use crate::server::{NodePresence, State, db}; +use colossus_cloud::{CloudError, CloudNode}; +use colossus_cloud_protocol::{ + Command, MAX_PAYLOAD_BYTES, MAX_QUEUED_FRAMES, PROTOCOL_MAJOR, decode, decode_update, encode, + v1alpha1::{ + self as wire, control_frame, + runtime_connection_server::{RuntimeConnection, RuntimeConnectionServer}, + runtime_frame, + }, +}; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashSet, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tonic::{ + Request, Response, Status, Streaming, + transport::server::{TcpConnectInfo, TlsConnectInfo}, +}; + +pub(crate) fn service(state: Arc) -> RuntimeConnectionServer { + RuntimeConnectionServer::new(Connection(state)) + .max_decoding_message_size(MAX_PAYLOAD_BYTES + 16384) + .max_encoding_message_size(MAX_PAYLOAD_BYTES + 16384) +} +pub(crate) struct Connection(Arc); +#[tonic::async_trait] +impl RuntimeConnection for Connection { + type ConnectStream = ReceiverStream>; + async fn revoke_enrollment( + &self, + request: Request, + ) -> Result, Status> { + let fingerprint = peer_fingerprint(&request)?; + let input = request.into_inner(); + db(self.0.repo.clone(), move |repo| { + repo.revoke_own_node( + &input.project_id, + &input.node_id, + &fingerprint, + &input.instance_id, + ) + }) + .await + .map_err(status)?; + Ok(Response::new(wire::EnrollmentRevoked {})) + } + async fn renew_certificate( + &self, + request: Request, + ) -> Result, Status> { + let fingerprint = peer_fingerprint(&request)?; + let input = request.into_inner(); + let (pem, fingerprint_new) = self + .0 + .ca + .sign(&input.csr_pem) + .map_err(|_| Status::invalid_argument("invalid certificate request"))?; + let certificate = colossus_cloud::CertificateRedemption { + fingerprint: fingerprint_new, + csr_sha256: hex::encode(Sha256::digest(input.csr_pem.as_bytes())), + certificate_pem: pem, + }; + let certificate_pem = db(self.0.repo.clone(), move |repo| { + repo.renew_certificate( + colossus_cloud::RenewalIdentity { + project_id: &input.project_id, + node_id: &input.node_id, + instance_id: &input.instance_id, + previous_fingerprint: &fingerprint, + renewal_id: &input.renewal_id, + }, + certificate, + crate::http::now(), + ) + }) + .await + .map_err(status)?; + Ok(Response::new(wire::RenewedCertificate { certificate_pem })) + } + async fn connect( + &self, + request: Request>, + ) -> Result, Status> { + let fingerprint = peer_fingerprint(&request)?; + let mut inbound = request.into_inner(); + let first = tokio::time::timeout(Duration::from_secs(10), inbound.message()) + .await + .map_err(|_| Status::deadline_exceeded("hello timeout"))?? + .ok_or_else(|| Status::invalid_argument("hello required"))?; + let Some(runtime_frame::Body::Hello(hello)) = first.body else { + return Err(Status::invalid_argument("hello required")); + }; + if hello.protocol_major != PROTOCOL_MAJOR + || hello.capabilities.len() > 64 + || hello + .capabilities + .iter() + .any(|cap| cap.is_empty() || cap.len() > 128 || cap.chars().any(char::is_control)) + { + return Err(Status::failed_precondition( + "incompatible protocol or capabilities", + )); + } + let node = db(self.0.repo.clone(), { + let hello = hello.clone(); + move |repo| { + repo.authenticate_node( + &hello.project_id, + &hello.node_id, + &fingerprint, + &hello.instance_id, + ) + } + }) + .await + .map_err(status)?; + let connection_id = uuid::Uuid::now_v7().simple().to_string(); + let key = format!("{}:{}", node.project_id, node.node_id); + { + let mut presence = self + .0 + .presence + .lock() + .map_err(|_| Status::unavailable("presence unavailable"))?; + if presence.get(&key).is_some_and(|current| { + current + .heartbeat + .is_some_and(|time| time.elapsed() < Duration::from_secs(30)) + }) { + return Err(Status::already_exists("node is already connected")); + } + if presence.len() >= 1024 && !presence.contains_key(&key) { + return Err(Status::resource_exhausted("node connection limit")); + } + presence.insert( + key.clone(), + NodePresence { + connection_id: connection_id.clone(), + ready: false, + capabilities: hello.capabilities, + heartbeat: Some(Instant::now()), + }, + ); + } + let (sender, receiver) = mpsc::channel(MAX_QUEUED_FRAMES); + sender + .send(Ok(wire::ControlFrame { + body: Some(control_frame::Body::Welcome(wire::ConnectionWelcome { + protocol_major: PROTOCOL_MAJOR, + connection_id: connection_id.clone(), + })), + })) + .await + .map_err(|_| Status::cancelled("connection closed"))?; + let state = self.0.clone(); + tokio::spawn(async move { + let result = + serve_connection(state.clone(), node, inbound, &sender, &key, &connection_id).await; + if let Ok(mut presence) = state.presence.lock() + && presence + .get(&key) + .is_some_and(|current| current.connection_id == connection_id) + { + presence.remove(&key); + } + if let Err(error) = result { + let _ = sender.send(Err(error)).await; + } + }); + Ok(Response::new(ReceiverStream::new(receiver))) + } +} +fn peer_fingerprint(request: &Request) -> Result { + let peer = request + .extensions() + .get::>() + .and_then(TlsConnectInfo::peer_certs) + .and_then(|certificates| certificates.first().cloned()) + .ok_or_else(|| Status::unauthenticated("client certificate required"))?; + Ok(hex::encode(Sha256::digest(peer.as_ref()))) +} +async fn serve_connection( + state: Arc, + node: CloudNode, + mut inbound: Streaming, + sender: &mpsc::Sender>, + key: &str, + generation: &str, +) -> Result<(), Status> { + let mut interval = tokio::time::interval(Duration::from_millis(500)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut last_heartbeat = Instant::now(); + let mut sent = HashSet::new(); + let mut watched = HashSet::new(); + let mut command_cursor: Option = None; + let mut task_cursor: Option = None; + let mut shutdown = state.shutdown.subscribe(); + loop { + if *shutdown.borrow() { + return Ok(()); + } + tokio::select! { + _=shutdown.changed()=>return Ok(()), + frame=inbound.message()=> { + let Some(frame)=frame? else{return Ok(());}; + match frame.body { + Some(runtime_frame::Body::Heartbeat(heartbeat))=>{ + db(state.repo.clone(),{let node=node.clone();move|repo|repo.authenticate_node(&node.project_id,&node.node_id,&node.certificate_sha256,&node.instance_id)}).await.map_err(status)?; + last_heartbeat=Instant::now(); + let mut presence=state.presence.lock().map_err(|_|Status::unavailable("presence unavailable"))?; + let current=presence.get_mut(key).filter(|current|current.connection_id==generation).ok_or_else(||Status::aborted("connection superseded"))?; + current.ready=heartbeat.ready;current.heartbeat=Some(last_heartbeat); + } + Some(runtime_frame::Body::Receipt(receipt))=>{ + let reply=decode(&receipt.reply_json).map_err(|_|Status::invalid_argument("invalid receipt"))?; + db(state.repo.clone(),{let node=node.clone();move|repo|repo.record_receipt(&node,&receipt.command_id,&receipt.task_id,reply)}).await.map_err(status)?; + } + Some(runtime_frame::Body::Snapshot(snapshot))=>{ + let released=decode(&snapshot.snapshot_json).map_err(|_|Status::invalid_argument("invalid snapshot"))?; + db(state.repo.clone(),{let node=node.clone();move|repo|repo.record_snapshot(&node,&snapshot.task_id,released)}).await.map_err(status)?; + } + Some(runtime_frame::Body::Event(event))=>{ + let update=decode_update(&event.update_json,&event.run_id,event.sequence).map_err(|_|Status::invalid_argument("invalid released event"))?; + let task_id=event.task_id; + let sequence=db(state.repo.clone(),{let node=node.clone();let task_id=task_id.clone();move|repo|repo.record_update(&node,&task_id,update)}).await.map_err(status)?; + send(sender,control_frame::Body::Acknowledgement(wire::EventAcknowledgement{task_id,sequence})).await?; + } + Some(runtime_frame::Body::OutputLimit(limit))=>{ + db(state.repo.clone(),{let node=node.clone();move|repo|repo.record_output_limit(&node,&limit.task_id,&limit.run_id,limit.after_sequence)}).await.map_err(status)?; + return Err(Status::resource_exhausted("released output retention limit")); + } + _=>return Err(Status::invalid_argument("unexpected runtime frame")), + } + } + _=interval.tick()=> { + if last_heartbeat.elapsed()>Duration::from_secs(30){return Err(Status::deadline_exceeded("heartbeat expired"));} + let commands=db(state.repo.clone(),{let node=node.clone();let cursor=command_cursor.clone();move|repo|repo.commands(&node,cursor.as_deref(),100)}).await.map_err(status)?; + command_cursor=if commands.len()==100 {commands.last().map(|command|command.command_id.clone())}else{None}; + for command in commands { + if command.reply.is_none() && !sent.contains(&command.command_id) { + if sent.len()>=4096{return Err(Status::resource_exhausted("connection command limit"));} + let bytes=encode(&command.command).map_err(|_|Status::invalid_argument("invalid command"))?; + send(sender,control_frame::Body::Command(wire::CloudCommand{command_id:command.command_id.clone(),task_id:command.task_id,command_json:bytes,after_sequence:0})).await?; + sent.insert(command.command_id); + } + } + let tasks=db(state.repo.clone(),{let node=node.clone();let cursor=task_cursor.clone();move|repo|repo.node_tasks(&node,cursor.as_deref(),100)}).await.map_err(status)?; + task_cursor=if tasks.len()==100 {tasks.last().map(|task|task.task_id.clone())}else{None}; + for task in tasks { + let Some(run_id)=task.run_id else{continue;}; + let complete=task.snapshot.as_ref().is_some_and(|snapshot|snapshot.run.terminal.is_some()&&task.last_sequence>=snapshot.run.last_sequence); + if !complete && !watched.contains(&task.task_id) { + if watched.len()>=4096{return Err(Status::resource_exhausted("connection watch limit"));} + let bytes=encode(&Command::Watch{run_id,snapshot_only:task.output_limited}).map_err(|_|Status::invalid_argument("invalid watch"))?; + send(sender,control_frame::Body::Command(wire::CloudCommand{command_id:format!("watch-{}",task.task_id),task_id:task.task_id.clone(),command_json:bytes,after_sequence:task.last_sequence})).await?; + watched.insert(task.task_id); + } + } + } + _=sender.closed()=>return Ok(()), + } + } +} +async fn send( + sender: &mpsc::Sender>, + body: control_frame::Body, +) -> Result<(), Status> { + tokio::time::timeout( + Duration::from_secs(10), + sender.send(Ok(wire::ControlFrame { body: Some(body) })), + ) + .await + .map_err(|_| Status::resource_exhausted("connection backpressure deadline"))? + .map_err(|_| Status::cancelled("connection closed")) +} +fn status(error: CloudError) -> Status { + match error { + CloudError::PermissionDenied => { + Status::permission_denied("enrollment revoked or authority absent") + } + CloudError::InvalidArgument => Status::invalid_argument("invalid cloud frame"), + CloudError::NotFound => Status::not_found("allocation not found"), + CloudError::Conflict => Status::aborted("durable reconciliation conflict"), + CloudError::ResourceExhausted => Status::resource_exhausted("cloud resource bound"), + CloudError::Storage => Status::unavailable("canonical storage unavailable"), + } +} diff --git a/crates/colossus-cloud-server/src/http.rs b/crates/colossus-cloud-server/src/http.rs new file mode 100644 index 000000000..844ba0665 --- /dev/null +++ b/crates/colossus-cloud-server/src/http.rs @@ -0,0 +1,451 @@ +use crate::server::{State, db}; +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, Path, Query, State as Extract}, + http::{HeaderMap, StatusCode, header}, + response::{ + Html, IntoResponse, Redirect, Response, + sse::{Event, KeepAlive, Sse}, + }, + routing::{get, post}, +}; +use colossus_cloud::{CertificateRedemption, CloudError, Enrollment}; +use colossus_sdk::{CreateRunRequest, RespondInteractionRequest}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Duration}; + +pub(crate) fn router(state: Arc) -> Router { + Router::new().route("/health/live",get(||async{"ok"})).route("/health/ready",get(ready)) + .route("/auth/login",get(login)).route("/auth/callback",get(callback)).route("/auth/logout",post(logout)) + .route("/api/me",get(me)).route("/api/enroll",post(enroll)) + .route("/api/projects/{project}/nodes",get(nodes)).route("/api/projects/{project}/invitations",post(invite)) + .route("/api/projects/{project}/nodes/{node}/revoke",post(revoke)) + .route("/api/projects/{project}/tasks",get(tasks).post(create)) + .route("/api/projects/{project}/tasks/{task}",get(task)) + .route("/api/projects/{project}/tasks/{task}/commands/{command}",get(command)) + .route("/api/projects/{project}/tasks/{task}/events",get(events)) + .route("/api/projects/{project}/tasks/{task}/cancel",post(cancel)) + .route("/api/projects/{project}/tasks/{task}/respond",post(respond)) + .fallback_service(tower_http::services::ServeDir::new(&state.config.web_root).not_found_service(tower_http::services::ServeFile::new(state.config.web_root.join("index.html")))) + .layer(tower_http::set_header::SetResponseHeaderLayer::if_not_present(header::HeaderName::from_static("x-content-type-options"), header::HeaderValue::from_static("nosniff"))) + .layer(tower_http::set_header::SetResponseHeaderLayer::if_not_present(header::HeaderName::from_static("referrer-policy"),header::HeaderValue::from_static("no-referrer"))) + .layer(tower_http::set_header::SetResponseHeaderLayer::if_not_present(header::HeaderName::from_static("content-security-policy"),header::HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"))) + .layer(DefaultBodyLimit::max(colossus_cloud_protocol::MAX_PAYLOAD_BYTES)) + .layer(axum::middleware::from_fn_with_state(state.clone(), admission)).with_state(state) +} +async fn admission( + Extract(state): Extract>, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> Response { + let Ok(_permit) = state.http_permits.clone().try_acquire_owned() else { + return Error(CloudError::ResourceExhausted).into_response(); + }; + let mut response = match tokio::time::timeout(Duration::from_secs(30), next.run(request)).await + { + Ok(response) => response, + Err(_) => Error(CloudError::Storage).into_response(), + }; + response.headers_mut().insert( + header::CACHE_CONTROL, + header::HeaderValue::from_static("no-store"), + ); + response +} +async fn ready(Extract(state): Extract>) -> Result<&'static str> { + db(state.repo.clone(), |repo| repo.check_readiness()).await?; + Ok("ok") +} +struct Error(CloudError); +impl From for Error { + fn from(error: CloudError) -> Self { + Self(error) + } +} +impl IntoResponse for Error { + fn into_response(self) -> Response { + let status = match self.0 { + CloudError::PermissionDenied => StatusCode::FORBIDDEN, + CloudError::NotFound => StatusCode::NOT_FOUND, + CloudError::Conflict => StatusCode::CONFLICT, + CloudError::ResourceExhausted => StatusCode::TOO_MANY_REQUESTS, + CloudError::InvalidArgument => StatusCode::BAD_REQUEST, + CloudError::Storage => StatusCode::SERVICE_UNAVAILABLE, + }; + ( + status, + [(header::CACHE_CONTROL, "no-store")], + Json(serde_json::json!({"error":self.0})), + ) + .into_response() + } +} +type Result = std::result::Result; +async fn login(Extract(state): Extract>) -> Result { + let (url, cookie) = state.auth.login()?; + let mut response = Redirect::temporary(&url).into_response(); + response.headers_mut().insert(header::SET_COOKIE, cookie); + Ok(response) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Callback { + state: String, + code: String, + #[serde(default)] + session_state: Option, + #[serde(default)] + iss: Option, +} +async fn callback( + Extract(state): Extract>, + headers: HeaderMap, + query: std::result::Result, axum::extract::rejection::QueryRejection>, +) -> Response { + let Ok(Query(query)) = query else { + return sign_in_failed(); + }; + if query + .iss + .as_ref() + .is_some_and(|issuer| issuer != &state.config.oidc.issuer) + { + return sign_in_failed(); + } + let _ = query.session_state; + let cookie = match state + .auth + .callback(&headers, &query.state, query.code) + .await + { + Ok(cookie) => cookie, + Err(_) => return sign_in_failed(), + }; + let mut response = Redirect::to("/").into_response(); + response.headers_mut().insert(header::SET_COOKIE, cookie); + response.headers_mut().insert( + header::CACHE_CONTROL, + header::HeaderValue::from_static("no-store"), + ); + response +} +fn sign_in_failed() -> Response { + ( + StatusCode::FORBIDDEN, + Html(include_str!("sign-in-failed.html")), + ) + .into_response() +} +async fn logout(Extract(state): Extract>, headers: HeaderMap) -> Result { + let cookie = state.auth.logout(&headers)?; + Ok(([(header::SET_COOKIE, cookie)], StatusCode::NO_CONTENT).into_response()) +} +async fn me( + Extract(state): Extract>, + headers: HeaderMap, +) -> Result> { + Ok(Json( + serde_json::json!({"memberships":state.auth.memberships(&headers)?}), + )) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Page { + after: Option, + limit: Option, +} +async fn nodes( + Extract(state): Extract>, + Path(project): Path, + headers: HeaderMap, + Query(page): Query, +) -> Result> { + let caller = state.auth.caller(&headers, &project, false)?; + let nodes = db(state.repo.clone(), move |repo| { + repo.list_nodes(&caller, page.after.as_deref(), page.limit.unwrap_or(100)) + }) + .await?; + let presence = state.presence.lock().map_err(|_| CloudError::Storage)?; + let nodes = nodes + .into_iter() + .map(|node| { + let online = presence + .get(&format!("{}:{}", node.project_id, node.node_id)) + .filter(|online| { + online + .heartbeat + .is_some_and(|time| time.elapsed() < Duration::from_secs(30)) + }); + serde_json::json!({"node":node,"presence":online}) + }) + .collect::>(); + Ok(Json(serde_json::json!({"nodes":nodes}))) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Invite { + label: String, + roles: BTreeSet, +} +async fn invite( + Extract(state): Extract>, + Path(project): Path, + headers: HeaderMap, + Json(input): Json, +) -> Result> { + let caller = state.auth.caller(&headers, &project, true)?; + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).map_err(|_| CloudError::Storage)?; + let token = hex::encode(bytes); + let token_hash = hex::encode(Sha256::digest(token.as_bytes())); + let node_id = uuid::Uuid::now_v7().simple().to_string(); + let invitation = Enrollment { + token_hash, + project_id: project, + node_id: node_id.clone(), + label: input.label, + roles: input.roles, + expires_at: now() + 600, + redeemed_certificate: None, + redeemed_csr: None, + certificate_pem: None, + }; + db(state.repo.clone(), move |repo| { + repo.invite(&caller, invitation, now()) + }) + .await?; + Ok(Json( + serde_json::json!({"token":token,"node_id":node_id,"expires_in":600,"enrollment_url":format!("{}/api/enroll",state.config.public_origin.trim_end_matches('/'))}), + )) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Redeem { + token: String, + instance_id: String, + csr_pem: String, +} +async fn enroll( + Extract(state): Extract>, + Json(input): Json, +) -> Result> { + if input.token.len() != 64 || !input.token.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(CloudError::PermissionDenied.into()); + } + let (certificate, fingerprint) = state + .ca + .sign(&input.csr_pem) + .map_err(|_| CloudError::InvalidArgument)?; + let token_hash = hex::encode(Sha256::digest(input.token.as_bytes())); + let csr_sha256 = hex::encode(Sha256::digest(input.csr_pem.as_bytes())); + let (node, certificate) = db(state.repo.clone(), move |repo| { + repo.redeem( + &token_hash, + &input.instance_id, + CertificateRedemption { + fingerprint, + csr_sha256, + certificate_pem: certificate, + }, + now(), + ) + }) + .await?; + Ok(Json( + serde_json::json!({"node":node,"certificate_pem":certificate,"ca_pem":state.ca.pem,"grpc_endpoint":state.config.grpc_endpoint}), + )) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Revision { + revision: u64, +} +async fn revoke( + Extract(state): Extract>, + Path((project, node)): Path<(String, String)>, + headers: HeaderMap, + Json(input): Json, +) -> Result> { + let caller = state.auth.caller(&headers, &project, true)?; + let node = db(state.repo.clone(), move |repo| { + repo.revoke_node(&caller, &node, input.revision) + }) + .await?; + Ok(Json(serde_json::json!({"node":node}))) +} +async fn tasks( + Extract(state): Extract>, + Path(project): Path, + headers: HeaderMap, + Query(page): Query, +) -> Result> { + let caller = state.auth.caller(&headers, &project, false)?; + let tasks = db(state.repo.clone(), move |repo| { + repo.list_tasks(&caller, page.after.as_deref(), page.limit.unwrap_or(100)) + }) + .await?; + Ok(Json(serde_json::json!({"tasks":tasks}))) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Create { + node_id: String, + request: CreateRunRequest, +} +async fn create( + Extract(state): Extract>, + Path(project): Path, + headers: HeaderMap, + Json(input): Json, +) -> Result> { + let caller = state.auth.caller(&headers, &project, true)?; + let task = db(state.repo.clone(), move |repo| { + repo.create_task(&caller, &input.node_id, input.request) + }) + .await?; + Ok(Json(serde_json::json!({"task":task}))) +} +async fn task( + Extract(state): Extract>, + Path((project, task)): Path<(String, String)>, + headers: HeaderMap, +) -> Result> { + let caller = state.auth.caller(&headers, &project, false)?; + let task = db(state.repo.clone(), move |repo| { + repo.get_task(&caller, &task) + }) + .await?; + Ok(Json(serde_json::json!({"task":task}))) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Mutation { + mutation_id: String, +} +async fn command( + Extract(state): Extract>, + Path((project, task, command)): Path<(String, String, String)>, + headers: HeaderMap, +) -> Result> { + let caller = state.auth.caller(&headers, &project, false)?; + let command = db(state.repo.clone(), move |repo| { + repo.get_command(&caller, &task, &command) + }) + .await?; + Ok(Json(serde_json::json!({"command":command}))) +} +async fn cancel( + Extract(state): Extract>, + Path((project, task)): Path<(String, String)>, + headers: HeaderMap, + Json(input): Json, +) -> Result> { + let caller = state.auth.caller(&headers, &project, true)?; + let command = db(state.repo.clone(), move |repo| { + repo.cancel_task(&caller, &task, &input.mutation_id) + }) + .await?; + Ok(Json(serde_json::json!({"command":command}))) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Respond { + mutation_id: String, + request: RespondInteractionRequest, +} +async fn respond( + Extract(state): Extract>, + Path((project, task)): Path<(String, String)>, + headers: HeaderMap, + Json(input): Json, +) -> Result> { + let caller = state.auth.caller(&headers, &project, true)?; + let command = db(state.repo.clone(), move |repo| { + repo.respond_task(&caller, &task, &input.mutation_id, input.request) + }) + .await?; + Ok(Json(serde_json::json!({"command":command}))) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Cursor { + #[serde(default)] + after: u64, +} +async fn events( + Extract(state): Extract>, + Path((project, task)): Path<(String, String)>, + headers: HeaderMap, + Query(cursor): Query, +) -> Result>>> { + let permit = state + .sse_permits + .clone() + .try_acquire_owned() + .map_err(|_| CloudError::ResourceExhausted)?; + let caller = state.auth.caller(&headers, &project, false)?; + let after = headers + .get("last-event-id") + .map(|value| { + value + .to_str() + .ok() + .and_then(|value| value.parse::().ok()) + .ok_or(CloudError::InvalidArgument) + }) + .transpose()? + .unwrap_or(cursor.after); + let first = db(state.repo.clone(), { + let task = task.clone(); + let caller = caller.clone(); + move |repo| repo.updates(&caller, &task, after, 100) + }) + .await?; + let stream = futures::stream::unfold( + (state, headers, project, task, caller, after, first, permit), + |(state, headers, project, task, caller, mut after, mut pending, permit)| async move { + loop { + if *state.shutdown.borrow() { + return None; + } + state.auth.caller(&headers, &project, false).ok()?; + if let Some(update) = pending.first().cloned() { + pending.remove(0); + after = update.sequence; + let event = Event::default() + .id(after.to_string()) + .event("run_update") + .json_data(update) + .ok()?; + return Some(( + Ok(event), + ( + state, headers, project, task, caller, after, pending, permit, + ), + )); + } + let mut shutdown = state.shutdown.subscribe(); + tokio::select! { + _ = tokio::time::sleep(Duration::from_millis(300)) => {}, + _ = shutdown.changed() => return None, + } + state.auth.caller(&headers, &project, false).ok()?; + pending = db(state.repo.clone(), { + let task = task.clone(); + let caller = caller.clone(); + move |repo| repo.updates(&caller, &task, after, 100) + }) + .await + .ok()?; + } + }, + ); + Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(10)))) +} +pub(crate) fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} diff --git a/crates/colossus-cloud-server/src/lib.rs b/crates/colossus-cloud-server/src/lib.rs new file mode 100644 index 000000000..d00954cfc --- /dev/null +++ b/crates/colossus-cloud-server/src/lib.rs @@ -0,0 +1,10 @@ +//! Authenticated browser and outbound runtime transports for the cloud application. +mod auth; +mod certificates; +/// Secret-reference-only Kubernetes and local host configuration. +pub mod config; +mod connection; +mod http; +/// Composed HTTP/OIDC and mutual-TLS server lifecycle. +pub mod server; +mod tls; diff --git a/crates/colossus-cloud-server/src/main.rs b/crates/colossus-cloud-server/src/main.rs new file mode 100644 index 000000000..75e888b2f --- /dev/null +++ b/crates/colossus-cloud-server/src/main.rs @@ -0,0 +1,33 @@ +//! Kubernetes control-plane entry point. Configuration contains secret references only. +use colossus_cloud_server::{config::Config, server::CloudServer}; +#[tokio::main] +async fn main() -> Result<(), Box> { + let path = std::env::args_os() + .nth(1) + .ok_or("usage: colossus-cloud-server CONFIG.json")?; + let config: Config = serde_json::from_slice(&std::fs::read(path)?)?; + let server = CloudServer::open(config).await?; + let (sender, receiver) = tokio::sync::watch::channel(false); + tokio::spawn(async move { + shutdown_signal().await; + let _ = sender.send(true); + }); + server.serve(receiver).await?; + Ok(()) +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + if let Ok(mut signal) = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + { + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = signal.recv() => {}, + } + return; + } + } + let _ = tokio::signal::ctrl_c().await; +} diff --git a/crates/colossus-cloud-server/src/server.rs b/crates/colossus-cloud-server/src/server.rs new file mode 100644 index 000000000..345b3f04a --- /dev/null +++ b/crates/colossus-cloud-server/src/server.rs @@ -0,0 +1,205 @@ +use crate::{ + auth::Authentication, + certificates::CertificateAuthority, + config::{Config, Storage}, +}; +use colossus_cloud::{CloudError, CloudRepository, CloudResult}; +use colossus_journal_redb::{ + DisabledCheckpointSigner, Ed25519CheckpointSigner, EnvironmentKeyProvider, + PlaintextKeyProvider, RedbEventJournal, +}; +use colossus_ports::{CheckpointSigner, EventJournal, KeyProvider}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Instant, +}; + +/// Sanitized online state; connectivity never changes a durable task's placement. +#[derive(Clone, serde::Serialize)] +pub struct NodePresence { + /// Current stream generation. + pub connection_id: String, + /// Runtime advertised readiness. + pub ready: bool, + /// Bounded released capability names. + pub capabilities: Vec, + #[serde(skip)] + pub(crate) heartbeat: Option, +} +pub(crate) struct State { + pub config: Config, + pub repo: CloudRepository, + pub auth: Authentication, + pub ca: CertificateAuthority, + pub presence: Mutex>, + pub http_permits: Arc, + pub sse_permits: Arc, + pub shutdown: tokio::sync::watch::Sender, +} +/// Fully composed HTTP/OIDC and mutual-TLS runtime host. +pub struct CloudServer { + pub(crate) state: Arc, +} +impl CloudServer { + /// Compose only after strict configuration, journal verification, and OIDC discovery. + pub async fn open(config: Config) -> Result { + config.validate()?; + let storage = config.storage.clone(); + let signing_variable = config.signing_key_variable.clone(); + let journal = + tokio::task::spawn_blocking(move || -> Result, &'static str> { + let signer: Arc = match signing_variable { + Some(variable) => { + let value = zeroize::Zeroizing::new( + std::env::var(variable).map_err(|_| "cloud signing key unavailable")?, + ); + let mut secret = zeroize::Zeroizing::new([0u8; 32]); + hex::decode_to_slice(value.trim(), secret.as_mut()) + .map_err(|_| "cloud signing key invalid")?; + Arc::new(Ed25519CheckpointSigner::new("cloud-signing-v1", *secret)) + } + None => Arc::new(DisabledCheckpointSigner), + }; + match storage { + Storage::Redb { path, key_variable } => { + let keys: Arc = match key_variable { + Some(variable) => Arc::new(EnvironmentKeyProvider::new( + variable, + "cloud-v1", + path.with_extension("anchor"), + )), + None => Arc::new(PlaintextKeyProvider), + }; + Ok(Arc::new( + RedbEventJournal::open(path, keys, signer) + .map_err(|_| "cloud journal unavailable")?, + )) + } + Storage::Postgres { + config, + key_variable, + anchor_path, + } => Ok(Arc::new( + colossus_journal_postgres::PostgresEventJournal::open( + config, + Arc::new(EnvironmentKeyProvider::new( + key_variable, + "cloud-v1", + anchor_path, + )), + signer, + ) + .map_err(|_| "cloud PostgreSQL journal unavailable")?, + )), + } + }) + .await + .map_err(|_| "cloud journal initialization failed")??; + Self::with_journal(config, journal).await + } + /// Compose with an independently owned canonical journal; useful for acceptance. + pub async fn with_journal( + config: Config, + journal: Arc, + ) -> Result { + config.validate()?; + let repo = CloudRepository::new(journal).map_err(|_| "cloud journal is recovering")?; + let ca = CertificateAuthority::load(&config)?; + let auth = Authentication::new(config.clone()).await?; + Ok(Self { + state: Arc::new(State { + config, + repo, + auth, + ca, + presence: Mutex::new(HashMap::new()), + http_permits: Arc::new(tokio::sync::Semaphore::new(128)), + sse_permits: Arc::new(tokio::sync::Semaphore::new(64)), + shutdown: tokio::sync::watch::channel(false).0, + }), + }) + } + /// Run browser HTTP and end-to-end mutual-TLS gRPC until shutdown. Ingress must + /// preserve HTTP/2 and TLS passthrough on the connector endpoint. + pub async fn serve( + self, + shutdown: tokio::sync::watch::Receiver, + ) -> Result<(), &'static str> { + let config = &self.state.config; + let grpc_listener = tokio::net::TcpListener::bind(config.grpc_bind) + .await + .map_err(|_| "gRPC listener unavailable")?; + let listener = tokio::net::TcpListener::bind(config.http_bind) + .await + .map_err(|_| "HTTP listener unavailable")?; + self.serve_listeners(listener, grpc_listener, shutdown) + .await + } + pub(crate) async fn serve_listeners( + self, + listener: tokio::net::TcpListener, + grpc_listener: tokio::net::TcpListener, + shutdown: tokio::sync::watch::Receiver, + ) -> Result<(), &'static str> { + let config = &self.state.config; + let incoming = crate::tls::incoming(grpc_listener, config, &self.state.ca.pem)?; + let grpc = tonic::transport::Server::builder() + .concurrency_limit_per_connection(2) + .timeout(std::time::Duration::from_secs(3600)) + .add_service(crate::connection::service(self.state.clone())); + let http = axum::serve(listener, crate::http::router(self.state.clone())) + .with_graceful_shutdown(wait_shutdown(shutdown.clone())); + let shutdown_for_streams = shutdown.clone(); + let grpc = grpc.serve_with_incoming_shutdown(incoming, wait_shutdown(shutdown)); + let drain = async { + tokio::try_join!( + async { http.await.map_err(|_| "HTTP server stopped") }, + async { grpc.await.map_err(|_| "gRPC server stopped") } + )?; + Ok::<(), &'static str>(()) + }; + tokio::pin!(drain); + let state = self.state.clone(); + let forward = async move { + wait_shutdown(shutdown_for_streams).await; + state.shutdown.send_replace(true); + }; + tokio::pin!(forward); + tokio::select! { + result = &mut drain => result?, + _ = &mut forward => { + tokio::time::timeout(std::time::Duration::from_secs(30), &mut drain) + .await.map_err(|_| "cloud shutdown drain deadline exceeded")??; + } + } + Ok(()) + } +} +async fn wait_shutdown(mut receiver: tokio::sync::watch::Receiver) { + loop { + if *receiver.borrow() { + return; + } + if receiver.changed().await.is_err() { + return; + } + } +} +pub(crate) async fn db( + repo: CloudRepository, + action: impl FnOnce(CloudRepository) -> CloudResult + Send + 'static, +) -> CloudResult { + static PERMITS: std::sync::OnceLock> = std::sync::OnceLock::new(); + let permit = PERMITS + .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(128))) + .clone() + .try_acquire_owned() + .map_err(|_| CloudError::ResourceExhausted)?; + tokio::task::spawn_blocking(move || { + let _permit = permit; + action(repo) + }) + .await + .map_err(|_| CloudError::Storage)? +} diff --git a/crates/colossus-cloud-server/src/sign-in-failed.html b/crates/colossus-cloud-server/src/sign-in-failed.html new file mode 100644 index 000000000..1e334a740 --- /dev/null +++ b/crates/colossus-cloud-server/src/sign-in-failed.html @@ -0,0 +1,78 @@ + + + + + + Sign-in interrupted · Colossus Cloud + + + +
+
COLOSSUS CLOUD
+

Let’s try signing in again.

+

+ Your sign-in could not be completed. The request may have expired. + Return to Colossus and start a fresh sign-in. +

+ Return to Colossus +
+ + diff --git a/crates/colossus-cloud-server/src/tls.rs b/crates/colossus-cloud-server/src/tls.rs new file mode 100644 index 000000000..73a0783e4 --- /dev/null +++ b/crates/colossus-cloud-server/src/tls.rs @@ -0,0 +1,64 @@ +use crate::config::Config; +use futures::{Stream, StreamExt}; +use rustls::{ + RootCertStore, ServerConfig, + pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject}, + server::WebPkiClientVerifier, +}; +use std::{sync::Arc, time::Duration}; +use tokio::{ + net::{TcpListener, TcpStream}, + time::timeout, +}; +use tokio_rustls::{TlsAcceptor, server::TlsStream}; +use tokio_stream::wrappers::TcpListenerStream; +use zeroize::Zeroizing; + +pub(crate) fn incoming( + listener: TcpListener, + config: &Config, + ca_pem: &str, +) -> Result, std::io::Error>>, &'static str> { + let certificate = + std::fs::read(&config.server_certificate).map_err(|_| "cannot load server certificate")?; + let key = + Zeroizing::new(std::fs::read(&config.server_key).map_err(|_| "cannot load server key")?); + let chain = CertificateDer::pem_slice_iter(&certificate) + .collect::, _>>() + .map_err(|_| "invalid server certificate")?; + let key = PrivateKeyDer::from_pem_slice(&key).map_err(|_| "invalid server key")?; + let mut roots = RootCertStore::empty(); + for certificate in CertificateDer::pem_slice_iter(ca_pem.as_bytes()) { + roots + .add(certificate.map_err(|_| "invalid client CA")?) + .map_err(|_| "invalid client CA")?; + } + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let verifier = WebPkiClientVerifier::builder_with_provider(Arc::new(roots), provider.clone()) + .build() + .map_err(|_| "invalid client CA")?; + let mut tls = ServerConfig::builder_with_provider(provider) + .with_protocol_versions(&[&rustls::version::TLS13]) + .map_err(|_| "TLS 1.3 unavailable")? + .with_client_cert_verifier(verifier) + .with_single_cert(chain, key) + .map_err(|_| "invalid TLS identity")?; + tls.alpn_protocols = vec![b"h2".to_vec()]; + let acceptor = TlsAcceptor::from(Arc::new(tls)); + Ok(TcpListenerStream::new(listener) + .map(move |socket| { + let acceptor = acceptor.clone(); + async move { + match socket { + Ok(socket) => timeout(Duration::from_secs(5), acceptor.accept(socket)) + .await + .ok() + .and_then(Result::ok) + .map(Ok), + Err(error) => Some(Err(error)), + } + } + }) + .buffer_unordered(64) + .filter_map(|result| async move { result })) +} diff --git a/crates/colossus-cloud/Cargo.toml b/crates/colossus-cloud/Cargo.toml new file mode 100644 index 000000000..62deb2c56 --- /dev/null +++ b/crates/colossus-cloud/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "colossus-cloud" +publish = false +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +colossus-cloud-protocol.workspace = true +colossus-contracts.workspace = true +colossus-ports.workspace = true +colossus-sdk = { workspace = true, features = ["serialization"] } +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +hex.workspace = true +thiserror.workspace = true +uuid.workspace = true + +[dev-dependencies] +colossus-journal-redb.workspace = true +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/colossus-cloud/src/enrollment.rs b/crates/colossus-cloud/src/enrollment.rs new file mode 100644 index 000000000..fac746f92 --- /dev/null +++ b/crates/colossus-cloud/src/enrollment.rs @@ -0,0 +1,50 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// Single-use enrollment invitation, bound to administrator-reviewed placement. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Enrollment { + /// Hash of the high-entropy invitation, never its redeemable plaintext. + pub token_hash: String, + /// Fixed owning project. + pub project_id: String, + /// Fixed node identity. + pub node_id: String, + /// Administrator-reviewed display label. + pub label: String, + /// Additional role ceiling, independent of the local grant. + pub roles: BTreeSet, + /// Unix second expiry enforced at redemption. + pub expires_at: u64, + /// Exact enrolled certificate hash after redemption. + pub redeemed_certificate: Option, + /// CSR hash bound to the consumed invitation for exact retry reconciliation. + pub redeemed_csr: Option, + /// Public issued certificate retained to reconcile a lost enrollment response. + pub certificate_pem: Option, +} + +/// Host-validated issuance, carrying no private signing or connector key. +pub struct CertificateRedemption { + /// Exact issued leaf hash. + pub fingerprint: String, + /// Exact verified CSR hash. + pub csr_sha256: String, + /// Issued public leaf PEM. + pub certificate_pem: String, +} + +/// Exact identity authenticated by the existing mTLS leaf during certificate renewal. +pub struct RenewalIdentity<'a> { + /// Owning project. + pub project_id: &'a str, + /// Fixed enrolled node. + pub node_id: &'a str, + /// Independently bound local runtime. + pub instance_id: &'a str, + /// SHA-256 of the presented client leaf. + pub previous_fingerprint: &'a str, + /// Durable native-generated renewal identity. + pub renewal_id: &'a str, +} diff --git a/crates/colossus-cloud/src/error.rs b/crates/colossus-cloud/src/error.rs new file mode 100644 index 000000000..71fba8e81 --- /dev/null +++ b/crates/colossus-cloud/src/error.rs @@ -0,0 +1,35 @@ +/// Bounded categorical cloud service failure, without credential or payload diagnostics. +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, thiserror::Error)] +#[serde(rename_all = "snake_case")] +pub enum CloudError { + /// Required project permission or enrolled node authority is absent. + #[error("cloud authority is absent or revoked")] + PermissionDenied, + /// A strict field or identity binding was invalid. + #[error("cloud request is invalid")] + InvalidArgument, + /// Resource is absent within the authenticated project. + #[error("cloud resource was not found")] + NotFound, + /// A mutation identity, revision, or event cursor conflicts with durable state. + #[error("cloud state conflicts with this request")] + Conflict, + /// A configured resource budget was exhausted. + #[error("cloud resource bound was exceeded")] + ResourceExhausted, + /// Canonical storage or integrity verification is unavailable. + #[error("cloud storage is unavailable")] + Storage, +} + +/// Result returned by project-scoped cloud services. +pub type CloudResult = Result; + +impl From for CloudError { + fn from(error: colossus_ports::StoreError) -> Self { + match error { + colossus_ports::StoreError::Conflict { .. } => Self::Conflict, + _ => Self::Storage, + } + } +} diff --git a/crates/colossus-cloud/src/identity.rs b/crates/colossus-cloud/src/identity.rs new file mode 100644 index 000000000..c551b9e8c --- /dev/null +++ b/crates/colossus-cloud/src/identity.rs @@ -0,0 +1,67 @@ +use crate::{CloudError, CloudResult, validate_identifier}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// Cloud permission independent of the runtime's local role/tool/scope ceiling. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CloudPermission { + /// Read the project's registered runtimes and released tasks. + Read, + /// Submit agent tasks beneath the selected node's grant. + Execute, + /// Cooperatively cancel project tasks and answer ordinary prompts. + Control, + /// Answer exact runtime-bound effect approvals. + Approve, + /// Enroll and revoke project nodes. + Administer, +} + +/// Project authority constructed only after the host authenticates an identity. +/// +/// Browser request bodies never supply this context. OIDC membership is resolved by +/// trusted host configuration, not by an arbitrary project field or end_user_id. +#[derive(Clone, Debug)] +pub struct CloudCaller { + pub(crate) subject: String, + pub(crate) project_id: String, + permissions: BTreeSet, +} + +impl CloudCaller { + /// Bind one verified subject to an explicit project membership and permission set. + pub fn new( + subject: String, + project_id: String, + permissions: BTreeSet, + ) -> CloudResult { + validate_identifier(&project_id)?; + if subject.is_empty() || subject.len() > 256 || subject.chars().any(char::is_control) { + return Err(CloudError::InvalidArgument); + } + Ok(Self { + subject, + project_id, + permissions, + }) + } + + /// Authenticated subject, suitable for bounded audit correlation. + pub fn subject(&self) -> &str { + &self.subject + } + + /// Exact project selected from authenticated membership. + pub fn project_id(&self) -> &str { + &self.project_id + } + + /// Enforce one exact cloud permission. Administer is not implicit execution. + pub fn require(&self, permission: CloudPermission) -> CloudResult<()> { + self.permissions + .contains(&permission) + .then_some(()) + .ok_or(CloudError::PermissionDenied) + } +} diff --git a/crates/colossus-cloud/src/lib.rs b/crates/colossus-cloud/src/lib.rs new file mode 100644 index 000000000..2648a10c5 --- /dev/null +++ b/crates/colossus-cloud/src/lib.rs @@ -0,0 +1,22 @@ +//! Project-scoped cloud placement and released-data services over the canonical journal. +//! +//! This crate never constructs a runtime, executes tools, or grants local authority. +//! Transports authenticate callers and nodes before entering these services. + +mod enrollment; +mod error; +mod identity; +mod repository; +mod types; +mod validation; + +pub use enrollment::{CertificateRedemption, Enrollment, RenewalIdentity}; + +pub use error::{CloudError, CloudResult}; +pub use identity::{CloudCaller, CloudPermission}; +pub use repository::CloudRepository; +pub use types::*; +pub use validation::validate_identifier; + +#[cfg(test)] +mod tests; diff --git a/crates/colossus-cloud/src/repository.rs b/crates/colossus-cloud/src/repository.rs new file mode 100644 index 000000000..6d03856f6 --- /dev/null +++ b/crates/colossus-cloud/src/repository.rs @@ -0,0 +1,91 @@ +use crate::{ + CloudCaller, CloudError, CloudNode, CloudPermission, CloudResult, CloudTask, PendingCommand, +}; +use colossus_cloud_protocol::{CloudReply, Command}; +use colossus_contracts::{Actor, ActorType, EventClassification, ExecutionContext, NewEvent}; +use colossus_ports::EventJournal; +use serde::{Serialize, de::DeserializeOwned}; +use std::sync::Arc; + +mod delivery; +mod enrollment; +mod nodes; +mod renewal; +mod tasks; + +/// Durable cloud application operations; storage adapters remain replaceable. +#[derive(Clone)] +pub struct CloudRepository { + pub(super) journal: Arc, +} + +impl CloudRepository { + /// Probe canonical storage without scanning or releasing project payloads. + pub fn check_readiness(&self) -> CloudResult<()> { + if self.journal.is_recovery_mode() { + return Err(CloudError::Storage); + } + self.journal.list_stream_ids("cloud.node:", None, 1)?; + Ok(()) + } + /// Bind a cloud-specific canonical journal. Never share a worker's writer identity. + pub fn new(journal: Arc) -> CloudResult { + if journal.is_recovery_mode() { + return Err(CloudError::Storage); + } + Ok(Self { journal }) + } + + pub(super) fn read(&self, stream: &str) -> CloudResult<(T, u64)> { + let events = self.journal.read_stream_backwards(stream, None, 1)?; + let event = events.first().ok_or(CloudError::NotFound)?; + if event.event_version != 1 || !event.event_type.starts_with("cloud.") { + return Err(CloudError::Storage); + } + let value = self.journal.decrypt_payload(event)?; + Ok(( + serde_json::from_value(value).map_err(|_| CloudError::Storage)?, + event.stream_version, + )) + } + + pub(super) fn event( + &self, + subject: &str, + stream: String, + version: u64, + kind: &str, + value: &T, + ) -> CloudResult { + Ok(NewEvent { + event_version: 1, + stream_id: stream, + expected_stream_version: version, + classification: EventClassification::Domain, + event_type: kind.into(), + actor: Actor { + actor_type: ActorType::Application, + id: subject.into(), + }, + context: ExecutionContext::default(), + payload: serde_json::to_value(value).map_err(|_| CloudError::InvalidArgument)?, + }) + } + + pub(super) fn append( + &self, + subject: &str, + stream: String, + version: u64, + kind: &str, + value: &T, + ) -> CloudResult<()> { + self.commit(vec![self.event(subject, stream, version, kind, value)?])?; + Ok(()) + } + pub(super) fn commit(&self, events: Vec) -> Result<(), colossus_ports::StoreError> { + self.journal.append_batch(events)?; + self.journal.checkpoint()?; + Ok(()) + } +} diff --git a/crates/colossus-cloud/src/repository/delivery.rs b/crates/colossus-cloud/src/repository/delivery.rs new file mode 100644 index 000000000..a845629f6 --- /dev/null +++ b/crates/colossus-cloud/src/repository/delivery.rs @@ -0,0 +1,378 @@ +use super::*; +use colossus_cloud_protocol::encode; +use colossus_sdk::{GetRunResponse, RunUpdate}; + +const MAX_EVENTS: u64 = 4096; +const MAX_RELEASED_BYTES: usize = 16 * 1024 * 1024; + +fn event_stream(project: &str, task: &str) -> String { + format!("cloud.output:{project}:{task}") +} + +impl CloudRepository { + /// Read one task-bound receipt within the caller's authorized project. + pub fn get_command( + &self, + caller: &CloudCaller, + task_id: &str, + command_id: &str, + ) -> CloudResult { + caller.require(CloudPermission::Read)?; + crate::validate_identifier(command_id)?; + let task = self.task(caller.project_id(), task_id)?; + let (command, _) = self.read::(&tasks::command_stream( + caller.project_id(), + &task.node_id, + command_id, + ))?; + if command.task_id != task_id || command.node_id != task.node_id { + return Err(CloudError::PermissionDenied); + } + Ok(command) + } + + /// Read a bounded command page, including receipts, for an authenticated live node. + /// Hosts advance through all pages so acknowledged commands cannot bury pending work. + pub fn commands( + &self, + node: &CloudNode, + after: Option<&str>, + limit: usize, + ) -> CloudResult> { + self.live_node(node)?; + if let Some(after) = after { + crate::validate_identifier(after)?; + } + let prefix = format!("cloud.command:{}:{}:", node.project_id, node.node_id); + let cursor = after.map(|id| format!("{prefix}{id}")); + self.journal + .list_stream_ids(&prefix, cursor.as_deref(), limit.min(100))? + .into_iter() + .map(|stream| { + self.read::(&stream) + .map(|(command, _)| command) + }) + .collect() + } + + /// Reconcile an exact reply and immutable run allocation atomically. A lost ACK + /// repeats the same command identity; it never allocates a second task or run. + pub fn record_receipt( + &self, + node: &CloudNode, + command_id: &str, + task_id: &str, + reply: CloudReply, + ) -> CloudResult<()> { + self.live_node(node)?; + crate::validate_identifier(command_id)?; + let stream = tasks::command_stream(&node.project_id, &node.node_id, command_id); + let (mut command, revision) = self.read::(&stream)?; + if command.task_id != task_id || command.node_id != node.node_id { + return Err(CloudError::PermissionDenied); + } + if let Some(existing) = &command.reply { + return if existing == &reply { + self.journal.checkpoint()?; + Ok(()) + } else { + Err(CloudError::Conflict) + }; + } + let mut task = self.task(&node.project_id, task_id)?; + if task.node_id != node.node_id { + return Err(CloudError::PermissionDenied); + } + let mut events = Vec::new(); + match (&command.command, &reply) { + (Command::Create { .. }, CloudReply::Run { run }) => { + crate::validate_identifier(&run.run.run_id)?; + if run.run.role != task.request.role + || run.run.mode != task.request.mode + || run + .pending_interactions + .iter() + .any(|interaction| interaction.run_id != run.run.run_id) + { + return Err(CloudError::InvalidArgument); + } + let allocation_stream = format!( + "cloud.run:{}:{}:{}", + node.project_id, node.node_id, run.run.run_id + ); + match self.read::(&allocation_stream) { + Ok((allocated, _)) if allocated == task_id => {} + Ok(_) => return Err(CloudError::Conflict), + Err(CloudError::NotFound) => events.push(self.event( + &node.node_id, + allocation_stream, + 0, + "cloud.run.allocated.v1", + &task_id, + )?), + Err(error) => return Err(error), + } + if task.run_id.as_ref().is_some_and(|id| id != &run.run.run_id) { + return Err(CloudError::Conflict); + } + task.run_id = Some(run.run.run_id.clone()); + task.snapshot = Some((**run).clone()); + let version = task.revision; + task.revision += 1; + events.push(self.event( + &node.node_id, + tasks::stream(&node.project_id, task_id), + version, + "cloud.task.accepted.v1", + &task, + )?); + } + (Command::Create { .. }, CloudReply::Failed { error }) => { + let version = task.revision; + task.revision += 1; + task.dispatch_error = Some(error.clone()); + events.push(self.event( + &node.node_id, + tasks::stream(&node.project_id, task_id), + version, + "cloud.task.dispatch-failed.v1", + &task, + )?); + } + (Command::Cancel { run_id, .. }, CloudReply::Cancelled { response }) + if response.run.run_id == *run_id => {} + (Command::Respond { request }, CloudReply::Responded { response }) + if response.interaction.run_id == request.run_id + && response.interaction.interaction_id == request.interaction_id => {} + (_, CloudReply::Failed { .. }) => {} + _ => return Err(CloudError::InvalidArgument), + } + command.reply = Some(reply); + command.revision = revision + 1; + events.push(self.event( + &node.node_id, + stream, + revision, + "cloud.command.reconciled.v1", + &command, + )?); + self.commit(events)?; + Ok(()) + } + + /// Replace a released snapshot only when it belongs to the fixed run and does + /// not regress its runtime cursor. Output replay has its own contiguous cursor. + pub fn record_snapshot( + &self, + node: &CloudNode, + task_id: &str, + snapshot: GetRunResponse, + ) -> CloudResult<()> { + self.live_node(node)?; + let mut task = self.task(&node.project_id, task_id)?; + if task.node_id != node.node_id || task.run_id.as_deref() != Some(&snapshot.run.run_id) { + return Err(CloudError::PermissionDenied); + } + if snapshot + .pending_interactions + .iter() + .any(|interaction| interaction.run_id != snapshot.run.run_id) + { + return Err(CloudError::InvalidArgument); + } + if task + .snapshot + .as_ref() + .is_some_and(|current| current.run.last_sequence > snapshot.run.last_sequence) + { + return Err(CloudError::Conflict); + } + if task.snapshot.as_ref() == Some(&snapshot) { + return Ok(()); + } + let revision = task.revision; + task.revision += 1; + task.snapshot = Some(snapshot); + self.append( + &node.node_id, + tasks::stream(&node.project_id, task_id), + revision, + "cloud.task.snapshot.v1", + &task, + ) + } + + /// Commit one caller-released event before acknowledging it. Reject gaps and + /// changed duplicates; both the event and resume cursor survive host restart. + pub fn record_update( + &self, + node: &CloudNode, + task_id: &str, + update: RunUpdate, + ) -> CloudResult { + self.live_node(node)?; + let mut task = self.task(&node.project_id, task_id)?; + if task.node_id != node.node_id || task.run_id.as_deref() != Some(&update.run_id) { + return Err(CloudError::PermissionDenied); + } + let stream = event_stream(&node.project_id, task_id); + if update.sequence == 0 { + return Err(CloudError::InvalidArgument); + } + if update.sequence <= task.last_sequence { + let events = self + .journal + .read_stream_from(&stream, update.sequence - 1, 1)?; + let event = events.first().ok_or(CloudError::Storage)?; + let existing: RunUpdate = serde_json::from_value(self.journal.decrypt_payload(event)?) + .map_err(|_| CloudError::Storage)?; + return if existing == update { + self.journal.checkpoint()?; + Ok(task.last_sequence) + } else { + Err(CloudError::Conflict) + }; + } + if update.sequence != task.last_sequence + 1 { + return Err(CloudError::Conflict); + } + let encoded = encode(&update); + let bytes = encoded.as_ref().map_or(usize::MAX, Vec::len); + if encoded.is_err() + || update.sequence > MAX_EVENTS + || task.released_bytes.saturating_add(bytes) > MAX_RELEASED_BYTES + { + if !task.output_limited { + let revision = task.revision; + task.revision += 1; + task.output_limited = true; + self.append( + &node.node_id, + tasks::stream(&node.project_id, task_id), + revision, + "cloud.task.output-limited.v1", + &task, + )?; + } + return Err(CloudError::ResourceExhausted); + } + let revision = task.revision; + task.revision += 1; + task.last_sequence = update.sequence; + task.released_bytes += bytes; + self.commit(vec![ + self.event( + &node.node_id, + stream, + update.sequence - 1, + "cloud.output.released.v1", + &update, + )?, + self.event( + &node.node_id, + tasks::stream(&node.project_id, task_id), + revision, + "cloud.task.cursor.v1", + &task, + )?, + ])?; + Ok(task.last_sequence) + } + + /// Stop cloud event retention without advancing an unreceived cursor. Only + /// the enrolled owner of this exact run may report the local frame bound. + pub fn record_output_limit( + &self, + node: &CloudNode, + task_id: &str, + run_id: &str, + after_sequence: u64, + ) -> CloudResult<()> { + self.live_node(node)?; + let mut task = self.task(&node.project_id, task_id)?; + if task.node_id != node.node_id || task.run_id.as_deref() != Some(run_id) { + return Err(CloudError::PermissionDenied); + } + if task.last_sequence != after_sequence { + return Err(CloudError::Conflict); + } + if !task.output_limited { + let revision = task.revision; + task.revision += 1; + task.output_limited = true; + self.append( + &node.node_id, + tasks::stream(&node.project_id, task_id), + revision, + "cloud.task.output-limited.v1", + &task, + )?; + } else { + self.journal.checkpoint()?; + } + Ok(()) + } + + /// Read a bounded, exclusive-cursor page of released output inside one project. + pub fn updates( + &self, + caller: &CloudCaller, + task_id: &str, + after: u64, + limit: usize, + ) -> CloudResult> { + caller.require(CloudPermission::Read)?; + let task = self.task(caller.project_id(), task_id)?; + if after > task.last_sequence { + return Err(CloudError::Conflict); + } + self.journal + .read_stream_from( + &event_stream(caller.project_id(), task_id), + after, + limit.min(100), + )? + .into_iter() + .map(|event| { + serde_json::from_value(self.journal.decrypt_payload(&event)?) + .map_err(|_| CloudError::Storage) + }) + .collect() + } + + /// Read the exact allocation for an authenticated node; never accept a cloud + /// caller's project selection or a node's arbitrary local run identifier. + pub fn node_task(&self, node: &CloudNode, task_id: &str) -> CloudResult { + self.live_node(node)?; + let task = self.task(&node.project_id, task_id)?; + if task.node_id != node.node_id { + return Err(CloudError::PermissionDenied); + } + Ok(task) + } +} + +impl CloudRepository { + /// Page fixed-node allocations through their own durable placement index. + pub fn node_tasks( + &self, + node: &CloudNode, + after: Option<&str>, + limit: usize, + ) -> CloudResult> { + self.live_node(node)?; + if let Some(after) = after { + crate::validate_identifier(after)?; + } + let prefix = format!("cloud.node-task:{}:{}:", node.project_id, node.node_id); + let cursor = after.map(|id| format!("{prefix}{id}")); + self.journal + .list_stream_ids(&prefix, cursor.as_deref(), limit.min(100))? + .into_iter() + .map(|stream| { + let (id, _) = self.read::(&stream)?; + self.node_task(node, &id) + }) + .collect() + } +} diff --git a/crates/colossus-cloud/src/repository/enrollment.rs b/crates/colossus-cloud/src/repository/enrollment.rs new file mode 100644 index 000000000..635d3c2dd --- /dev/null +++ b/crates/colossus-cloud/src/repository/enrollment.rs @@ -0,0 +1,113 @@ +use super::*; +use crate::{ + CertificateRedemption, Enrollment, validate_identifier, validation::bounded_fingerprint, +}; + +impl CloudRepository { + /// Allocate an expiring invitation beneath explicit administration authority. + pub fn invite( + &self, + caller: &CloudCaller, + invitation: Enrollment, + now: u64, + ) -> CloudResult<()> { + caller.require(CloudPermission::Administer)?; + bounded_fingerprint(&invitation.token_hash)?; + validate_identifier(&invitation.node_id)?; + if invitation.project_id != caller.project_id() + || invitation.redeemed_certificate.is_some() + || invitation.redeemed_csr.is_some() + || invitation.certificate_pem.is_some() + || invitation.expires_at <= now + || invitation.expires_at > now.saturating_add(600) + || invitation.label.is_empty() + || invitation.label.len() > 128 + || invitation.label.chars().any(char::is_control) + || invitation.roles.is_empty() + || invitation.roles.len() > 32 + { + return Err(CloudError::InvalidArgument); + } + for role in &invitation.roles { + validate_identifier(role)?; + } + self.append( + caller.subject(), + format!("cloud.invitation:{}", invitation.token_hash), + 0, + "cloud.invitation.created.v1", + &invitation, + ) + } + + /// Atomically consume an invitation and enroll its fixed placement. A repeated + /// redeem with the same certificate/instance reconciles a lost HTTP response. + pub fn redeem( + &self, + token_hash: &str, + instance_id: &str, + certificate: CertificateRedemption, + now: u64, + ) -> CloudResult<(CloudNode, String)> { + bounded_fingerprint(token_hash)?; + bounded_fingerprint(&certificate.fingerprint)?; + bounded_fingerprint(&certificate.csr_sha256)?; + if certificate.certificate_pem.len() > 16384 { + return Err(CloudError::InvalidArgument); + } + validate_identifier(instance_id)?; + let invitation_stream = format!("cloud.invitation:{token_hash}"); + let (mut invitation, revision) = self + .read::(&invitation_stream) + .map_err(|_| CloudError::PermissionDenied)?; + if let Some(existing) = &invitation.redeemed_certificate { + if invitation.redeemed_csr.as_deref() != Some(&certificate.csr_sha256) { + return Err(CloudError::PermissionDenied); + } + return self + .authenticate_node( + &invitation.project_id, + &invitation.node_id, + existing, + instance_id, + ) + .and_then(|node| { + self.journal.checkpoint()?; + Ok((node, invitation.certificate_pem.ok_or(CloudError::Storage)?)) + }); + } + if invitation.expires_at <= now { + return Err(CloudError::PermissionDenied); + } + let node = CloudNode { + node_id: invitation.node_id.clone(), + project_id: invitation.project_id.clone(), + instance_id: instance_id.into(), + label: invitation.label.clone(), + certificate_sha256: certificate.fingerprint.clone(), + roles: invitation.roles.clone(), + revoked: false, + revision: 1, + }; + invitation.redeemed_certificate = Some(certificate.fingerprint); + invitation.redeemed_csr = Some(certificate.csr_sha256); + invitation.certificate_pem = Some(certificate.certificate_pem.clone()); + self.commit(vec![ + self.event( + "enrollment", + invitation_stream, + revision, + "cloud.invitation.redeemed.v1", + &invitation, + )?, + self.event( + "enrollment", + format!("cloud.node:{}:{}", node.project_id, node.node_id), + 0, + "cloud.node.enrolled.v1", + &node, + )?, + ])?; + Ok((node, certificate.certificate_pem)) + } +} diff --git a/crates/colossus-cloud/src/repository/nodes.rs b/crates/colossus-cloud/src/repository/nodes.rs new file mode 100644 index 000000000..83cc5c4d7 --- /dev/null +++ b/crates/colossus-cloud/src/repository/nodes.rs @@ -0,0 +1,162 @@ +use super::*; +use crate::{validate_identifier, validation::bounded_fingerprint}; + +fn stream(project: &str, node: &str) -> String { + format!("cloud.node:{project}:{node}") +} + +impl CloudRepository { + /// Enroll one node under explicit project administration, with no implicit runtime grant. + pub fn register_node( + &self, + caller: &CloudCaller, + mut node: CloudNode, + ) -> CloudResult { + caller.require(CloudPermission::Administer)?; + validate_identifier(&node.node_id)?; + validate_identifier(&node.instance_id)?; + bounded_fingerprint(&node.certificate_sha256)?; + if node.project_id != caller.project_id() + || node.revoked + || node.revision != 0 + || node.label.is_empty() + || node.label.len() > 128 + || node.label.chars().any(char::is_control) + || node.roles.is_empty() + || node.roles.len() > 32 + { + return Err(CloudError::InvalidArgument); + } + for role in &node.roles { + validate_identifier(role)?; + } + node.certificate_sha256.make_ascii_lowercase(); + node.revision = 1; + self.append( + caller.subject(), + stream(caller.project_id(), &node.node_id), + 0, + "cloud.node.enrolled.v1", + &node, + )?; + Ok(node) + } + + /// Read one exact project-owned node. + pub fn get_node(&self, caller: &CloudCaller, node_id: &str) -> CloudResult { + caller.require(CloudPermission::Read)?; + self.node(caller.project_id(), node_id) + } + + /// List one bounded lexical node page, exclusively after a validated node identity. + pub fn list_nodes( + &self, + caller: &CloudCaller, + after: Option<&str>, + limit: usize, + ) -> CloudResult> { + caller.require(CloudPermission::Read)?; + if let Some(after) = after { + validate_identifier(after)?; + } + let prefix = format!("cloud.node:{}:", caller.project_id()); + let cursor = after.map(|id| format!("{prefix}{id}")); + self.journal + .list_stream_ids(&prefix, cursor.as_deref(), limit.min(100))? + .into_iter() + .map(|stream| self.read::(&stream).map(|(node, _)| node)) + .collect() + } + + /// Durably revoke an exact revision. Existing authenticated streams must recheck this state. + pub fn revoke_node( + &self, + caller: &CloudCaller, + node_id: &str, + revision: u64, + ) -> CloudResult { + caller.require(CloudPermission::Administer)?; + let mut node = self.node(caller.project_id(), node_id)?; + if node.revoked { + self.journal.checkpoint()?; + return Ok(node); + } + if node.revision != revision { + return Err(CloudError::Conflict); + } + node.revoked = true; + node.revision += 1; + self.append( + caller.subject(), + stream(caller.project_id(), node_id), + revision, + "cloud.node.revoked.v1", + &node, + )?; + Ok(node) + } + + /// Verify a TLS-authenticated client leaf against durable project enrollment and local instance. + pub fn authenticate_node( + &self, + project: &str, + node_id: &str, + fingerprint: &str, + instance_id: &str, + ) -> CloudResult { + let node = self.node(project, node_id)?; + if node.revoked || node.certificate_sha256 != fingerprint || node.instance_id != instance_id + { + return Err(CloudError::PermissionDenied); + } + Ok(node) + } + + /// A TLS-authenticated enrollment can revoke only its exact fixed node. + /// Repeated calls remain idempotent after an acknowledgement is lost. + pub fn revoke_own_node( + &self, + project: &str, + node_id: &str, + fingerprint: &str, + instance_id: &str, + ) -> CloudResult<()> { + let mut node = self.node(project, node_id)?; + if node.certificate_sha256 != fingerprint || node.instance_id != instance_id { + return Err(CloudError::PermissionDenied); + } + if node.revoked { + self.journal.checkpoint()?; + return Ok(()); + } + let revision = node.revision; + node.revoked = true; + node.revision += 1; + self.append( + node_id, + stream(project, node_id), + revision, + "cloud.node.revoked.v1", + &node, + ) + } + + pub(super) fn node(&self, project: &str, node_id: &str) -> CloudResult { + validate_identifier(project)?; + validate_identifier(node_id)?; + let (node, revision) = self.read::(&stream(project, node_id))?; + if node.project_id != project || node.node_id != node_id || node.revision != revision { + return Err(CloudError::Storage); + } + Ok(node) + } + + pub(super) fn live_node(&self, node: &CloudNode) -> CloudResult { + self.authenticate_node( + &node.project_id, + &node.node_id, + &node.certificate_sha256, + &node.instance_id, + ) + } +} diff --git a/crates/colossus-cloud/src/repository/renewal.rs b/crates/colossus-cloud/src/repository/renewal.rs new file mode 100644 index 000000000..6ed5c1ed9 --- /dev/null +++ b/crates/colossus-cloud/src/repository/renewal.rs @@ -0,0 +1,95 @@ +use super::*; +use crate::{CertificateRedemption, validate_identifier, validation::bounded_fingerprint}; +use serde::Deserialize; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Renewal { + id: String, + previous_fingerprint: String, + csr_sha256: String, + certificate_pem: String, + certificate_sha256: String, + issued_at: u64, +} + +impl CloudRepository { + /// Rotate a fixed node's leaf beneath its existing mTLS authority. The key stays + /// native. Exact retries reconcile a lost reply, including after host restart. + pub fn renew_certificate( + &self, + identity: crate::RenewalIdentity<'_>, + certificate: CertificateRedemption, + now: u64, + ) -> CloudResult { + let project = identity.project_id; + let node_id = identity.node_id; + let instance_id = identity.instance_id; + let previous_fingerprint = identity.previous_fingerprint; + let renewal_id = identity.renewal_id; + validate_identifier(renewal_id)?; + bounded_fingerprint(previous_fingerprint)?; + bounded_fingerprint(&certificate.fingerprint)?; + bounded_fingerprint(&certificate.csr_sha256)?; + if certificate.certificate_pem.len() > 16384 { + return Err(CloudError::InvalidArgument); + } + let mut node = self.node(project, node_id)?; + if node.revoked || node.instance_id != instance_id { + return Err(CloudError::PermissionDenied); + } + let renewal_stream = format!("cloud.renewal:{project}:{node_id}"); + let prior = match self.read::(&renewal_stream) { + Ok(value) => Some(value), + Err(CloudError::NotFound) => None, + Err(error) => return Err(error), + }; + if let Some((prior, _)) = &prior { + if prior.id == renewal_id { + return if prior.previous_fingerprint == previous_fingerprint + && prior.csr_sha256 == certificate.csr_sha256 + && node.certificate_sha256 == prior.certificate_sha256 + { + self.journal.checkpoint()?; + Ok(prior.certificate_pem.clone()) + } else { + Err(CloudError::Conflict) + }; + } + if now < prior.issued_at.saturating_add(60) { + return Err(CloudError::ResourceExhausted); + } + } + if node.certificate_sha256 != previous_fingerprint { + return Err(CloudError::PermissionDenied); + } + let renewal = Renewal { + id: renewal_id.into(), + previous_fingerprint: previous_fingerprint.into(), + csr_sha256: certificate.csr_sha256, + certificate_pem: certificate.certificate_pem.clone(), + certificate_sha256: certificate.fingerprint.clone(), + issued_at: now, + }; + let revision = node.revision; + node.revision += 1; + node.certificate_sha256 = certificate.fingerprint; + self.commit(vec![ + self.event( + node_id, + format!("cloud.node:{project}:{node_id}"), + revision, + "cloud.node.certificate-renewed.v1", + &node, + )?, + self.event( + node_id, + renewal_stream, + prior.as_ref().map_or(0, |(_, version)| *version), + "cloud.renewal.issued.v1", + &renewal, + )?, + ])?; + Ok(certificate.certificate_pem) + } +} diff --git a/crates/colossus-cloud/src/repository/tasks.rs b/crates/colossus-cloud/src/repository/tasks.rs new file mode 100644 index 000000000..bea95b48f --- /dev/null +++ b/crates/colossus-cloud/src/repository/tasks.rs @@ -0,0 +1,324 @@ +use super::*; +use crate::{validate_identifier, validation::fingerprint}; +use colossus_sdk::{ + CreateRunRequest, IdempotencyKey, InputContentPart, InteractionAnswer, + RespondInteractionRequest, +}; +use serde::Deserialize; +use std::collections::BTreeSet; + +#[derive(Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Admission { + active: BTreeSet, +} + +pub(super) fn stream(project: &str, task: &str) -> String { + format!("cloud.task:{project}:{task}") +} +pub(super) fn command_stream(project: &str, node: &str, command: &str) -> String { + format!("cloud.command:{project}:{node}:{command}") +} + +impl CloudRepository { + /// Atomically allocate a fixed-node task and its dispatch command. Identical retries reuse both. + pub fn create_task( + &self, + caller: &CloudCaller, + node_id: &str, + mut request: CreateRunRequest, + ) -> CloudResult { + caller.require(CloudPermission::Execute)?; + let node = self.node(caller.project_id(), node_id)?; + if node.revoked || !node.roles.contains(&request.role) { + return Err(CloudError::PermissionDenied); + } + // Cross-run context and artifacts require separate cloud ownership mapping. + // Refuse them until that mapping exists; never forward a guessed runtime ID. + if request.session_id.is_some() + || request.plan_action.is_some() + || request.branch.is_some() + || !request.plugin_skill_ids.is_empty() + || request.input.is_empty() + || request.input.len() > 128 + || request.max_turns > 100 + || request + .input + .iter() + .any(|part| !matches!(part, InputContentPart::Text(_))) + { + return Err(CloudError::InvalidArgument); + } + let input_bytes: usize = request + .input + .iter() + .map(|part| match part { + InputContentPart::Text(text) => text.len(), + _ => 0, + }) + .sum(); + if input_bytes == 0 || input_bytes > 1024 * 1024 { + return Err(CloudError::ResourceExhausted); + } + let key = fingerprint( + &serde_json::to_vec(&( + caller.project_id(), + caller.subject(), + request.idempotency_key.as_str(), + )) + .map_err(|_| CloudError::InvalidArgument)?, + ); + let task_id = key[..32].to_owned(); + request.idempotency_key = + IdempotencyKey::new(format!("cloud-{}-{task_id}", caller.project_id())) + .map_err(|_| CloudError::InvalidArgument)?; + request.end_user_id = None; + let task = CloudTask { + task_id: task_id.clone(), + project_id: caller.project_id().into(), + node_id: node_id.into(), + subject: caller.subject().into(), + request: request.clone(), + run_id: None, + snapshot: None, + dispatch_error: None, + last_sequence: 0, + released_bytes: 0, + output_limited: false, + revision: 1, + }; + let command = PendingCommand { + command_id: task_id.clone(), + task_id: task_id.clone(), + node_id: node_id.into(), + command: Command::Create { + request: Box::new(request), + }, + reply: None, + revision: 1, + }; + let admission_stream = format!("cloud.admission:{}:{node_id}", caller.project_id()); + for _ in 0..8 { + match self.task(caller.project_id(), &task_id) { + Ok(existing) => { + return if existing.request == task.request + && existing.node_id == task.node_id + && existing.subject == task.subject + { + self.journal.checkpoint()?; + Ok(existing) + } else { + Err(CloudError::Conflict) + }; + } + Err(CloudError::NotFound) => {} + Err(error) => return Err(error), + } + let (mut admission, revision) = match self.read::(&admission_stream) { + Ok(value) => value, + Err(CloudError::NotFound) => (Admission::default(), 0), + Err(error) => return Err(error), + }; + let mut active = BTreeSet::new(); + for id in &admission.active { + let existing = self.task(caller.project_id(), id)?; + let settled = existing.snapshot.as_ref().is_some_and(|snapshot| { + matches!( + snapshot.run.status, + colossus_sdk::RunStatus::Completed + | colossus_sdk::RunStatus::Failed + | colossus_sdk::RunStatus::Cancelled + ) + }) || existing + .dispatch_error + .as_ref() + .is_some_and(|error| error.code != colossus_sdk::ApiErrorCode::OutcomeUnknown); + if !settled { + active.insert(id.clone()); + } + } + if active.len() >= colossus_cloud_protocol::MAX_ACTIVE_TASKS { + return Err(CloudError::ResourceExhausted); + } + active.insert(task_id.clone()); + admission.active = active; + let events = vec![ + self.event( + caller.subject(), + admission_stream.clone(), + revision, + "cloud.admission.allocated.v1", + &admission, + )?, + self.event( + caller.subject(), + format!( + "cloud.node-task:{}:{node_id}:{task_id}", + caller.project_id() + ), + 0, + "cloud.node-task.allocated.v1", + &task_id, + )?, + self.event( + caller.subject(), + stream(caller.project_id(), &task_id), + 0, + "cloud.task.allocated.v1", + &task, + )?, + self.event( + caller.subject(), + command_stream(caller.project_id(), node_id, &task_id), + 0, + "cloud.command.created.v1", + &command, + )?, + ]; + match self.commit(events) { + Ok(_) => return Ok(task), + Err(colossus_ports::StoreError::Conflict { .. }) => { + continue; + } + Err(error) => return Err(error.into()), + } + } + Err(CloudError::Conflict) + } + + /// Read the task's released state only inside the authenticated project. + pub fn get_task(&self, caller: &CloudCaller, task_id: &str) -> CloudResult { + caller.require(CloudPermission::Read)?; + self.task(caller.project_id(), task_id) + } + + /// Read a bounded page of task identities using the journal's indexed project namespace. + pub fn list_tasks( + &self, + caller: &CloudCaller, + after: Option<&str>, + limit: usize, + ) -> CloudResult> { + caller.require(CloudPermission::Read)?; + if let Some(after) = after { + validate_identifier(after)?; + } + let prefix = format!("cloud.task:{}:", caller.project_id()); + let cursor = after.map(|id| format!("{prefix}{id}")); + self.journal + .list_stream_ids(&prefix, cursor.as_deref(), limit.min(100))? + .into_iter() + .map(|stream| self.read::(&stream).map(|(task, _)| task)) + .collect() + } + + /// Queue cooperative cancellation beneath explicit control permission. + pub fn cancel_task( + &self, + caller: &CloudCaller, + task_id: &str, + mutation_id: &str, + ) -> CloudResult { + caller.require(CloudPermission::Control)?; + let task = self.task(caller.project_id(), task_id)?; + let run_id = task.run_id.clone().ok_or(CloudError::Conflict)?; + self.queue_mutation( + caller, + &task, + mutation_id, + Command::Cancel { + run_id, + idempotency_key: IdempotencyKey::new(format!( + "cloud-cancel-{task_id}-{mutation_id}" + )) + .map_err(|_| CloudError::InvalidArgument)?, + }, + ) + } + + /// Queue an exact prompt/approval answer. Approval authority is independent of cancellation. + pub fn respond_task( + &self, + caller: &CloudCaller, + task_id: &str, + mutation_id: &str, + mut request: RespondInteractionRequest, + ) -> CloudResult { + caller.require( + if matches!(request.response, InteractionAnswer::Approval { .. }) { + CloudPermission::Approve + } else { + CloudPermission::Control + }, + )?; + let task = self.task(caller.project_id(), task_id)?; + if task.run_id.as_deref() != Some(&request.run_id) { + return Err(CloudError::PermissionDenied); + } + request.idempotency_key = + IdempotencyKey::new(format!("cloud-respond-{task_id}-{mutation_id}")) + .map_err(|_| CloudError::InvalidArgument)?; + self.queue_mutation( + caller, + &task, + mutation_id, + Command::Respond { + request: Box::new(request), + }, + ) + } + + fn queue_mutation( + &self, + caller: &CloudCaller, + task: &CloudTask, + mutation_id: &str, + command: Command, + ) -> CloudResult { + validate_identifier(mutation_id)?; + if self.node(caller.project_id(), &task.node_id)?.revoked { + return Err(CloudError::PermissionDenied); + } + let command_id = fingerprint( + &serde_json::to_vec(&(task.task_id.as_str(), caller.subject(), mutation_id)) + .map_err(|_| CloudError::InvalidArgument)?, + )[..32] + .to_owned(); + let pending = PendingCommand { + command_id: command_id.clone(), + task_id: task.task_id.clone(), + node_id: task.node_id.clone(), + command, + reply: None, + revision: 1, + }; + let stream = command_stream(caller.project_id(), &task.node_id, &command_id); + match self.append( + caller.subject(), + stream.clone(), + 0, + "cloud.command.created.v1", + &pending, + ) { + Ok(()) => Ok(pending), + Err(CloudError::Conflict) => { + let (existing, _) = self.read::(&stream)?; + if existing.command != pending.command || existing.task_id != pending.task_id { + return Err(CloudError::Conflict); + } + Ok(existing) + } + Err(error) => Err(error), + } + } + + pub(super) fn task(&self, project: &str, task_id: &str) -> CloudResult { + validate_identifier(project)?; + validate_identifier(task_id)?; + let (task, revision) = self.read::(&stream(project, task_id))?; + if task.project_id != project || task.task_id != task_id || task.revision != revision { + return Err(CloudError::Storage); + } + Ok(task) + } +} diff --git a/crates/colossus-cloud/src/tests.rs b/crates/colossus-cloud/src/tests.rs new file mode 100644 index 000000000..a8b00c45d --- /dev/null +++ b/crates/colossus-cloud/src/tests.rs @@ -0,0 +1,450 @@ +use super::*; +use colossus_cloud_protocol::{CloudReply, Command}; +use colossus_journal_redb::{DisabledCheckpointSigner, PlaintextKeyProvider, RedbEventJournal}; +use colossus_sdk::*; +use std::{collections::BTreeSet, sync::Arc}; + +fn caller(project: &str, permissions: &[CloudPermission]) -> CloudCaller { + CloudCaller::new( + "alice".into(), + project.into(), + permissions.iter().copied().collect(), + ) + .unwrap() +} +fn fixture() -> (CloudRepository, CloudCaller, CloudNode) { + let journal = RedbEventJournal::open_in_memory( + Arc::new(PlaintextKeyProvider), + Arc::new(DisabledCheckpointSigner), + ) + .unwrap(); + let repo = CloudRepository::new(Arc::new(journal)).unwrap(); + let caller = caller( + "project-a", + &[ + CloudPermission::Read, + CloudPermission::Execute, + CloudPermission::Control, + CloudPermission::Approve, + CloudPermission::Administer, + ], + ); + let node = repo + .register_node( + &caller, + CloudNode { + node_id: "node-a".into(), + project_id: "project-a".into(), + instance_id: "instance-a".into(), + label: "Test runtime".into(), + certificate_sha256: "a".repeat(64), + roles: BTreeSet::from(["primary".into()]), + revoked: false, + revision: 0, + }, + ) + .unwrap(); + (repo, caller, node) +} +fn request() -> CreateRunRequest { + CreateRunRequest { + plugin_skill_ids: vec![], + input: vec![InputContentPart::Text("hello".into())], + session_id: None, + end_user_id: None, + role: "primary".into(), + mode: RunMode::Execute, + research_depth: None, + research_sources: vec![], + plan_action: None, + branch: None, + max_turns: 4, + idempotency_key: IdempotencyKey::new("once").unwrap(), + } +} + +#[test] +fn native_revocation_is_exact_and_retryable() { + let (repo, caller, node) = fixture(); + assert_eq!( + repo.revoke_own_node( + &node.project_id, + &node.node_id, + &"b".repeat(64), + &node.instance_id + ), + Err(CloudError::PermissionDenied) + ); + assert_eq!( + repo.revoke_own_node( + &node.project_id, + &node.node_id, + &node.certificate_sha256, + "another-instance" + ), + Err(CloudError::PermissionDenied) + ); + assert_eq!( + repo.revoke_own_node( + "another-project", + &node.node_id, + &node.certificate_sha256, + &node.instance_id + ), + Err(CloudError::NotFound) + ); + assert!(!repo.get_node(&caller, &node.node_id).unwrap().revoked); + repo.revoke_own_node( + &node.project_id, + &node.node_id, + &node.certificate_sha256, + &node.instance_id, + ) + .unwrap(); + let revoked = repo.get_node(&caller, &node.node_id).unwrap(); + assert!(revoked.revoked); + repo.revoke_own_node( + &node.project_id, + &node.node_id, + &node.certificate_sha256, + &node.instance_id, + ) + .unwrap(); + assert_eq!( + repo.get_node(&caller, &node.node_id).unwrap().revision, + revoked.revision + ); + assert!( + repo.authenticate_node( + &node.project_id, + &node.node_id, + &node.certificate_sha256, + &node.instance_id + ) + .is_err() + ); +} + +#[test] +fn admission_is_atomic_bounded_and_reuses_settled_slots() { + let (repo, caller, node) = fixture(); + let mut admitted = Vec::new(); + for index in 0..16 { + let mut request = request(); + request.idempotency_key = IdempotencyKey::new(format!("task-{index}")).unwrap(); + admitted.push(repo.create_task(&caller, &node.node_id, request).unwrap()); + } + assert_eq!( + repo.create_task(&caller, &node.node_id, request()) + .unwrap_err(), + CloudError::ResourceExhausted + ); + let mut settled = snapshot(); + settled.run.status = RunStatus::Completed; + repo.record_receipt( + &node, + &admitted[0].task_id, + &admitted[0].task_id, + CloudReply::Run { + run: Box::new(settled), + }, + ) + .unwrap(); + assert!(repo.create_task(&caller, &node.node_id, request()).is_ok()); + // A retry occupies its original slot even while admission is full. + let mut first = request(); + first.idempotency_key = IdempotencyKey::new("task-1").unwrap(); + assert_eq!( + repo.create_task(&caller, &node.node_id, first) + .unwrap() + .task_id, + admitted[1].task_id + ); +} + +#[test] +fn certificate_rotation_reconciles_lost_ack_and_rejects_old_leaf_or_revocation() { + let (repo, caller, node) = fixture(); + let identity = || RenewalIdentity { + project_id: &node.project_id, + node_id: &node.node_id, + instance_id: &node.instance_id, + previous_fingerprint: &node.certificate_sha256, + renewal_id: "renew-1", + }; + let certificate = || CertificateRedemption { + fingerprint: "b".repeat(64), + csr_sha256: "c".repeat(64), + certificate_pem: "public-leaf".into(), + }; + assert_eq!( + repo.renew_certificate(identity(), certificate(), 100) + .unwrap(), + "public-leaf" + ); + let mut fresh_signing = certificate(); + fresh_signing.certificate_pem = "different-random-serial".into(); + fresh_signing.fingerprint = "d".repeat(64); + assert_eq!( + repo.renew_certificate(identity(), fresh_signing, 101) + .unwrap(), + "public-leaf" + ); + assert_eq!( + repo.authenticate_node( + &node.project_id, + &node.node_id, + &node.certificate_sha256, + &node.instance_id + ) + .unwrap_err(), + CloudError::PermissionDenied + ); + let rotated = repo + .authenticate_node( + &node.project_id, + &node.node_id, + &"b".repeat(64), + &node.instance_id, + ) + .unwrap(); + repo.revoke_node(&caller, &node.node_id, rotated.revision) + .unwrap(); + assert_eq!( + repo.renew_certificate(identity(), certificate(), 102) + .unwrap_err(), + CloudError::PermissionDenied + ); +} +fn snapshot() -> GetRunResponse { + GetRunResponse { + run: Run { + plugin_skill_ids: vec![], + run_id: "run-a".into(), + session_id: "session-a".into(), + title: "hello".into(), + role: "primary".into(), + mode: RunMode::Execute, + status: RunStatus::Running, + created_at: "2026-10-04T00:00:00Z".into(), + updated_at: "2026-10-04T00:00:00Z".into(), + started_at: None, + finished_at: None, + last_sequence: 2, + pending_interaction_count: 0, + terminal: None, + etag: "v2".into(), + archived: false, + }, + pending_interactions: vec![], + } +} +#[test] +fn oversized_output_keeps_the_exact_cursor_and_run_controls_available() { + let (repo, caller, node) = fixture(); + let task = repo.create_task(&caller, &node.node_id, request()).unwrap(); + repo.record_receipt( + &node, + &task.task_id, + &task.task_id, + CloudReply::Run { + run: Box::new(snapshot()), + }, + ) + .unwrap(); + assert_eq!( + repo.record_output_limit(&node, &task.task_id, "foreign-run", 0), + Err(CloudError::PermissionDenied) + ); + assert_eq!( + repo.record_output_limit(&node, &task.task_id, "run-a", 1), + Err(CloudError::Conflict) + ); + let update = RunUpdate { + run_id: "run-a".into(), + sequence: 1, + created_at: "2026-10-04T00:00:00Z".into(), + update: RunUpdateKind::OutputDelta("x".repeat(colossus_cloud_protocol::MAX_PAYLOAD_BYTES)), + }; + assert_eq!( + repo.record_update(&node, &task.task_id, update), + Err(CloudError::ResourceExhausted) + ); + let limited = repo.get_task(&caller, &task.task_id).unwrap(); + assert!(limited.output_limited); + assert_eq!(limited.last_sequence, 0); + assert!( + repo.cancel_task(&caller, &task.task_id, "cancel-limited") + .is_ok() + ); + repo.record_output_limit(&node, &task.task_id, "run-a", 0) + .unwrap(); + let mut settled = snapshot(); + settled.run.status = RunStatus::Cancelled; + repo.record_snapshot(&node, &task.task_id, settled).unwrap(); + assert_eq!( + repo.get_task(&caller, &task.task_id) + .unwrap() + .snapshot + .unwrap() + .run + .status, + RunStatus::Cancelled + ); +} + +#[test] +fn command_receipts_are_scoped_to_the_exact_project_and_task() { + let (repo, caller, node) = fixture(); + let first = repo.create_task(&caller, &node.node_id, request()).unwrap(); + let command = repo + .get_command(&caller, &first.task_id, &first.task_id) + .unwrap(); + assert!(command.reply.is_none()); + let mut second = request(); + second.idempotency_key = IdempotencyKey::new("second").unwrap(); + let second = repo.create_task(&caller, &node.node_id, second).unwrap(); + assert_eq!( + repo.get_command(&caller, &second.task_id, &first.task_id), + Err(CloudError::PermissionDenied) + ); + assert!( + repo.get_command( + &self::caller("foreign", &[CloudPermission::Read]), + &first.task_id, + &first.task_id + ) + .is_err() + ); + assert_eq!( + repo.get_command( + &self::caller("project-a", &[CloudPermission::Execute]), + &first.task_id, + &first.task_id + ), + Err(CloudError::PermissionDenied) + ); +} +#[test] +fn lost_receipt_replays_one_allocation_and_conflicting_retry_fails() { + let (repo, caller, node) = fixture(); + let first = repo.create_task(&caller, &node.node_id, request()).unwrap(); + let again = repo.create_task(&caller, &node.node_id, request()).unwrap(); + assert_eq!(first, again); + let commands = repo.commands(&node, None, 100).unwrap(); + assert_eq!(commands.len(), 1); + assert!(matches!(commands[0].command, Command::Create { .. })); + let reply = CloudReply::Run { + run: Box::new(snapshot()), + }; + repo.record_receipt(&node, &first.task_id, &first.task_id, reply.clone()) + .unwrap(); + repo.record_receipt(&node, &first.task_id, &first.task_id, reply) + .unwrap(); + let current = repo.get_task(&caller, &first.task_id).unwrap(); + assert_eq!(current.run_id.as_deref(), Some("run-a")); + let mut altered = request(); + altered.input = vec![InputContentPart::Text("changed".into())]; + assert_eq!( + repo.create_task(&caller, &node.node_id, altered) + .unwrap_err(), + CloudError::Conflict + ); +} +#[test] +fn events_require_contiguous_exact_run_sequences_and_duplicates_are_verified() { + let (repo, caller, node) = fixture(); + let task = repo.create_task(&caller, &node.node_id, request()).unwrap(); + repo.record_receipt( + &node, + &task.task_id, + &task.task_id, + CloudReply::Run { + run: Box::new(snapshot()), + }, + ) + .unwrap(); + let mut update = RunUpdate { + run_id: "run-a".into(), + sequence: 2, + created_at: "2026-10-04T00:00:00Z".into(), + update: RunUpdateKind::OutputDelta("hello".into()), + }; + assert_eq!( + repo.record_update(&node, &task.task_id, update.clone()) + .unwrap_err(), + CloudError::Conflict + ); + update.sequence = 1; + assert_eq!( + repo.record_update(&node, &task.task_id, update.clone()) + .unwrap(), + 1 + ); + assert_eq!( + repo.record_update(&node, &task.task_id, update.clone()) + .unwrap(), + 1 + ); + update.update = RunUpdateKind::OutputDelta("tampered".into()); + assert_eq!( + repo.record_update(&node, &task.task_id, update) + .unwrap_err(), + CloudError::Conflict + ); + let events = repo.updates(&caller, &task.task_id, 0, 100).unwrap(); + assert_eq!(events.len(), 1); + assert!( + repo.updates(&caller, &task.task_id, 1, 100) + .unwrap() + .is_empty() + ); + assert_eq!( + repo.get_task(&caller, &task.task_id) + .unwrap() + .snapshot + .unwrap() + .run + .last_sequence, + 2 + ); +} +#[test] +fn project_isolation_local_role_ceiling_and_revocation_remain_independent() { + let (repo, admin, node) = fixture(); + assert_eq!( + repo.create_task( + &caller("project-a", &[CloudPermission::Administer]), + &node.node_id, + request() + ) + .unwrap_err(), + CloudError::PermissionDenied + ); + assert_eq!( + repo.get_node( + &caller("project-b", &[CloudPermission::Read]), + &node.node_id + ) + .unwrap_err(), + CloudError::NotFound + ); + let mut invalid = request(); + invalid.role = "admin".into(); + assert_eq!( + repo.create_task(&admin, &node.node_id, invalid) + .unwrap_err(), + CloudError::PermissionDenied + ); + repo.revoke_node(&admin, &node.node_id, node.revision) + .unwrap(); + assert_eq!( + repo.commands(&node, None, 100).unwrap_err(), + CloudError::PermissionDenied + ); + assert_eq!( + repo.create_task(&admin, &node.node_id, request()) + .unwrap_err(), + CloudError::PermissionDenied + ); +} diff --git a/crates/colossus-cloud/src/types.rs b/crates/colossus-cloud/src/types.rs new file mode 100644 index 000000000..09dc8a084 --- /dev/null +++ b/crates/colossus-cloud/src/types.rs @@ -0,0 +1,75 @@ +use colossus_cloud_protocol::{CloudReply, Command}; +use colossus_sdk::{CreateRunRequest, GetRunResponse}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// One enrolled node bound to a project, local instance, and TLS client certificate. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CloudNode { + /// Stable registered machine identity. + pub node_id: String, + /// Owning cloud project. + pub project_id: String, + /// Exact local Colossus instance independently verified by the connector SDK. + pub instance_id: String, + /// Bounded human-readable display label. + pub label: String, + /// SHA-256 of the enrolled TLS client leaf, never credential material. + pub certificate_sha256: String, + /// Additional cloud role ceiling, intersected with the local application grant. + pub roles: BTreeSet, + /// Durable revocation; active streams recheck before accepting commands or output. + pub revoked: bool, + /// Journal revision used for exact enrollment/revocation operations. + pub revision: u64, +} + +/// Immutable task allocation. Accepted work is never moved to another node implicitly. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CloudTask { + /// Stable cloud identity. + pub task_id: String, + /// Owning project. + pub project_id: String, + /// Exact enrolled execution target. + pub node_id: String, + /// Authenticated submitting subject. + pub subject: String, + /// Reviewed SDK request with a cloud-derived idempotency key. + pub request: CreateRunRequest, + /// Exact local run identity after durable allocation is acknowledged. + pub run_id: Option, + /// Latest released caller-visible runtime snapshot. + pub snapshot: Option, + /// Released failure settling initial dispatch; unknown outcomes remain explicit. + pub dispatch_error: Option, + /// Highest contiguous durably received local run sequence. + pub last_sequence: u64, + /// Aggregate released event bytes retained for this task. + pub released_bytes: usize, + /// Output retention reached its bound; snapshots/control continue on the fixed run. + #[serde(default)] + pub output_limited: bool, + /// Optimistic journal revision. + pub revision: u64, +} + +/// Durable command and receipt; replay carries the same runtime idempotency identity. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PendingCommand { + /// Stable cloud-generated dispatch/mutation identity. + pub command_id: String, + /// Exact immutable task allocation. + pub task_id: String, + /// Exact enrolled target. + pub node_id: String, + /// Closed typed SDK operation. + pub command: Command, + /// Durable reply, absent until reconciled. + pub reply: Option, + /// Optimistic journal revision. + pub revision: u64, +} diff --git a/crates/colossus-cloud/src/validation.rs b/crates/colossus-cloud/src/validation.rs new file mode 100644 index 000000000..522ba5b77 --- /dev/null +++ b/crates/colossus-cloud/src/validation.rs @@ -0,0 +1,26 @@ +use crate::{CloudError, CloudResult}; + +/// Validate a path-safe identity before composing stream identifiers or HTTP routes. +pub fn validate_identifier(value: &str) -> CloudResult<()> { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(CloudError::InvalidArgument); + } + Ok(()) +} + +pub(crate) fn fingerprint(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(bytes)) +} + +pub(crate) fn bounded_fingerprint(value: &str) -> CloudResult<()> { + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(CloudError::InvalidArgument); + } + Ok(()) +} diff --git a/crates/colossus-connector/Cargo.toml b/crates/colossus-connector/Cargo.toml new file mode 100644 index 000000000..97c76ba1f --- /dev/null +++ b/crates/colossus-connector/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "colossus-connector" +publish = false +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[dependencies] +clap.workspace = true +colossus-cloud-protocol.workspace = true +colossus-credentials.workspace = true +colossus-cloud.workspace = true +colossus-contracts.workspace = true +colossus-home.workspace = true +colossus-ports.workspace = true +colossus-sdk = { workspace = true, features = ["serialization", "keyring"] } +async-trait.workspace = true +futures.workspace = true +fs4.workspace = true +hex.workspace = true +rcgen.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tokio.workspace = true +tokio-stream.workspace = true +tonic = { workspace = true, features = ["transport", "tls-ring"] } +url.workspace = true +uuid.workspace = true +zeroize = { workspace = true, features = ["derive"] } +x509-parser.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/colossus-connector/src/cli.rs b/crates/colossus-connector/src/cli.rs new file mode 100644 index 000000000..7809531bc --- /dev/null +++ b/crates/colossus-connector/src/cli.rs @@ -0,0 +1,293 @@ +use crate::cli_control::{Control, Report}; +use crate::{ConnectorStatus, EnrollmentStore, RuntimeConnector}; +use clap::{Args, Subcommand}; +use colossus_credentials::{EnvironmentKeyStore, PlatformCredentialVault}; +use colossus_home::{ColossusHome, ConfinedRoot}; +use colossus_ports::CredentialVault; +use colossus_sdk::{ + ApiMajor, Colossus, DaemonConnectOptions, InstanceId, KeyringCredentialProvider, TlsFingerprint, +}; +use serde::Deserialize; +use std::{ + io::{BufRead, BufReader, Read, Write}, + path::PathBuf, + str::FromStr, + sync::Arc, +}; +use zeroize::Zeroizing; + +/// Portable CLI and standalone connector lifecycle operations. +#[derive(Subcommand)] +pub enum ConnectorCommand { + /// Enroll one independently verified local daemon; invitation is read from stdin. + Enroll(EnrollArguments), + /// Maintain its outbound connection until Ctrl-C; accepted runs survive disconnect. + Run(LocalArguments), + /// Show the saved non-secret enrollment identity. + Status(StorageArguments), + /// Rotate the native-held certificate/key; retries reconcile the saved exchange. + Renew(StorageArguments), + /// Stop this CLI connection without cancelling accepted runtime work. + Disconnect(StorageArguments), + /// Disconnect and revoke this exact native enrollment in the cloud. + Revoke(StorageArguments), + /// Forget local enrollment after stopping the connector; remote authority remains revoked separately. + Forget(StorageArguments), +} +/// Secret-storage references shared by all connector commands. +#[derive(Args)] +pub struct StorageArguments { + /// Opaque local enrollment name, permitting multiple explicitly selected nodes. + #[arg(long, default_value = "active")] + pub name: String, + /// Explicit headless authority; variable must hold a secret 32-byte hex wrapping key. + #[arg(long)] + pub vault_key_variable: Option, +} +/// Independently enrolled local daemon connection references. +#[derive(Args)] +pub struct LocalArguments { + /// Owner-private JSON containing exact descriptor, instance, pin, and dedicated keyring references. + #[arg(long)] + pub local_config: PathBuf, + /// Native credential authority and enrollment identity. + #[command(flatten)] + pub storage: StorageArguments, +} +/// Single-use cloud enrollment references; no invitation is accepted through argv or environment. +#[derive(Args)] +pub struct EnrollArguments { + /// Exact HTTPS enrollment endpoint obtained from the authenticated web fleet view. + #[arg(long)] + pub enrollment_url: String, + /// Explicitly permit HTTP enrollment on loopback for local acceptance only. + #[arg(long)] + pub allow_loopback_http: bool, + /// Independently verified local daemon and native vault references. + #[command(flatten)] + pub local: LocalArguments, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LocalConfig { + descriptor: PathBuf, + certificate: PathBuf, + instance_id: String, + certificate_sha256: String, + keyring_service: String, + keyring_account: String, + #[serde(default)] + headless_authority: Option, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct HeadlessAuthority { + directory: PathBuf, + key_variable: String, +} +fn store(arguments: &StorageArguments) -> Result> { + let home = ColossusHome::resolve_and_ensure()?; + let root = ConfinedRoot::bind( + home.confined_root() + .prepare_directory(std::path::Path::new("cloud-connector"))?, + )?; + let vault: Arc = match &arguments.vault_key_variable { + Some(variable) => Arc::new(PlatformCredentialVault::with_key_store( + root.clone(), + "cloud-connector", + Arc::new(EnvironmentKeyStore::new(root, variable.clone())?), + )?), + None => Arc::new(PlatformCredentialVault::new(root, "cloud-connector")?), + }; + Ok(EnrollmentStore::new(vault, &arguments.name)?) +} +async fn local(arguments: &LocalArguments) -> Result> { + let parent = arguments + .local_config + .parent() + .ok_or("local config must be an absolute protected file")?; + let root = ConfinedRoot::bind(parent)?; + let file = root.open_existing_file( + arguments + .local_config + .file_name() + .ok_or("invalid local config")? + .as_ref(), + )?; + let mut bytes = Vec::new(); + file.file().take(16385).read_to_end(&mut bytes)?; + if bytes.len() > 16384 { + return Err("local config exceeds bound".into()); + } + let config: LocalConfig = serde_json::from_slice(&bytes)?; + file.revalidate(&root)?; + let credential: Arc = match config.headless_authority { + Some(authority) => Arc::new(crate::HeadlessCredentialProvider::new( + ConfinedRoot::bind(authority.directory)?, + authority.key_variable, + &config.keyring_service, + &config.keyring_account, + )?), + None => Arc::new(KeyringCredentialProvider::new( + config.keyring_service, + config.keyring_account, + )?), + }; + let options = DaemonConnectOptions::new( + InstanceId::from_str(&config.instance_id)?, + config.descriptor, + TlsFingerprint::from_hex(&config.certificate_sha256)?, + ApiMajor::new(1)?, + credential, + )? + .with_certificate_path(config.certificate)?; + Ok(Colossus::connect_installed(options).await?) +} +/// Execute the shared `colossus cloud` / `colossus-connector` command surface. +/// Credentials and tokens never appear in status output or process arguments. +pub async fn run_cli(command: ConnectorCommand) -> Result<(), Box> { + match command { + ConnectorCommand::Renew(arguments) => { + Control::new(&arguments.name)?.disconnect().await?; + let enrollment = store(&arguments)?; + enrollment.renew(true).await?; + println!("connector certificate renewed"); + } + ConnectorCommand::Enroll(arguments) => { + let client = local(&arguments.local).await?; + eprint!("One-use cloud invitation: "); + std::io::stderr().flush()?; + let mut token = Zeroizing::new(String::new()); + BufReader::new(std::io::stdin()) + .take(66) + .read_line(&mut token)?; + let token = Zeroizing::new(token.trim().to_owned()); + let enrollment = store(&arguments.local.storage)?; + let config = enrollment + .enroll( + arguments.enrollment_url, + token, + client + .instance_id() + .ok_or("local instance unavailable")? + .to_string(), + client.capabilities().iter().map(str::to_owned).collect(), + arguments.allow_loopback_http, + ) + .await?; + Control::new(&arguments.local.storage.name)?.save(&Report::saved(Some(&config)))?; + println!( + "{}", + serde_json::json!({"node_id":config.node_id,"project_id":config.project_id,"endpoint":config.endpoint,"status":"enrolled"}) + ); + client.close().await?; + } + ConnectorCommand::Run(arguments) => { + let control = Control::new(&arguments.storage.name)?; + eprintln!( + "connecting to the pinned local runtime; the native credential store may require unlocking" + ); + let client = local(&arguments).await?; + let enrollment = store(&arguments.storage)?; + let (config, key) = enrollment.load()?.ok_or("enroll this connector first")?; + if client.instance_id().map(|id| id.to_string()).as_deref() != Some(&config.instance_id) + { + return Err("local enrolled instance changed".into()); + } + let mut report = Report::saved(Some(&config)); + report.run_instance = Some(uuid::Uuid::now_v7().simple().to_string()); + let connector = RuntimeConnector::new(config, key, client.agent_runs())? + .with_enrollment_store(enrollment); + control.heartbeat(&mut report, ConnectorStatus::Connecting)?; + let (shutdown, receiver) = tokio::sync::watch::channel(false); + let (status, mut updates) = tokio::sync::watch::channel(ConnectorStatus::Connecting); + let final_status = updates.clone(); + let interrupt = shutdown.clone(); + let interrupt_task = tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + let _ = interrupt.send(true); + }); + let monitor_control = control.clone(); + let mut monitor_report = report.clone(); + let monitor = tokio::spawn(async move { + let mut poll = tokio::time::interval(std::time::Duration::from_millis(200)); + let mut ticks = 0; + loop { + tokio::select! { + result = updates.changed() => { + if result.is_err() { break; } + let state = *updates.borrow_and_update(); + eprintln!("connector {}", serde_json::to_string(&state).unwrap_or_default()); + if let Err(error) = monitor_control.heartbeat(&mut monitor_report, state) { + eprintln!("connector status unavailable: {error}"); + let _ = shutdown.send(true); + break; + } + } + _ = poll.tick() => { + let stop = monitor_control.stopped(monitor_report.run_instance.as_deref().unwrap_or_default()); + if !matches!(stop, Ok(false)) { let _ = shutdown.send(true); break; } + ticks += 1; + if ticks % 10 == 0 && monitor_control.heartbeat(&mut monitor_report, *updates.borrow()).is_err() { + let _ = shutdown.send(true); break; + } + } + } + } + }); + let result = connector.run(receiver, status).await; + monitor.abort(); + interrupt_task.abort(); + client.close().await?; + report.run_instance = None; + control.heartbeat( + &mut report, + if *final_status.borrow() == ConnectorStatus::Revoked { + ConnectorStatus::Revoked + } else { + ConnectorStatus::Disconnected + }, + )?; + result?; + } + ConnectorCommand::Status(arguments) => { + let control = Control::new(&arguments.name)?; + if let Some(report) = control.report()? { + println!("{}", Control::output(&report)); + return Ok(()); + } + let saved = store(&arguments)?.load()?; + println!( + "{}", + Control::output(&Report::saved(saved.as_ref().map(|(config, _)| config))) + ); + } + ConnectorCommand::Disconnect(arguments) => { + let control = Control::new(&arguments.name)?; + control.disconnect().await?; + if let Some(report) = control.report()? { + println!("{}", Control::output(&report)); + } else { + println!("{}", serde_json::json!({"status":"disconnected"})); + } + } + ConnectorCommand::Revoke(arguments) => { + let control = Control::new(&arguments.name)?; + control.disconnect().await?; + let enrollment = store(&arguments)?; + enrollment.revoke().await?; + let saved = enrollment.load()?; + let report = Report::saved(saved.as_ref().map(|(config, _)| config)); + control.save(&report)?; + println!("{}", Control::output(&report)); + } + ConnectorCommand::Forget(arguments) => { + let control = Control::new(&arguments.name)?; + control.disconnect().await?; + store(&arguments)?.forget()?; + control.save(&Report::saved(None))?; + println!("{}", serde_json::json!({"enrolled":false})); + } + } + Ok(()) +} diff --git a/crates/colossus-connector/src/cli_control.rs b/crates/colossus-connector/src/cli_control.rs new file mode 100644 index 000000000..4bf5191c6 --- /dev/null +++ b/crates/colossus-connector/src/cli_control.rs @@ -0,0 +1,229 @@ +//! Owner-private non-secret CLI lifecycle control; never authorizes cloud work. +use crate::{ConnectionConfig, ConnectorStatus}; +use colossus_home::{ColossusHome, ConfinedRoot}; +use fs4::fs_std::FileExt; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use sha2::{Digest, Sha256}; +use std::{ + io::{Read, Seek, Write}, + path::{Path, PathBuf}, + time::Duration, +}; + +type Error = Box; +#[derive(Clone)] +pub(super) struct Control { + root: ConfinedRoot, + name: String, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Report { + pub enrolled: bool, + pub node_id: Option, + pub project_id: Option, + pub endpoint: Option, + pub status: ConnectorStatus, + pub run_instance: Option, + pub reported_at_ms: u64, +} +impl Report { + pub fn saved(config: Option<&ConnectionConfig>) -> Self { + Self { + enrolled: config.is_some(), + node_id: config.map(|config| config.node_id.clone()), + project_id: config.map(|config| config.project_id.clone()), + endpoint: config.map(|config| config.endpoint.clone()), + status: if config.is_some_and(|config| config.revoked) { + ConnectorStatus::Revoked + } else { + ConnectorStatus::Disconnected + }, + run_instance: None, + reported_at_ms: now(), + } + } + fn live(&self) -> bool { + self.run_instance.is_some() + && now().saturating_sub(self.reported_at_ms) < 10_000 + && matches!( + self.status, + ConnectorStatus::Connected + | ConnectorStatus::Connecting + | ConnectorStatus::Reconnecting + ) + } +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Stop { + run_instance: String, +} +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u64::MAX as u128) as u64) + .unwrap_or(0) +} +impl Control { + pub fn new(name: &str) -> Result { + colossus_ports::CredentialKey::new("cloud-connector", name)?; + let home = ColossusHome::resolve_and_ensure()?; + let root = ConfinedRoot::bind( + home.confined_root() + .prepare_directory(Path::new("cloud-connector/cli-control"))?, + )?; + Ok(Self { + root, + name: hex::encode(Sha256::digest(name.as_bytes())), + }) + } + fn path(&self, suffix: &str) -> PathBuf { + PathBuf::from(format!("{}-{suffix}.json", self.name)) + } + fn read(&self, suffix: &str) -> Result, Error> { + let retained = match self.root.open_existing_file(&self.path(suffix)) { + Ok(file) => file, + Err(colossus_home::HomeError::Io { source, .. }) + if source.kind() == std::io::ErrorKind::NotFound => + { + return Ok(None); + } + Err(error) => return Err(error.into()), + }; + let mut file = retained.file(); + FileExt::lock_shared(file)?; + let result = (|| { + retained.revalidate(&self.root)?; + let mut bytes = Vec::new(); + Read::by_ref(&mut file) + .take(16_385) + .read_to_end(&mut bytes)?; + if bytes.len() > 16_384 { + return Err("connector control record exceeds bound".into()); + } + if bytes.is_empty() { + return Ok(None); + } + let value = serde_json::from_slice(&bytes)?; + retained.revalidate(&self.root)?; + Ok(Some(value)) + })(); + FileExt::unlock(file)?; + result + } + fn write(&self, suffix: &str, value: &T) -> Result<(), Error> { + let bytes = serde_json::to_vec(value)?; + if bytes.len() > 16_384 { + return Err("connector control record exceeds bound".into()); + } + let retained = self.root.open_file(&self.path(suffix))?; + let mut file = retained.file(); + FileExt::lock_exclusive(file)?; + let result = (|| { + retained.revalidate(&self.root)?; + file.rewind()?; + file.write_all(&bytes)?; + file.set_len(bytes.len() as u64)?; + file.sync_data()?; + retained.revalidate(&self.root)?; + Ok(()) + })(); + FileExt::unlock(file)?; + result + } + pub fn report(&self) -> Result, Error> { + self.read("status") + } + pub fn save(&self, report: &Report) -> Result<(), Error> { + self.write("status", report) + } + pub fn stopped(&self, run: &str) -> Result { + Ok(self + .read::("stop")? + .is_some_and(|request| request.run_instance == run)) + } + pub async fn disconnect(&self) -> Result<(), Error> { + let Some(report) = self.report()?.filter(Report::live) else { + return Ok(()); + }; + let run = report + .run_instance + .ok_or("connector run identity unavailable")?; + self.write( + "stop", + &Stop { + run_instance: run.clone(), + }, + )?; + for _ in 0..50 { + tokio::time::sleep(Duration::from_millis(200)).await; + if self + .report()? + .is_none_or(|report| report.run_instance.as_deref() != Some(&run) || !report.live()) + { + return Ok(()); + } + } + Err("connector disconnect timed out; inspect its process before retrying".into()) + } + pub fn output(report: &Report) -> serde_json::Value { + let mut value = serde_json::to_value(report).unwrap_or(serde_json::Value::Null); + if let Some(object) = value.as_object_mut() { + object.remove("run_instance"); + object.insert( + "status_fresh".into(), + serde_json::Value::Bool(now().saturating_sub(report.reported_at_ms) < 10_000), + ); + if report.run_instance.is_some() + && !report.live() + && !matches!( + report.status, + ConnectorStatus::Disconnected | ConnectorStatus::Revoked + ) + { + object.insert("status".into(), serde_json::Value::String("unknown".into())); + } + } + value + } + pub fn heartbeat(&self, report: &mut Report, status: ConnectorStatus) -> Result<(), Error> { + report.status = status; + report.reported_at_ms = now(); + self.save(report) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn restart_ignores_stale_stop_and_stale_status_is_unknown() { + let temporary = tempfile::tempdir().unwrap(); + let control = Control { + root: ConfinedRoot::bind(temporary.path().canonicalize().unwrap().join("control")) + .unwrap(), + name: "fixture".into(), + }; + control + .write( + "stop", + &Stop { + run_instance: "previous-run".into(), + }, + ) + .unwrap(); + assert!(control.stopped("previous-run").unwrap()); + assert!(!control.stopped("new-run").unwrap()); + let mut report = Report::saved(None); + report.run_instance = Some("new-run".into()); + report.status = ConnectorStatus::Connected; + report.reported_at_ms = 0; + control.save(&report).unwrap(); + let output = Control::output(&control.report().unwrap().unwrap()); + assert_eq!(output["status"], "unknown"); + assert_eq!(output["status_fresh"], false); + assert!(output.get("run_instance").is_none()); + } +} diff --git a/crates/colossus-connector/src/connection.rs b/crates/colossus-connector/src/connection.rs new file mode 100644 index 000000000..422f12339 --- /dev/null +++ b/crates/colossus-connector/src/connection.rs @@ -0,0 +1,456 @@ +use colossus_cloud_protocol::{ + CloudReply, Command, MAX_ACTIVE_TASKS, MAX_PAYLOAD_BYTES, MAX_QUEUED_FRAMES, PROTOCOL_MAJOR, + decode, encode, + v1alpha1::{ + self as wire, control_frame, runtime_connection_client::RuntimeConnectionClient, + runtime_frame, + }, +}; +use colossus_sdk::{ + AgentRunClient, ApiErrorCode, CancelRunRequest, GetRunRequest, WatchRunRequest, +}; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use std::{collections::HashSet, sync::Arc, time::Duration}; +use tokio::{ + sync::{Semaphore, mpsc, watch}, + task::JoinSet, +}; +use tokio_stream::wrappers::ReceiverStream; +use tonic::{ + Status, + transport::{Certificate, ClientTlsConfig, Endpoint, Identity}, +}; +use zeroize::Zeroizing; + +/// Non-secret persisted enrollment and local runtime identity binding. +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConnectionConfig { + /// HTTPS cloud endpoint with independently enrolled CA trust. + pub endpoint: String, + /// Fixed project namespace. + pub project_id: String, + /// Stable cloud node identity. + pub node_id: String, + /// Independently verified local runtime instance. + pub instance_id: String, + /// PEM client leaf issued for this enrollment. + pub certificate_pem: String, + /// Independently obtained cloud TLS CA chain. + pub ca_pem: String, + /// Local runtime's authenticated optional capabilities. + pub capabilities: Vec, + /// Native acknowledgement that this enrollment has been revoked. + #[serde(default)] + pub revoked: bool, +} +/// Sanitized lifecycle state, suitable for CLI and Desktop projection. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConnectorStatus { + /// Opening an outbound stream. + Connecting, + /// Authenticated cloud stream is live. + Connected, + /// Transport disappeared; fixed-node work continues locally. + Reconnecting, + /// Explicit disconnect completed without cancelling agent work. + Disconnected, + /// Cloud enrollment or client certificate was rejected; no retry occurs. + Revoked, +} +/// Outbound connector bound to an independently authorized local application. +pub struct RuntimeConnector { + config: ConnectionConfig, + key: Zeroizing, + runs: Arc, + enrollment: Option, +} +impl RuntimeConnector { + /// Bind native-held credentials and an SDK client carrying a dedicated cloud grant. + /// No renderer credential or generic worker tunnel is accepted. + pub fn new( + config: ConnectionConfig, + key: Zeroizing, + runs: Arc, + ) -> Result { + if config.revoked { + return Err("cloud enrollment was revoked"); + } + let url = url::Url::parse(&config.endpoint).map_err(|_| "invalid cloud endpoint")?; + if url.scheme() != "https" + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.path() != "/" + { + return Err("cloud endpoint must be HTTPS"); + } + if config.project_id.is_empty() + || config.node_id.is_empty() + || config.instance_id.is_empty() + || key.len() > 16384 + || key.is_empty() + { + return Err("invalid connector enrollment"); + } + Ok(Self { + config, + key, + runs, + enrollment: None, + }) + } + /// Retain the native enrollment authority for automatic, durable certificate renewal. + pub fn with_enrollment_store(mut self, store: crate::EnrollmentStore) -> Self { + self.enrollment = Some(store); + self + } + /// Reconnect with bounded backoff until explicit shutdown or SDK lifetime closure. Dropping this future + /// cancels watches only; it never cancels accepted runtime tasks or closes a daemon. + pub async fn run( + mut self, + mut shutdown: watch::Receiver, + status: watch::Sender, + ) -> Result<(), &'static str> { + let mut delay = 1u64; + loop { + if self.runs.is_closed() { + status.send_replace(ConnectorStatus::Disconnected); + return Ok(()); + } + if *shutdown.borrow() { + status.send_replace(ConnectorStatus::Disconnected); + return Ok(()); + } + // Reconcile a persisted rotation even when the old certificate is + // still fresh: the host may have committed it before the ACK was lost. + if let Some(store) = &self.enrollment { + let renewed = tokio::select! { + result = store.renew(false) => result, + _ = shutdown.changed() => { + status.send_replace(ConnectorStatus::Disconnected); + return Ok(()); + } + }; + if let Err(error) = renewed { + if error == "cloud enrollment was revoked" { + status.send_replace(ConnectorStatus::Revoked); + return Err(error); + } + status.send_replace(ConnectorStatus::Reconnecting); + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(delay)) => {}, + _ = shutdown.changed() => { + status.send_replace(ConnectorStatus::Disconnected); + return Ok(()); + } + } + delay = (delay * 2).min(30); + continue; + } + let store = store.clone(); + let (config, key) = tokio::task::spawn_blocking(move || store.load()) + .await + .map_err(|_| "enrollment vault unavailable")?? + .ok_or("enrollment unavailable")?; + self.config = config; + self.key = key; + } + status.send_replace(if delay == 1 { + ConnectorStatus::Connecting + } else { + ConnectorStatus::Reconnecting + }); + let result = tokio::select! {result=self.connect_once(&status)=>result,_=shutdown.changed()=>{status.send_replace(ConnectorStatus::Disconnected);return Ok(());}}; + if self.runs.is_closed() { + status.send_replace(ConnectorStatus::Disconnected); + return Ok(()); + } + if result.as_ref().err().is_some_and(|error| { + matches!( + error.code(), + tonic::Code::PermissionDenied | tonic::Code::Unauthenticated + ) + }) { + status.send_replace(ConnectorStatus::Revoked); + return Err("cloud enrollment was rejected or revoked"); + } + status.send_replace(ConnectorStatus::Reconnecting); + tokio::select! {_=tokio::time::sleep(Duration::from_secs(delay))=>{},_=shutdown.changed()=>{status.send_replace(ConnectorStatus::Disconnected);return Ok(());}} + delay = (delay * 2).min(30); + } + } + async fn connect_once(&self, status: &watch::Sender) -> Result<(), Status> { + let tls = ClientTlsConfig::new() + .ca_certificate(Certificate::from_pem(self.config.ca_pem.clone())) + .identity(Identity::from_pem( + self.config.certificate_pem.clone(), + self.key.as_bytes(), + )); + let endpoint = Endpoint::from_shared(self.config.endpoint.clone()) + .map_err(|_| Status::invalid_argument("invalid endpoint"))? + .tls_config(tls) + .map_err(|_| Status::invalid_argument("invalid TLS identity"))? + .connect_timeout(Duration::from_secs(10)); + // Endpoint's connect_timeout covers TCP. Bound TLS and HTTP/2 startup too. + let channel = tokio::time::timeout(Duration::from_secs(10), endpoint.connect()) + .await + .map_err(|_| Status::deadline_exceeded("cloud channel startup timeout"))? + .map_err(|_| Status::unavailable("cloud transport unavailable"))?; + let mut client = RuntimeConnectionClient::new(channel) + .max_decoding_message_size(MAX_PAYLOAD_BYTES + 16384) + .max_encoding_message_size(MAX_PAYLOAD_BYTES + 16384); + let (sender, receiver) = mpsc::channel(MAX_QUEUED_FRAMES); + send( + &sender, + runtime_frame::Body::Hello(wire::RuntimeHello { + protocol_major: PROTOCOL_MAJOR, + node_id: self.config.node_id.clone(), + instance_id: self.config.instance_id.clone(), + capabilities: self.config.capabilities.clone(), + project_id: self.config.project_id.clone(), + }), + ) + .await?; + let mut stream = tokio::time::timeout( + Duration::from_secs(10), + client.connect(ReceiverStream::new(receiver)), + ) + .await + .map_err(|_| Status::deadline_exceeded("cloud response headers timeout"))?? + .into_inner(); + let welcome = tokio::time::timeout(Duration::from_secs(10), stream.message()) + .await + .map_err(|_| Status::deadline_exceeded("cloud welcome timeout"))?? + .ok_or_else(|| Status::unavailable("cloud closed"))?; + if !matches!(welcome.body,Some(control_frame::Body::Welcome(ref welcome)) if welcome.protocol_major==PROTOCOL_MAJOR) + { + return Err(Status::failed_precondition("cloud protocol mismatch")); + } + status.send_replace(ConnectorStatus::Connected); + let mut heartbeat = tokio::time::interval(Duration::from_secs(5)); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let permits = Arc::new(Semaphore::new(MAX_ACTIVE_TASKS)); + let mut watchers: JoinSet> = JoinSet::new(); + let mut watched = HashSet::new(); + loop { + tokio::select! { + _=heartbeat.tick()=>{ + if self.runs.is_closed() { return Err(Status::unavailable("local runtime closed")); } + if self.enrollment.is_some() && crate::enrollment::certificate_due(&self.config.certificate_pem).map_err(|_|Status::failed_precondition("invalid connector certificate"))? { return Ok(()); } + send(&sender,runtime_frame::Body::Heartbeat(wire::RuntimeHeartbeat{ready:!self.runs.is_closed()})).await?; + }, + finished=watchers.join_next(),if !watchers.is_empty()=>{ + finished.ok_or_else(||Status::internal("watch lost"))?.map_err(|_|Status::internal("watch failed"))??; + } + frame=stream.message()=>{ + let Some(frame)=frame? else{return Ok(());}; + match frame.body { + Some(control_frame::Body::Command(command))=>{ + let operation:Command=decode(&command.command_json).map_err(|_|Status::invalid_argument("invalid cloud command"))?; + if let Command::Watch{run_id,snapshot_only}=operation { + if watched.contains(&command.task_id){continue;} + let permit=permits.clone().try_acquire_owned().map_err(|_|Status::resource_exhausted("local watch bound"))?; + if watched.len()>=4096{return Err(Status::resource_exhausted("connection task bound"));} + watched.insert(command.task_id.clone()); + let runs=self.runs.clone();let sender=sender.clone(); + watchers.spawn(async move{let _permit=permit;watch_task(runs,sender,command.task_id,run_id,command.after_sequence,snapshot_only).await}); + } else { + let mut reply=execute(self.runs.as_ref(),operation).await; + // An unavailable reply can follow an accepted mutation. Reconnect + // with its stable runtime idempotency key to reconcile it. + if matches!(&reply,CloudReply::Failed{error} if error.code==ApiErrorCode::Unavailable){return Err(Status::unavailable("local runtime unavailable"));} + if let CloudReply::Cancelled { response } = &mut reply { + crate::released::compact_cancellation(response).map_err(Status::resource_exhausted)?; + } + let limited_run = if let CloudReply::Run {run} = &mut reply { + crate::released::compact_snapshot(run).map_err(Status::resource_exhausted)?.then(||run.run.run_id.clone()) + } else {None}; + send(&sender,runtime_frame::Body::Receipt(wire::CommandReceipt{command_id:command.command_id,task_id:command.task_id.clone(),reply_json:encode(&reply).map_err(|_|Status::resource_exhausted("receipt too large"))?})).await?; + if let Some(run_id) = limited_run { + send(&sender,runtime_frame::Body::OutputLimit(wire::ReleasedOutputLimit{task_id:command.task_id, run_id, after_sequence:0})).await?; + } + } + } + Some(control_frame::Body::Acknowledgement(_))=>{}, + _=>return Err(Status::invalid_argument("unexpected cloud frame")), + } + } + } + } + } +} +async fn execute(runs: &dyn AgentRunClient, command: Command) -> CloudReply { + let result = match command { + Command::Create { request } => match runs.create_run(*request).await { + Ok(created) => runs + .get_run(GetRunRequest { + run_id: created.run.run_id, + }) + .await + .map(|run| CloudReply::Run { run: Box::new(run) }), + Err(error) => Err(error), + }, + Command::Cancel { + run_id, + idempotency_key, + } => runs + .cancel_run(CancelRunRequest { + run_id, + idempotency_key, + }) + .await + .map(|response| CloudReply::Cancelled { response }), + Command::Respond { request } => runs + .respond_interaction(*request) + .await + .map(|response| CloudReply::Responded { response }), + Command::Watch { .. } => unreachable!("watch dispatch is handled separately"), + }; + result.unwrap_or_else(|error| CloudReply::Failed { error }) +} +async fn watch_task( + runs: Arc, + sender: mpsc::Sender, + task_id: String, + run_id: String, + after: u64, + snapshot_only: bool, +) -> Result<(), Status> { + let mut snapshot = runs + .get_run(GetRunRequest { + run_id: run_id.clone(), + }) + .await + .map_err(|_| Status::unavailable("local snapshot unavailable"))?; + let limited = + crate::released::compact_snapshot(&mut snapshot).map_err(Status::resource_exhausted)?; + send( + &sender, + runtime_frame::Body::Snapshot(wire::RuntimeSnapshot { + task_id: task_id.clone(), + snapshot_json: encode(&snapshot) + .map_err(|_| Status::resource_exhausted("snapshot bound"))?, + }), + ) + .await?; + if limited && !snapshot_only { + send( + &sender, + runtime_frame::Body::OutputLimit(wire::ReleasedOutputLimit { + task_id: task_id.clone(), + run_id: run_id.clone(), + after_sequence: after, + }), + ) + .await?; + return Ok(()); + } + if snapshot_only { + let mut snapshot = snapshot; + loop { + if snapshot.run.terminal.is_some() { + return Ok(()); + } + tokio::time::sleep(Duration::from_secs(2)).await; + snapshot = runs + .get_run(GetRunRequest { + run_id: run_id.clone(), + }) + .await + .map_err(|_| Status::unavailable("local snapshot unavailable"))?; + crate::released::compact_snapshot(&mut snapshot).map_err(Status::resource_exhausted)?; + send( + &sender, + runtime_frame::Body::Snapshot(wire::RuntimeSnapshot { + task_id: task_id.clone(), + snapshot_json: encode(&snapshot) + .map_err(|_| Status::resource_exhausted("snapshot bound"))?, + }), + ) + .await?; + } + } + let mut stream = runs + .watch_run(WatchRunRequest { + run_id: run_id.clone(), + after_sequence: after, + }) + .await + .map_err(|_| Status::unavailable("local watch unavailable"))?; + while let Some(update) = stream.next().await { + let update = update.map_err(|_| Status::unavailable("local feed unavailable"))?; + let update_json = match encode(&update) { + Ok(bytes) => bytes, + Err(_) => { + send( + &sender, + runtime_frame::Body::OutputLimit(wire::ReleasedOutputLimit { + task_id: task_id.clone(), + run_id: run_id.clone(), + after_sequence: update.sequence.saturating_sub(1), + }), + ) + .await?; + // Let the host durably record the limit and close this stream. + // Closing locally here could discard the queued limit frame. + return Ok(()); + } + }; + send( + &sender, + runtime_frame::Body::Event(wire::ReleasedRunEvent { + task_id: task_id.clone(), + run_id: run_id.clone(), + sequence: update.sequence, + update_json, + }), + ) + .await?; + let mut snapshot = runs + .get_run(GetRunRequest { + run_id: run_id.clone(), + }) + .await + .map_err(|_| Status::unavailable("local snapshot unavailable"))?; + let limited = + crate::released::compact_snapshot(&mut snapshot).map_err(Status::resource_exhausted)?; + send( + &sender, + runtime_frame::Body::Snapshot(wire::RuntimeSnapshot { + task_id: task_id.clone(), + snapshot_json: encode(&snapshot) + .map_err(|_| Status::resource_exhausted("snapshot bound"))?, + }), + ) + .await?; + if limited { + send( + &sender, + runtime_frame::Body::OutputLimit(wire::ReleasedOutputLimit { + task_id: task_id.clone(), + run_id: run_id.clone(), + after_sequence: update.sequence, + }), + ) + .await?; + return Ok(()); + } + } + Ok(()) +} +async fn send( + sender: &mpsc::Sender, + body: runtime_frame::Body, +) -> Result<(), Status> { + tokio::time::timeout( + Duration::from_secs(10), + sender.send(wire::RuntimeFrame { body: Some(body) }), + ) + .await + .map_err(|_| Status::resource_exhausted("outbound backpressure deadline"))? + .map_err(|_| Status::cancelled("cloud stream closed")) +} diff --git a/crates/colossus-connector/src/enrollment.rs b/crates/colossus-connector/src/enrollment.rs new file mode 100644 index 000000000..ddce35e14 --- /dev/null +++ b/crates/colossus-connector/src/enrollment.rs @@ -0,0 +1,190 @@ +use crate::ConnectionConfig; +use colossus_cloud::CloudNode; +use colossus_contracts::VaultRecord; +use colossus_ports::{CredentialKey, CredentialVault}; +use rcgen::{CertificateParams, KeyPair}; +use serde::{Deserialize, Serialize}; +use std::{sync::Arc, time::Duration}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; +mod renewal; +mod revocation; +pub(crate) use renewal::certificate_due; + +/// Native enrollment store, shared by CLI and Desktop; secrets stay in the encrypted vault. +#[derive(Clone)] +pub struct EnrollmentStore { + vault: Arc, + key: CredentialKey, +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Record { + config: Option, + pending: Pending, + #[serde(default)] + rotation: Option, +} +#[derive(Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] +#[serde(deny_unknown_fields)] +struct Pending { + client_key_pem: String, + csr_pem: String, + token: String, + enrollment_url: String, + instance_id: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Enrolled { + node: CloudNode, + certificate_pem: String, + ca_pem: String, + grpc_endpoint: String, +} +impl EnrollmentStore { + /// Bind an opaque enrollment record under the caller's protected native vault. + pub fn new(vault: Arc, id: &str) -> Result { + Ok(Self { + vault, + key: CredentialKey::new("cloud-connector", id) + .map_err(|_| "invalid enrollment identity")?, + }) + } + /// Generate the key locally and persist the pending exchange before contacting + /// the host. Retrying uses the same CSR and invitation after a lost response. + pub async fn enroll( + &self, + url: String, + token: Zeroizing, + instance_id: String, + capabilities: Vec, + allow_loopback_http: bool, + ) -> Result { + let endpoint = url::Url::parse(&url).map_err(|_| "invalid enrollment URL")?; + let local = matches!( + endpoint.host_str(), + Some("localhost" | "127.0.0.1" | "[::1]") + ); + if !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.query().is_some() + || endpoint.fragment().is_some() + || !(endpoint.scheme() == "https" + || (allow_loopback_http && local && endpoint.scheme() == "http")) + { + return Err("enrollment requires HTTPS"); + } + let store = self.clone(); + let existing = tokio::task::spawn_blocking(move || store.vault.read(&store.key)) + .await + .map_err(|_| "enrollment vault unavailable")? + .map_err(|_| "enrollment vault unavailable")?; + let mut record: Record = if let Some(existing) = existing { + serde_json::from_slice(existing.expose()).map_err(|_| "enrollment record invalid")? + } else { + if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("invalid enrollment invitation"); + } + let key = KeyPair::generate().map_err(|_| "connector key generation failed")?; + let csr = CertificateParams::default() + .serialize_request(&key) + .map_err(|_| "connector certificate request failed")? + .pem() + .map_err(|_| "connector certificate encoding failed")?; + Record { + config: None, + rotation: None, + pending: Pending { + client_key_pem: key.serialize_pem(), + csr_pem: csr, + token: token.to_string(), + enrollment_url: url.clone(), + instance_id: instance_id.clone(), + }, + } + }; + if record.pending.enrollment_url != url || record.pending.instance_id != instance_id { + return Err("pending enrollment belongs to another runtime or cloud"); + } + if let Some(config) = &record.config { + return Ok(config.clone()); + } + self.save_async(&record).await?; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(15)) + .build() + .map_err(|_| "enrollment HTTP unavailable")?; + let response=client.post(&url).json(&serde_json::json!({"token":record.pending.token,"instance_id":record.pending.instance_id,"csr_pem":record.pending.csr_pem})).send().await.map_err(|_|"enrollment transport unavailable; retry the same exchange")?; + if !response.status().is_success() { + return Err("cloud enrollment rejected"); + } + if response.content_length().is_some_and(|bytes| bytes > 65536) { + return Err("enrollment response too large"); + } + use futures::StreamExt as _; + let mut body = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = body.next().await { + let chunk = chunk.map_err(|_| "enrollment response unavailable")?; + if bytes.len().saturating_add(chunk.len()) > 65536 { + return Err("enrollment response too large"); + } + bytes.extend_from_slice(&chunk); + } + let enrolled: Enrolled = + serde_json::from_slice(&bytes).map_err(|_| "invalid enrollment response")?; + if enrolled.node.instance_id != instance_id { + return Err("enrolled runtime identity mismatch"); + } + let config = ConnectionConfig { + endpoint: enrolled.grpc_endpoint, + project_id: enrolled.node.project_id, + node_id: enrolled.node.node_id, + instance_id, + certificate_pem: enrolled.certificate_pem, + ca_pem: enrolled.ca_pem, + capabilities, + revoked: false, + }; + record.config = Some(config.clone()); + record.pending.token.zeroize(); + self.save_async(&record).await?; + Ok(config) + } + /// Load native enrollment and private TLS key without serializing secrets to UI. + pub fn load(&self) -> Result)>, &'static str> { + let Some(record) = self + .vault + .read(&self.key) + .map_err(|_| "enrollment vault unavailable")? + else { + return Ok(None); + }; + let record: Record = + serde_json::from_slice(record.expose()).map_err(|_| "enrollment record invalid")?; + Ok(record.config.map(|config| { + ( + config, + Zeroizing::new(record.pending.client_key_pem.clone()), + ) + })) + } + /// Remove the local enrollment after disconnect. This does not revoke the remote node. + pub fn forget(&self) -> Result<(), &'static str> { + self.vault + .delete(&self.key) + .map_err(|_| "enrollment cleanup failed") + } + async fn save_async(&self, record: &Record) -> Result<(), &'static str> { + let encoded = + Zeroizing::new(serde_json::to_vec(record).map_err(|_| "enrollment encoding failed")?); + let record = + VaultRecord::new(encoded.to_vec()).map_err(|_| "enrollment record exceeds bound")?; + let store = self.clone(); + tokio::task::spawn_blocking(move || store.vault.write(&store.key, &record)) + .await + .map_err(|_| "enrollment persistence failed")? + .map_err(|_| "enrollment persistence failed") + } +} diff --git a/crates/colossus-connector/src/enrollment/renewal.rs b/crates/colossus-connector/src/enrollment/renewal.rs new file mode 100644 index 000000000..75521b698 --- /dev/null +++ b/crates/colossus-connector/src/enrollment/renewal.rs @@ -0,0 +1,122 @@ +use super::*; +use colossus_cloud_protocol::v1alpha1::{ + CertificateRenewal, runtime_connection_client::RuntimeConnectionClient, +}; +use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity}; + +#[derive(Serialize, Deserialize, Zeroize, ZeroizeOnDrop)] +#[serde(deny_unknown_fields)] +pub(super) struct Rotation { + client_key_pem: String, + csr_pem: String, + renewal_id: String, +} +pub(crate) fn certificate_due(pem: &str) -> Result { + let (_, pem) = x509_parser::pem::parse_x509_pem(pem.as_bytes()) + .map_err(|_| "invalid connector certificate")?; + let certificate = pem + .parse_x509() + .map_err(|_| "invalid connector certificate")?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| "invalid system clock")? + .as_secs(); + Ok(certificate.validity().not_after.timestamp() <= now as i64 + 7 * 24 * 60 * 60) +} +impl EnrollmentStore { + /// Renew at seven days remaining, or explicitly rotate before expiry. Persisting + /// the next key/CSR before RPC makes a lost response recoverable with the old leaf. + pub async fn renew(&self, force: bool) -> Result<(), &'static str> { + let store = self.clone(); + let stored = tokio::task::spawn_blocking(move || store.vault.read(&store.key)) + .await + .map_err(|_| "enrollment vault unavailable")? + .map_err(|_| "enrollment vault unavailable")? + .ok_or("connector is not enrolled")?; + let mut record: Record = + serde_json::from_slice(stored.expose()).map_err(|_| "invalid enrollment record")?; + let config = record + .config + .as_ref() + .ok_or("connector is not enrolled")? + .clone(); + if config.revoked { + return Err("cloud enrollment was revoked"); + } + if record.rotation.is_none() && !force && !certificate_due(&config.certificate_pem)? { + return Ok(()); + } + if record.rotation.is_none() { + let key = KeyPair::generate().map_err(|_| "connector key generation failed")?; + let csr = CertificateParams::default() + .serialize_request(&key) + .map_err(|_| "certificate request failed")? + .pem() + .map_err(|_| "certificate request failed")?; + record.rotation = Some(Rotation { + client_key_pem: key.serialize_pem(), + csr_pem: csr, + renewal_id: uuid::Uuid::now_v7().simple().to_string(), + }); + self.save_async(&record).await?; + } + let rotation = record + .rotation + .as_ref() + .ok_or("renewal state unavailable")?; + let tls = ClientTlsConfig::new() + .ca_certificate(Certificate::from_pem(config.ca_pem)) + .identity(Identity::from_pem( + config.certificate_pem, + record.pending.client_key_pem.as_bytes(), + )); + let endpoint = Endpoint::from_shared(config.endpoint) + .map_err(|_| "invalid cloud endpoint")? + .tls_config(tls) + .map_err(|_| "invalid connector identity")? + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(15)); + let channel = tokio::time::timeout(Duration::from_secs(10), endpoint.connect()) + .await + .map_err(|_| "certificate renewal channel startup timeout")? + .map_err(|_| "certificate renewal transport unavailable")?; + let certificate = RuntimeConnectionClient::new(channel) + .max_decoding_message_size(65536) + .max_encoding_message_size(65536) + .renew_certificate(CertificateRenewal { + project_id: config.project_id, + node_id: config.node_id, + instance_id: config.instance_id, + renewal_id: rotation.renewal_id.clone(), + csr_pem: rotation.csr_pem.clone(), + }) + .await + .map_err(|error| match error.code() { + tonic::Code::PermissionDenied | tonic::Code::Unauthenticated => { + "cloud enrollment was revoked" + } + _ => "certificate renewal rejected; retry the same exchange", + })? + .into_inner() + .certificate_pem; + if certificate_due(&certificate)? { + return Err("renewed certificate lifetime invalid"); + } + let (_, pem) = x509_parser::pem::parse_x509_pem(certificate.as_bytes()) + .map_err(|_| "invalid renewed certificate")?; + let leaf = pem + .parse_x509() + .map_err(|_| "invalid renewed certificate")?; + let key = KeyPair::from_pem(&rotation.client_key_pem).map_err(|_| "invalid renewal key")?; + if leaf.public_key().subject_public_key.data.as_ref() != key.public_key_raw() { + return Err("renewed certificate does not bind the requested key"); + } + if let Some(config) = &mut record.config { + config.certificate_pem = certificate; + } + record.pending.client_key_pem.zeroize(); + record.pending.client_key_pem = rotation.client_key_pem.clone(); + record.rotation = None; + self.save_async(&record).await + } +} diff --git a/crates/colossus-connector/src/enrollment/revocation.rs b/crates/colossus-connector/src/enrollment/revocation.rs new file mode 100644 index 000000000..69feb39cc --- /dev/null +++ b/crates/colossus-connector/src/enrollment/revocation.rs @@ -0,0 +1,63 @@ +use super::*; +use colossus_cloud_protocol::v1alpha1::{ + EnrollmentIdentity, runtime_connection_client::RuntimeConnectionClient, +}; +use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity}; + +impl EnrollmentStore { + /// Revoke this exact native enrollment using its own client certificate. + /// Persist the acknowledgement locally; a lost acknowledgement can be retried. + pub async fn revoke(&self) -> Result<(), &'static str> { + let mut record = self.read_record().await?; + if record.rotation.is_some() { + self.renew(false).await?; + record = self.read_record().await?; + } + let config = record.config.as_ref().ok_or("connector is not enrolled")?; + if config.revoked { + return Ok(()); + } + let tls = ClientTlsConfig::new() + .ca_certificate(Certificate::from_pem(&config.ca_pem)) + .identity(Identity::from_pem( + &config.certificate_pem, + record.pending.client_key_pem.as_bytes(), + )); + let endpoint = Endpoint::from_shared(config.endpoint.clone()) + .map_err(|_| "invalid cloud endpoint")? + .tls_config(tls) + .map_err(|_| "invalid connector identity")? + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(15)); + let channel = tokio::time::timeout(Duration::from_secs(10), endpoint.connect()) + .await + .map_err(|_| "revocation channel startup timeout; retry the same enrollment")? + .map_err(|_| "revocation transport unavailable; retry the same enrollment")?; + RuntimeConnectionClient::new(channel) + .max_decoding_message_size(65536) + .max_encoding_message_size(65536) + .revoke_enrollment(EnrollmentIdentity { + project_id: config.project_id.clone(), + node_id: config.node_id.clone(), + instance_id: config.instance_id.clone(), + }) + .await + .map_err(|_| "cloud revocation rejected; retry or revoke in the web fleet")?; + record + .config + .as_mut() + .ok_or("connector is not enrolled")? + .revoked = true; + self.save_async(&record).await + } + + async fn read_record(&self) -> Result { + let store = self.clone(); + let stored = tokio::task::spawn_blocking(move || store.vault.read(&store.key)) + .await + .map_err(|_| "enrollment vault unavailable")? + .map_err(|_| "enrollment vault unavailable")? + .ok_or("connector is not enrolled")?; + serde_json::from_slice(stored.expose()).map_err(|_| "invalid enrollment record") + } +} diff --git a/crates/colossus-connector/src/headless.rs b/crates/colossus-connector/src/headless.rs new file mode 100644 index 000000000..af03c7f30 --- /dev/null +++ b/crates/colossus-connector/src/headless.rs @@ -0,0 +1,53 @@ +use colossus_credentials::{EnvironmentKeyStore, PlatformKeyStore}; +use colossus_home::ConfinedRoot; +use colossus_sdk::{CredentialProvider, SdkError, SdkResult, Secret}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; + +/// Domain-separated native secret reference shared with offline CLI administration. +/// This names encrypted material; it never contains the application bearer. +pub fn headless_credential_account(service: &str, account: &str) -> String { + let mut hash = Sha256::new(); + hash.update(b"colossus-headless-public-api-v1\0"); + hash.update((service.len() as u64).to_be_bytes()); + hash.update(service.as_bytes()); + hash.update(account.as_bytes()); + hex::encode(hash.finalize()) +} + +/// Explicit headless alternative to OS-keyring delivery, using owner-private sealed +/// credential envelopes and an operator-injected wrapping key. No bearer is read +/// from an environment variable or plaintext configuration. +pub struct HeadlessCredentialProvider { + store: Arc, + account: String, +} +impl HeadlessCredentialProvider { + /// Bind the exact private authority used by offline enrollment. + pub fn new( + root: ConfinedRoot, + variable: String, + service: &str, + account: &str, + ) -> SdkResult { + let store = + EnvironmentKeyStore::new(root, variable).map_err(|_| SdkError::Authentication)?; + Ok(Self { + store: Arc::new(store), + account: headless_credential_account(service, account), + }) + } +} +#[async_trait::async_trait] +impl CredentialProvider for HeadlessCredentialProvider { + async fn load(&self) -> SdkResult { + let store = self.store.clone(); + let account = self.account.clone(); + let value = tokio::task::spawn_blocking(move || store.read(&account)) + .await + .map_err(|_| SdkError::Authentication)? + .map_err(|_| SdkError::Authentication)? + .ok_or(SdkError::Authentication)?; + Secret::new(value.to_vec()) + } +} diff --git a/crates/colossus-connector/src/lib.rs b/crates/colossus-connector/src/lib.rs new file mode 100644 index 000000000..1f5582bcc --- /dev/null +++ b/crates/colossus-connector/src/lib.rs @@ -0,0 +1,11 @@ +//! Outbound, caller-bound runtime connector. Cloud traffic cannot access worker IPC. +mod connection; +pub use connection::{ConnectionConfig, ConnectorStatus, RuntimeConnector}; +mod enrollment; +pub use enrollment::EnrollmentStore; +mod cli; +mod cli_control; +mod headless; +mod released; +pub use cli::{ConnectorCommand, EnrollArguments, LocalArguments, StorageArguments, run_cli}; +pub use headless::{HeadlessCredentialProvider, headless_credential_account}; diff --git a/crates/colossus-connector/src/main.rs b/crates/colossus-connector/src/main.rs new file mode 100644 index 000000000..f135c6082 --- /dev/null +++ b/crates/colossus-connector/src/main.rs @@ -0,0 +1,15 @@ +//! Standalone cross-platform outbound connector executable. +use clap::Parser; +#[derive(Parser)] +#[command( + name = "colossus-connector", + about = "Connect an independently enrolled Colossus runtime to its cloud control plane" +)] +struct Arguments { + #[command(subcommand)] + command: colossus_connector::ConnectorCommand, +} +#[tokio::main] +async fn main() -> Result<(), Box> { + colossus_connector::run_cli(Arguments::parse().command).await +} diff --git a/crates/colossus-connector/src/released.rs b/crates/colossus-connector/src/released.rs new file mode 100644 index 000000000..ce10308ed --- /dev/null +++ b/crates/colossus-connector/src/released.rs @@ -0,0 +1,117 @@ +use colossus_cloud_protocol::{CloudReply, encode}; +use colossus_sdk::{CancelRunResponse, GetRunResponse, RunTerminal}; + +/// Cancellation of an already-terminal run can return its large released result. +/// The receipt confirms the exact run; snapshots own the explicit output-limit marker. +pub(crate) fn compact_cancellation(response: &mut CancelRunResponse) -> Result<(), &'static str> { + let mut snapshot = GetRunResponse { + run: response.run.clone(), + pending_interactions: vec![], + }; + compact_snapshot(&mut snapshot)?; + response.run = snapshot.run; + Ok(()) +} + +/// Keep lifecycle/identity metadata observable when released content exceeds the +/// cloud frame budget. Whole interactions are omitted, never partially approved. +/// The caller must durably mark the cloud projection incomplete. +pub(crate) fn compact_snapshot(snapshot: &mut GetRunResponse) -> Result { + let fits = |snapshot: &GetRunResponse| { + encode(&CloudReply::Run { + run: Box::new(snapshot.clone()), + }) + .is_ok() + }; + if fits(snapshot) { + return Ok(false); + } + if let Some(terminal) = &mut snapshot.run.terminal { + match terminal { + RunTerminal::Result(result) => result.output.clear(), + RunTerminal::Failure(failure) => { + failure.message = + "Run details exceed the cloud frame budget; open this run locally.".into() + } + RunTerminal::Cancellation(cancellation) => { + cancellation.message = + "Run details exceed the cloud frame budget; open this run locally.".into() + } + } + } + if !fits(snapshot) { + snapshot.pending_interactions.clear(); + } + if fits(snapshot) { + Ok(true) + } else { + Err("run metadata exceeds cloud frame budget") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use colossus_cloud_protocol::MAX_PAYLOAD_BYTES; + use colossus_sdk::{Run, RunMode, RunResult, RunStatus}; + + #[test] + fn oversized_terminal_output_keeps_identity_lifecycle_and_cursor() { + let mut snapshot = GetRunResponse { + run: Run { + plugin_skill_ids: vec![], + run_id: "run-1".into(), + session_id: "session-1".into(), + title: "Test task".into(), + role: "primary".into(), + mode: RunMode::Execute, + status: RunStatus::Completed, + created_at: "2026-10-05T00:00:00Z".into(), + updated_at: "2026-10-05T00:00:01Z".into(), + started_at: None, + finished_at: None, + last_sequence: 17, + pending_interaction_count: 0, + etag: "revision-17".into(), + archived: false, + terminal: Some(RunTerminal::Result(RunResult { + output: "x".repeat(MAX_PAYLOAD_BYTES), + plan_id: None, + plan_revision: None, + plan_status: None, + goal_id: None, + profile: "fixture".into(), + model_profile: "fixture".into(), + provider_profile: "fixture".into(), + model: "fixture".into(), + elapsed_seconds: 1.0, + })), + }, + pending_interactions: vec![], + }; + let mut cancelled = CancelRunResponse { + run: snapshot.run.clone(), + }; + compact_cancellation(&mut cancelled).unwrap(); + assert_eq!(cancelled.run.run_id, "run-1"); + assert_eq!(cancelled.run.status, RunStatus::Completed); + assert!( + encode(&CloudReply::Cancelled { + response: cancelled + }) + .is_ok() + ); + assert!(compact_snapshot(&mut snapshot).unwrap()); + assert_eq!(snapshot.run.run_id, "run-1"); + assert_eq!(snapshot.run.status, RunStatus::Completed); + assert_eq!(snapshot.run.last_sequence, 17); + assert_eq!(snapshot.run.etag, "revision-17"); + assert!( + encode(&CloudReply::Run { + run: Box::new(snapshot.clone()) + }) + .is_ok() + ); + assert!(!compact_snapshot(&mut snapshot).unwrap()); + } +} diff --git a/crates/colossus-credentials/src/database.rs b/crates/colossus-credentials/src/database.rs index e3677d7ee..e1e5a03a4 100644 --- a/crates/colossus-credentials/src/database.rs +++ b/crates/colossus-credentials/src/database.rs @@ -69,7 +69,22 @@ pub(crate) struct OpenedVault { pub file: ConfinedFile, // The lease outlives the database. Never unlink the lock file: another owner may // already hold its inode/handle while this process releases its own lease. - pub lease: ConfinedFile, + pub lease: VaultLease, +} + +/// Explicit unlock also releases a transient fork-inherited duplicate of the same +/// open file description. This field drops after the database and its file handles. +pub(crate) struct VaultLease(pub ConfinedFile); +impl std::ops::Deref for VaultLease { + type Target = ConfinedFile; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl Drop for VaultLease { + fn drop(&mut self) { + let _ = fs4::fs_std::FileExt::unlock(self.0.file()); + } } impl OpenedVault { diff --git a/crates/colossus-credentials/src/environment.rs b/crates/colossus-credentials/src/environment.rs new file mode 100644 index 000000000..9a1f74490 --- /dev/null +++ b/crates/colossus-credentials/src/environment.rs @@ -0,0 +1,117 @@ +use crate::{PlatformKeyStore, crypto}; +use colossus_contracts::CredentialError; +use colossus_home::ConfinedRoot; +use sha2::{Digest, Sha256}; +use std::{ + io::{Read, Seek, SeekFrom, Write}, + path::PathBuf, +}; +use zeroize::Zeroizing; + +/// Explicit headless credential authority. A configured 32-byte environment key +/// seals small vault-key envelopes inside an owner-private root. There is no +/// fallback to an OS store, plaintext, an ephemeral key, or another environment name. +/// Kubernetes/systemd should inject the key from their secret authority. +pub struct EnvironmentKeyStore { + root: ConfinedRoot, + variable: String, +} +impl EnvironmentKeyStore { + /// Bind an explicit key reference and protected root; creation happens on write. + pub fn new(root: ConfinedRoot, variable: String) -> Result { + if variable.is_empty() + || variable.len() > 128 + || !variable + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return Err(CredentialError::InvalidInput); + } + Ok(Self { root, variable }) + } + fn key(&self) -> Result { + let value = Zeroizing::new( + std::env::var(&self.variable).map_err(|_| CredentialError::Unavailable)?, + ); + let mut key = Zeroizing::new([0u8; 32]); + hex::decode_to_slice(value.trim(), key.as_mut()).map_err(|_| CredentialError::Corrupt)?; + Ok(key) + } + fn path(account: &str) -> Result { + if account.is_empty() || account.len() > 512 { + return Err(CredentialError::InvalidInput); + } + Ok(PathBuf::from(format!( + "headless-keys/{}.sealed", + hex::encode(Sha256::digest(account.as_bytes())) + ))) + } +} +impl PlatformKeyStore for EnvironmentKeyStore { + fn read(&self, account: &str) -> Result>>, CredentialError> { + let path = Self::path(account)?; + if !self + .root + .path() + .join(&path) + .try_exists() + .map_err(|_| CredentialError::Io)? + { + return Ok(None); + } + let file = self + .root + .open_existing_file(&path) + .map_err(|_| CredentialError::Io)?; + let mut encoded = Vec::new(); + file.file() + .take(513) + .read_to_end(&mut encoded) + .map_err(|_| CredentialError::Io)?; + if encoded.len() > 512 { + return Err(CredentialError::Corrupt); + } + let plaintext = crypto::decrypt(&self.key()?, account.as_bytes(), &encoded)?; + file.revalidate(&self.root) + .map_err(|_| CredentialError::Io)?; + if plaintext.len() > 256 { + return Err(CredentialError::Corrupt); + } + Ok(Some(plaintext)) + } + fn write(&self, account: &str, envelope: &[u8]) -> Result<(), CredentialError> { + if envelope.len() > 256 { + return Err(CredentialError::Oversized); + } + let encoded = crypto::encrypt(&self.key()?, account.as_bytes(), envelope)?; + let file = self + .root + .open_file(&Self::path(account)?) + .map_err(|_| CredentialError::Io)?; + let mut handle = file.file(); + handle + .seek(SeekFrom::Start(0)) + .map_err(|_| CredentialError::Io)?; + handle + .write_all(&encoded) + .map_err(|_| CredentialError::Io)?; + handle + .set_len(encoded.len() as u64) + .map_err(|_| CredentialError::Io)?; + handle.sync_all().map_err(|_| CredentialError::Io)?; + file.revalidate(&self.root) + .map_err(|_| CredentialError::Io)?; + self.root.sync_directory().map_err(|_| CredentialError::Io) + } + fn delete(&self, account: &str) -> Result<(), CredentialError> { + let path = self.root.path().join(Self::path(account)?); + if !path.try_exists().map_err(|_| CredentialError::Io)? { + return Ok(()); + } + self.root + .revalidate_file(&path) + .map_err(|_| CredentialError::Io)?; + std::fs::remove_file(path).map_err(|_| CredentialError::Io)?; + self.root.sync_directory().map_err(|_| CredentialError::Io) + } +} diff --git a/crates/colossus-credentials/src/lib.rs b/crates/colossus-credentials/src/lib.rs index d83633e0d..5c5f93457 100644 --- a/crates/colossus-credentials/src/lib.rs +++ b/crates/colossus-credentials/src/lib.rs @@ -8,5 +8,7 @@ mod vault; pub use platform::{PlatformKeyStore, SystemKeyStore}; pub use vault::PlatformCredentialVault; +mod environment; #[cfg(test)] mod tests; +pub use environment::EnvironmentKeyStore; diff --git a/crates/colossus-credentials/src/vault.rs b/crates/colossus-credentials/src/vault.rs index aedb4e9b6..f23d26813 100644 --- a/crates/colossus-credentials/src/vault.rs +++ b/crates/colossus-credentials/src/vault.rs @@ -150,6 +150,7 @@ impl PlatformCredentialVault { { return Err(CredentialError::Busy); } + let lease = crate::database::VaultLease(lease); let file = if create { self.root.open_file(Path::new(DATABASE_FILE)) } else { diff --git a/crates/colossus-credentials/tests/headless.rs b/crates/colossus-credentials/tests/headless.rs new file mode 100644 index 000000000..f63d2922a --- /dev/null +++ b/crates/colossus-credentials/tests/headless.rs @@ -0,0 +1,69 @@ +//! Headless key rotation/restart acceptance runs in isolated processes. Keeping +//! subprocess creation outside vault unit tests avoids inherited flock lifetimes. +use colossus_contracts::CredentialError; +use colossus_credentials::{EnvironmentKeyStore, PlatformKeyStore}; +use colossus_home::ColossusHome; + +#[test] +fn sealed_authority_survives_restart_and_rejects_changed_key() { + let directory = tempfile::tempdir().unwrap(); + let home = + ColossusHome::ensure_at(directory.path().canonicalize().unwrap().join("private")).unwrap(); + for (mode, wrapping_key) in [ + ("write", "a".repeat(64)), + ("read", "a".repeat(64)), + ("wrong-key", "b".repeat(64)), + ] { + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "headless_child", "--ignored"]) + .env("COLOSSUS_HEADLESS_TEST_ROOT", home.root()) + .env("COLOSSUS_HEADLESS_TEST_MODE", mode) + .env("COLOSSUS_HEADLESS_ACCEPTANCE_KEY", wrapping_key) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + } + for entry in std::fs::read_dir(home.root().join("headless-keys")).unwrap() { + let bytes = std::fs::read(entry.unwrap().path()).unwrap(); + assert!( + !bytes + .windows(b"public-api-bearer".len()) + .any(|window| window == b"public-api-bearer") + ); + } +} + +#[test] +#[ignore = "isolated process helper invoked by sealed authority acceptance"] +fn headless_child() { + let path = std::env::var_os("COLOSSUS_HEADLESS_TEST_ROOT").unwrap(); + let home = ColossusHome::ensure_at(path).unwrap(); + let store = EnvironmentKeyStore::new( + home.confined_root().clone(), + "COLOSSUS_HEADLESS_ACCEPTANCE_KEY".into(), + ) + .unwrap(); + match std::env::var("COLOSSUS_HEADLESS_TEST_MODE") + .unwrap() + .as_str() + { + "write" => { + assert!(store.read("authority").unwrap().is_none()); + assert_eq!(std::fs::read_dir(home.root()).unwrap().count(), 0); + store.write("authority", b"public-api-bearer").unwrap(); + } + "read" => assert_eq!( + store.read("authority").unwrap().unwrap().as_slice(), + b"public-api-bearer" + ), + "wrong-key" => assert_eq!( + store.read("authority").unwrap_err(), + CredentialError::Corrupt + ), + _ => panic!("unknown mode"), + } +} diff --git a/crates/colossus-credentials/tests/native_linux.rs b/crates/colossus-credentials/tests/native_linux.rs new file mode 100644 index 000000000..b6c12e665 --- /dev/null +++ b/crates/colossus-credentials/tests/native_linux.rs @@ -0,0 +1,71 @@ +//! Operator-owned native Secret Service acceptance, isolated from user collections. +#![cfg(target_os = "linux")] +use colossus_contracts::VaultRecord; +use colossus_credentials::PlatformCredentialVault; +use colossus_home::ColossusHome; +use colossus_ports::{CredentialKey, CredentialVault}; + +#[test] +#[ignore = "requires an isolated, unlocked Secret Service session; Linux Desktop builder runs this"] +fn native_secret_service_survives_process_restart() { + assert!(std::env::var_os("DBUS_SESSION_BUS_ADDRESS").is_some()); + let temporary = tempfile::tempdir().unwrap(); + let home = + ColossusHome::ensure_at(temporary.path().canonicalize().unwrap().join("private")).unwrap(); + for operation in ["write", "read", "delete"] { + let child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "native_secret_service_child", "--ignored"]) + .env("COLOSSUS_NATIVE_ACCEPTANCE_ROOT", home.root()) + .env("COLOSSUS_NATIVE_ACCEPTANCE_OPERATION", operation) + .output() + .unwrap(); + assert!( + child.status.success(), + "{}", + String::from_utf8_lossy(&child.stderr) + ); + } + for entry in std::fs::read_dir(home.root()).unwrap() { + let path = entry.unwrap().path(); + if path.is_file() { + let bytes = std::fs::read(path).unwrap(); + assert!( + !bytes + .windows(b"synthetic-native-secret".len()) + .any(|part| part == b"synthetic-native-secret") + ); + } + } +} + +#[test] +#[ignore = "isolated native credential acceptance subprocess"] +fn native_secret_service_child() { + let home = + ColossusHome::ensure_at(std::env::var_os("COLOSSUS_NATIVE_ACCEPTANCE_ROOT").unwrap()) + .unwrap(); + let vault = + PlatformCredentialVault::new(home.confined_root().clone(), "native-linux-acceptance") + .unwrap(); + let key = CredentialKey::new("acceptance", "native-restart").unwrap(); + match std::env::var("COLOSSUS_NATIVE_ACCEPTANCE_OPERATION") + .unwrap() + .as_str() + { + "write" => vault + .write( + &key, + &VaultRecord::new(b"synthetic-native-secret".to_vec()).unwrap(), + ) + .unwrap(), + "read" => assert_eq!( + vault.read(&key).unwrap().unwrap().expose(), + b"synthetic-native-secret" + ), + "delete" => { + vault.delete(&key).unwrap(); + vault.delete_key_for_uninstall().unwrap(); + } + _ => panic!("invalid acceptance operation"), + } +} diff --git a/crates/colossus-grpc/src/auth.rs b/crates/colossus-grpc/src/auth.rs index cb0426b93..a2db87c69 100644 --- a/crates/colossus-grpc/src/auth.rs +++ b/crates/colossus-grpc/src/auth.rs @@ -22,7 +22,8 @@ const TOKEN_PREFIX: &str = "cls_v1"; const TOKEN_SECRET_BYTES: usize = 32; const MAX_AUTHORIZATION_BYTES: usize = 768; const CREDENTIAL_DOMAIN: &[u8] = b"colossus-public-api-credential-v1\0"; -pub(crate) const MAX_CREDENTIAL_BATCH_SIZE: usize = 2; +// Primary, native approval broker, and independently authorized cloud connector. +pub(crate) const MAX_CREDENTIAL_BATCH_SIZE: usize = 3; /// Maximum authenticated protobuf decodes in progress across all applications. pub const MAX_CONCURRENT_AUTHENTICATED_DECODES: usize = 8; /// Maximum authenticated protobuf decodes in progress for one application. @@ -686,7 +687,7 @@ mod tests { let authenticator = CredentialAuthenticator::new([19_u8; 32], repository); assert!( authenticator - .issue_pending_batch(&[grant(), grant(), grant()]) + .issue_pending_batch(&[grant(), grant(), grant(), grant()]) .is_err(), "credential batches are bounded before allocation or random generation" ); @@ -721,6 +722,7 @@ mod tests { credential_ids[0].clone(), credential_ids[1].clone(), Uuid::now_v7().to_string(), + Uuid::now_v7().to_string(), ]; assert!(authenticator.activate_batch(&oversized_ids).is_err()); assert!(authenticator.revoke_batch(&oversized_ids).is_err()); diff --git a/crates/colossus-home/src/confined.rs b/crates/colossus-home/src/confined.rs index e136471f7..40aefcfa3 100644 --- a/crates/colossus-home/src/confined.rs +++ b/crates/colossus-home/src/confined.rs @@ -852,7 +852,8 @@ mod tests { #[test] fn retained_file_revalidation_rejects_missing_and_replaced_leaf_names() { let temporary = private_tempdir(); - let root = ConfinedRoot::bind(temporary.path().join("private")).unwrap(); + let root = + ConfinedRoot::bind(temporary.path().canonicalize().unwrap().join("private")).unwrap(); let retained = root.open_file(Path::new("state.redb")).unwrap(); retained.revalidate(&root).unwrap(); fs::rename(retained.path(), root.path().join("moved.redb")).unwrap(); @@ -867,7 +868,8 @@ mod tests { fn existing_read_write_open_never_creates_a_missing_leaf_or_parent() { use std::io::{Read as _, Seek as _, Write as _}; let temporary = private_tempdir(); - let root = ConfinedRoot::bind(temporary.path().join("private")).unwrap(); + let root = + ConfinedRoot::bind(temporary.path().canonicalize().unwrap().join("private")).unwrap(); assert!( root.open_existing_file_read_write(Path::new("missing.redb")) .is_err() @@ -893,7 +895,8 @@ mod tests { #[test] fn existing_read_write_open_rejects_hard_links_and_escaping_paths() { let temporary = private_tempdir(); - let root = ConfinedRoot::bind(temporary.path().join("private")).unwrap(); + let root = + ConfinedRoot::bind(temporary.path().canonicalize().unwrap().join("private")).unwrap(); drop(root.open_file(Path::new("state.redb")).unwrap()); fs::hard_link( root.path().join("state.redb"), diff --git a/crates/colossus-runtime/src/plugin_authorization_tests.rs b/crates/colossus-runtime/src/plugin_authorization_tests.rs index 59676ce4c..808503e49 100644 --- a/crates/colossus-runtime/src/plugin_authorization_tests.rs +++ b/crates/colossus-runtime/src/plugin_authorization_tests.rs @@ -2,6 +2,7 @@ use super::*; use colossus_contracts::PluginManagementRequest as Op; pub(super) fn open(root: &Path, plugins: PluginsConfig) -> Runtime { + let root = root.canonicalize().expect("canonical fixture root"); let workspace = root.join("workspace"); fs::create_dir_all(&workspace).expect("workspace"); let home = colossus_home::ColossusHome::ensure_at(root.join("home")).expect("home"); diff --git a/crates/colossus-sdk/Cargo.toml b/crates/colossus-sdk/Cargo.toml index ea901b0e8..e7679333f 100644 --- a/crates/colossus-sdk/Cargo.toml +++ b/crates/colossus-sdk/Cargo.toml @@ -37,6 +37,7 @@ sidecar = [ "dep:tempfile", ] embedded = [] +serialization = ["dep:serde"] [dependencies] async-trait.workspace = true @@ -72,6 +73,9 @@ tempfile = { workspace = true, optional = true } nix = { workspace = true, features = ["fs", "process", "signal"], optional = true } rustix = { workspace = true, optional = true } +[target.'cfg(target_os = "linux")'.dependencies] +rustix = { workspace = true, features = ["net"], optional = true } + [target.'cfg(windows)'.dependencies] colossus-windows-native = { workspace = true, optional = true } diff --git a/crates/colossus-sdk/src/backend.rs b/crates/colossus-sdk/src/backend.rs index 8a9c9b766..0bbf70251 100644 --- a/crates/colossus-sdk/src/backend.rs +++ b/crates/colossus-sdk/src/backend.rs @@ -361,6 +361,16 @@ pub trait Backend: Send + Sync { /// Caller-bound run service. fn agent_runs(&self) -> Arc; + /// Authenticated runtime instance, when this backend has a native identity. + fn instance_id(&self) -> Option { + None + } + + /// Independent cloud run client, present only after native cloud grant provisioning. + fn connector_runs(&self) -> Option> { + None + } + /// Cached authenticated server capabilities. /// /// Custom and preview-era embedded backends default to no optional behaviors. diff --git a/crates/colossus-sdk/src/client.rs b/crates/colossus-sdk/src/client.rs index b8aafe151..2d444279e 100644 --- a/crates/colossus-sdk/src/client.rs +++ b/crates/colossus-sdk/src/client.rs @@ -72,6 +72,16 @@ impl Colossus { self.backend.agent_runs() } + /// Return the independently verified runtime instance without descriptor access. + pub fn instance_id(&self) -> Option { + self.backend.instance_id() + } + + /// Return the independently granted native cloud run client, without exposing credentials. + pub fn connector_runs(&self) -> Option> { + self.backend.connector_runs() + } + /// Return authenticated optional behaviors cached during connection setup. pub fn capabilities(&self) -> ServerCapabilities { self.backend.capabilities() diff --git a/crates/colossus-sdk/src/grpc.rs b/crates/colossus-sdk/src/grpc.rs index 430dfb87c..821a01c57 100644 --- a/crates/colossus-sdk/src/grpc.rs +++ b/crates/colossus-sdk/src/grpc.rs @@ -134,6 +134,7 @@ impl fmt::Debug for GrpcConnectOptions { /// Concrete pinned-TLS, bearer-authenticated public gRPC backend. pub struct GrpcBackend { + instance_id: crate::InstanceId, kind: BackendKind, agent_runs: Arc, artifacts: Arc, @@ -195,6 +196,7 @@ impl GrpcBackend { std::sync::atomic::Ordering::Release, ); Ok(Self { + instance_id: options.expected_instance_id, kind: options.backend_kind, agent_runs, plugins: Arc::new(plugins::GrpcPluginClient { @@ -247,6 +249,10 @@ impl Backend for GrpcBackend { self.agent_runs.clone() } + fn instance_id(&self) -> Option { + Some(self.instance_id) + } + fn capabilities(&self) -> ServerCapabilities { self.capabilities.clone() } diff --git a/crates/colossus-sdk/src/lib.rs b/crates/colossus-sdk/src/lib.rs index e4ce99a6a..e6d9f2541 100644 --- a/crates/colossus-sdk/src/lib.rs +++ b/crates/colossus-sdk/src/lib.rs @@ -20,6 +20,8 @@ mod error; mod grpc; #[cfg(feature = "keyring")] mod keyring_provider; +#[cfg(all(feature = "sidecar", target_os = "linux"))] +mod linux_credential_session; #[cfg(all(feature = "sidecar", target_os = "macos"))] mod macos_code_identity; #[cfg(all(feature = "sidecar", target_os = "macos"))] diff --git a/crates/colossus-sdk/src/linux_credential_session.rs b/crates/colossus-sdk/src/linux_credential_session.rs new file mode 100644 index 000000000..c7964e52d --- /dev/null +++ b/crates/colossus-sdk/src/linux_credential_session.rs @@ -0,0 +1,177 @@ +//! Narrow native authority for the Linux Secret Service session. +//! +//! A managed child has no ambient environment. Its one session locator comes from +//! the native host and must resolve to a local Unix bus owned by the same OS user. + +use crate::{SdkError, SdkResult}; +use rustix::net::{AddressFamily, SocketAddrUnix, SocketFlags, SocketType}; +use std::{os::unix::ffi::OsStringExt as _, path::PathBuf, time::Duration}; + +const INVALID_SESSION: SdkError = SdkError::InvalidConfiguration( + "Linux Managed Local requires a same-user local Unix Secret Service session", +); + +pub(super) async fn verified_address() -> SdkResult { + let uid = rustix::process::getuid(); + let address = match std::env::var("DBUS_SESSION_BUS_ADDRESS") { + Ok(address) => address, + Err(std::env::VarError::NotPresent) => format!("unix:path=/run/user/{}/bus", uid.as_raw()), + Err(std::env::VarError::NotUnicode(_)) => return Err(INVALID_SESSION), + }; + verify(&address, uid).await?; + Ok(address) +} + +async fn verify(address: &str, expected_uid: rustix::process::Uid) -> SdkResult<()> { + let socket_address = parse(address)?; + let socket = rustix::net::socket_with( + AddressFamily::UNIX, + SocketType::STREAM, + SocketFlags::CLOEXEC | SocketFlags::NONBLOCK, + None, + ) + .map_err(|_| INVALID_SESSION)?; + match rustix::net::connect(&socket, &socket_address) { + Ok(()) | Err(rustix::io::Errno::INPROGRESS) => {} + Err(_) => return Err(INVALID_SESSION), + } + let stream = tokio::net::UnixStream::from_std(socket.into()).map_err(|_| INVALID_SESSION)?; + tokio::time::timeout(Duration::from_secs(2), stream.writable()) + .await + .map_err(|_| INVALID_SESSION)? + .map_err(|_| INVALID_SESSION)?; + if stream.take_error().map_err(|_| INVALID_SESSION)?.is_some() { + return Err(INVALID_SESSION); + } + let peer = rustix::net::sockopt::socket_peercred(&stream).map_err(|_| INVALID_SESSION)?; + if peer.uid != expected_uid { + return Err(INVALID_SESSION); + } + Ok(()) +} + +fn parse(address: &str) -> SdkResult { + // Multiple transports, TCP, autolaunch and unfamiliar parameters cannot turn + // this non-secret native locator into network or process-launch authority. + if address.len() > 1024 || !address.is_ascii() || address.contains(';') { + return Err(INVALID_SESSION); + } + let parameters = address.strip_prefix("unix:").ok_or(INVALID_SESSION)?; + let mut endpoint = None; + let mut guid_seen = false; + for parameter in parameters.split(',') { + let (key, value) = parameter.split_once('=').ok_or(INVALID_SESSION)?; + match key { + "path" | "abstract" if endpoint.is_none() => { + endpoint = Some((key, decode(value)?)); + } + "guid" if !guid_seen => { + if value.len() != 32 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(INVALID_SESSION); + } + guid_seen = true; + } + _ => return Err(INVALID_SESSION), + } + } + let (kind, name) = endpoint.ok_or(INVALID_SESSION)?; + if kind == "abstract" { + return SocketAddrUnix::new_abstract_name(&name).map_err(|_| INVALID_SESSION); + } + let path = PathBuf::from(std::ffi::OsString::from_vec(name)); + if !path.is_absolute() { + return Err(INVALID_SESSION); + } + // Do not let a filesystem alias select a different native credential session. + let canonical = std::fs::canonicalize(&path).map_err(|_| INVALID_SESSION)?; + if canonical != path { + return Err(INVALID_SESSION); + } + SocketAddrUnix::new(&path).map_err(|_| INVALID_SESSION) +} + +fn decode(value: &str) -> SdkResult> { + let mut decoded = Vec::with_capacity(value.len()); + let mut bytes = value.bytes(); + while let Some(byte) = bytes.next() { + let byte = if byte == b'%' { + let high = bytes.next().and_then(hex_digit).ok_or(INVALID_SESSION)?; + let low = bytes.next().and_then(hex_digit).ok_or(INVALID_SESSION)?; + high * 16 + low + } else if byte.is_ascii_alphanumeric() || b"_-/.\\".contains(&byte) { + byte + } else { + return Err(INVALID_SESSION); + }; + if byte == 0 { + return Err(INVALID_SESSION); + } + decoded.push(byte); + } + if decoded.is_empty() { + return Err(INVALID_SESSION); + } + Ok(decoded) +} + +fn hex_digit(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::net::UnixListener; + + #[test] + fn rejects_remote_ambiguous_or_malformed_session_authority() { + for address in [ + "tcp:host=example.test,port=1234", + "autolaunch:", + "unix:abstract=test;tcp:host=example.test", + "unix:abstract=test,abstract=other", + "unix:abstract=test,path=/tmp/bus", + "unix:abstract=test,guid=bad", + "unix:abstract=test,guid=00000000000000000000000000000000,guid=00000000000000000000000000000000", + "unix:abstract=test,unknown=value", + "unix:abstract=", + "unix:abstract=%00name", + "unix:abstract=bad%", + "unix:abstract=bad%gg", + "unix:path=relative", + ] { + assert!(parse(address).is_err(), "accepted {address}"); + } + } + + #[tokio::test] + async fn local_path_requires_exact_peer_uid_and_rejects_aliases() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("bus"); + let _listener = UnixListener::bind(&path).unwrap(); + let address = format!("unix:path={}", path.display()); + let uid = rustix::process::getuid(); + verify(&address, uid).await.unwrap(); + let other_uid = rustix::process::Uid::from_raw(uid.as_raw().wrapping_add(1)); + assert!(verify(&address, other_uid).await.is_err()); + let alias = directory.path().join("alias"); + std::os::unix::fs::symlink(&path, &alias).unwrap(); + assert!(parse(&format!("unix:path={}", alias.display())).is_err()); + } + + #[tokio::test] + async fn local_abstract_bus_preserves_percent_encoded_names() { + use std::os::linux::net::SocketAddrExt as _; + let name = format!("colossus-session-{}+", uuid::Uuid::now_v7()); + let socket = std::os::unix::net::SocketAddr::from_abstract_name(name.as_bytes()).unwrap(); + let _listener = UnixListener::bind_addr(&socket).unwrap(); + let encoded = name.replace('+', "%2b"); + let address = format!("unix:abstract={encoded},guid=00000000000000000000000000000000"); + verify(&address, rustix::process::getuid()).await.unwrap(); + } +} diff --git a/crates/colossus-sdk/src/native_sidecar.rs b/crates/colossus-sdk/src/native_sidecar.rs index 47506b4ff..e3906c3f7 100644 --- a/crates/colossus-sdk/src/native_sidecar.rs +++ b/crates/colossus-sdk/src/native_sidecar.rs @@ -276,10 +276,17 @@ impl SidecarLifecycle for NativeSidecarLifecycle { let agent_runs = Arc::new(SwitchingAgentRunClient::new( running.transports().agent_runs(), )); + let connector_runs = running.transports().connector.as_ref().map(|transport| { + Arc::new(SwitchingAgentRunClient::new(AgentRunTransports { + primary: transport.agent_runs(), + approval_broker: None, + })) + }); let state = Arc::new(ManagedSidecarState { options: options.clone(), bootstrap: Arc::clone(&self.bootstrap), agent_runs: Arc::clone(&agent_runs), + connector_runs, artifacts: artifacts.clone(), process: tokio::sync::Mutex::new(Some(running)), close_guard: tokio::sync::Mutex::new(()), @@ -882,6 +889,7 @@ fn validate_discovery_leaf_absent(directory: &File, name: &str) -> SdkResult<()> struct ConnectedTransports { primary: Arc, approval_broker: Option>, + connector: Option>, } impl ConnectedTransports { @@ -897,6 +905,9 @@ impl ConnectedTransports { async fn close(&self) { let _ = self.primary.close().await; + if let Some(connector) = &self.connector { + let _ = connector.close().await; + } if let Some(approval_broker) = &self.approval_broker { let _ = approval_broker.close().await; } @@ -973,6 +984,7 @@ struct ManagedSidecarState { options: SidecarOptions, bootstrap: Arc, agent_runs: Arc, + connector_runs: Option>, artifacts: Option>, process: tokio::sync::Mutex>, close_guard: tokio::sync::Mutex<()>, @@ -1007,6 +1019,17 @@ impl Backend for ManagedSidecarBackend { self.agent_runs.clone() } + fn instance_id(&self) -> Option { + Some(self.state.options.instance_id()) + } + + fn connector_runs(&self) -> Option> { + self.state + .connector_runs + .as_ref() + .map(|client| client.clone() as Arc) + } + fn capabilities(&self) -> ServerCapabilities { self.capabilities.clone() } @@ -1030,6 +1053,10 @@ impl Backend for ManagedSidecarBackend { // managed tree even while the aborted supervisor is still being joined. let running = self.state.process.lock().await.take(); self.state.closing.store(true, Ordering::Release); + if let Some(client) = &self.state.connector_runs { + client.mark_closed(); + client.release().await; + } self.agent_runs.mark_closed(); self.agent_runs.release().await; if let Some(artifacts) = &self.artifacts { @@ -1406,6 +1433,16 @@ async fn supervise(state: Arc) { state.status.send_replace(NativeSidecarStatus::Stopping); return; } + if let (Some(client), Some(transport)) = + (&state.connector_runs, &restarted.transports().connector) + { + client + .replace(AgentRunTransports { + primary: transport.agent_runs(), + approval_broker: None, + }) + .await; + } state .agent_runs .replace(restarted.transports().agent_runs()) @@ -1513,6 +1550,16 @@ async fn launch_child( }; validate_ready(options, &ready)?; provisional.bind_discovery()?; + if ready.connector_bearer.is_some() != bootstrap.has_connector_grant() { + return Err(SdkError::IdentityMismatch); + } + let connector_credential: Option> = + ready.connector_bearer.as_ref().map(|bearer| { + Arc::new(MemoryCredentialProvider { + bearer: Zeroizing::new(bearer.expose().as_bytes().to_vec()), + }) as Arc + }); + let connector_credential_id = ready.connector_credential_id.clone(); let credential_id = ready.credential_id.clone(); let approval_broker_credential_id = ready.approval_broker_credential_id.clone(); let exchange_id = ready.exchange_id.clone(); @@ -1551,6 +1598,7 @@ async fn launch_child( exchange_id: exchange_id.clone(), credential_id: credential_id.clone(), approval_broker_credential_id: approval_broker_credential_id.clone(), + connector_credential_id: connector_credential_id.clone(), }), ) .await?; @@ -1559,6 +1607,9 @@ async fn launch_child( ChildFrame::Failed(failure) => return Err(map_child_failure(failure.code)), ChildFrame::Ready(_) => return Err(SdkError::IdentityMismatch), }; + if activated.connector_credential_id != connector_credential_id { + return Err(SdkError::IdentityMismatch); + } validate_activated( &activated, &exchange_id, @@ -1596,7 +1647,22 @@ async fn launch_child( } else { None }; + let connector = match connector_credential { + Some(credential) => Some( + connect_sidecar_transport( + options, + &endpoint, + fingerprint, + &certificate_pem, + credential, + deadline, + ) + .await?, + ), + None => None, + }; Ok(ConnectedTransports { + connector, primary, approval_broker, }) @@ -1679,6 +1745,7 @@ async fn spawn_verified_sidecar( )> { use nix::fcntl::{FcntlArg, FdFlag, fcntl}; + let credential_session = crate::linux_credential_session::verified_address().await?; // The manifest-matching bytes live in a sealed anonymous file. Clearing only // FD_CLOEXEC lets the child resolve this exact kernel object through procfs; // replacement of the bundle path is therefore irrelevant to execution. @@ -1689,6 +1756,7 @@ async fn spawn_verified_sidecar( command .arg("__managed-sidecar-v1") .env_clear() + .env("DBUS_SESSION_BUS_ADDRESS", credential_session) .current_dir(canonical_instance) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -2905,6 +2973,7 @@ mod tests { exchange_id: exchange.clone(), credential_id: primary.clone(), approval_broker_credential_id: Some(broker.clone()), + connector_credential_id: None, }; validate_activated(&activated, &exchange, &primary, Some(&broker)) .expect("exact activation"); diff --git a/crates/colossus-sdk/src/native_sidecar_windows.rs b/crates/colossus-sdk/src/native_sidecar_windows.rs index 954b1d1e3..b00e0a7b4 100644 --- a/crates/colossus-sdk/src/native_sidecar_windows.rs +++ b/crates/colossus-sdk/src/native_sidecar_windows.rs @@ -360,6 +360,15 @@ async fn launch( let fingerprint = TlsFingerprint::from_hex(&ready.certificate_sha256) .map_err(|_| SdkError::IdentityMismatch)?; let certificate_pem = ready.certificate_pem.as_bytes().to_vec(); + if ready.connector_bearer.is_some() != bootstrap.has_connector_grant() { + return Err(SdkError::IdentityMismatch); + } + let connector_credential: Option> = + ready.connector_bearer.as_ref().map(|bearer| { + Arc::new(MemoryCredentialProvider { + bearer: Zeroizing::new(bearer.expose().as_bytes().to_vec()), + }) as Arc + }); let primary_credential: Arc = Arc::new(MemoryCredentialProvider { bearer: Zeroizing::new(ready.bearer.expose().as_bytes().to_vec()), }); @@ -382,6 +391,7 @@ async fn launch( exchange_id: ready.exchange_id.clone(), credential_id: ready.credential_id.clone(), approval_broker_credential_id: ready.approval_broker_credential_id.clone(), + connector_credential_id: ready.connector_credential_id.clone(), }), ) .await?; @@ -390,6 +400,9 @@ async fn launch( ChildFrame::Failed(failure) => return Err(map_child_failure(failure.code)), ChildFrame::Ready(_) => return Err(SdkError::IdentityMismatch), }; + if activated.connector_credential_id != ready.connector_credential_id { + return Err(SdkError::IdentityMismatch); + } validate_activated( &activated, &ready.exchange_id, @@ -422,7 +435,30 @@ async fn launch( } else { None }; + let connector = match connector_credential { + Some(credential) => Some( + connect_sidecar( + options, + &endpoint, + fingerprint, + &certificate_pem, + credential, + deadline, + ) + .await?, + ), + None => None, + }; let (agent_runs_closed, _) = watch::channel(false); + let connector_runs = connector.as_ref().map(|transport| { + Arc::new(WindowsAgentRuns { + transports: AgentRunTransports { + primary: transport.agent_runs(), + approval_broker: None, + }, + closed: agent_runs_closed.clone(), + }) + }); let agent_runs = Arc::new(WindowsAgentRuns { transports: AgentRunTransports { primary: primary.agent_runs(), @@ -431,6 +467,8 @@ async fn launch( closed: agent_runs_closed, }); Ok(WindowsSidecarBackend { + connector, + connector_runs, primary, approval, agent_runs, @@ -587,6 +625,8 @@ impl AgentRunClient for WindowsAgentRuns { } struct WindowsSidecarBackend { + connector: Option>, + connector_runs: Option>, primary: Arc, approval: Option>, agent_runs: Arc, @@ -617,6 +657,15 @@ impl Backend for WindowsSidecarBackend { self.agent_runs.clone() } + fn instance_id(&self) -> Option { + self.primary.instance_id() + } + + fn connector_runs(&self) -> Option> { + self.connector_runs + .as_ref() + .map(|client| client.clone() as Arc) + } fn capabilities(&self) -> ServerCapabilities { self.primary.capabilities() } @@ -632,6 +681,9 @@ impl Backend for WindowsSidecarBackend { self.status.send_replace(NativeSidecarStatus::Stopping); self.agent_runs.closed.send_replace(true); let _ = self.primary.close().await; + if let Some(connector) = &self.connector { + let _ = connector.close().await; + } if let Some(approval) = &self.approval { let _ = approval.close().await; } diff --git a/crates/colossus-sdk/src/sidecar.rs b/crates/colossus-sdk/src/sidecar.rs index eaca071e3..335b3fb81 100644 --- a/crates/colossus-sdk/src/sidecar.rs +++ b/crates/colossus-sdk/src/sidecar.rs @@ -247,6 +247,7 @@ pub struct SidecarBootstrapConfig { client_key_pem: Option, codex_auth_path: Option, approval_broker_grant: Option, + connector_grant: Option, host_credentials: Vec, worker_ipc_authentication: Option, } @@ -278,6 +279,7 @@ impl SidecarBootstrapConfig { client_key_pem: None, codex_auth_path: None, approval_broker_grant: None, + connector_grant: None, host_credentials: Vec::new(), worker_ipc_authentication: None, }) @@ -419,6 +421,35 @@ impl SidecarBootstrapConfig { Ok(self) } + pub(crate) fn has_connector_grant(&self) -> bool { + self.connector_grant.is_some() + } + + /// Provision an independent cloud application beneath the local grant's ceilings. + /// Native hosts opt in before launch; enrollment is a separate explicit action. + pub fn with_connector_grant(mut self, grant: SidecarApplicationGrant) -> SdkResult { + if grant.application_id == self.grant.application_id + || grant + .allowed_roles + .iter() + .any(|role| !self.grant.allowed_roles.contains(role)) + || grant + .allowed_tools + .iter() + .any(|tool| !self.grant.allowed_tools.contains(tool)) + || grant.scopes.iter().any(|scope| { + !(self.grant.scopes.contains(scope) + || scope == "approvals:respond" && self.approval_broker_grant.is_some()) + }) + { + return Err(SdkError::InvalidConfiguration( + "connector grant exceeds local authority", + )); + } + self.connector_grant = Some(grant); + Ok(self) + } + /// Attach a bounded exact map of host-resolved provider credentials. pub fn with_host_credentials( mut self, @@ -541,6 +572,10 @@ impl SidecarBootstrapConfig { .approval_broker_grant .as_ref() .map(SidecarApprovalBrokerGrant::wire), + connector_grant: self + .connector_grant + .as_ref() + .map(SidecarApplicationGrant::wire), host_credentials: self .host_credentials .iter() diff --git a/crates/colossus-sdk/src/types.rs b/crates/colossus-sdk/src/types.rs index ffd083535..60178de1a 100644 --- a/crates/colossus-sdk/src/types.rs +++ b/crates/colossus-sdk/src/types.rs @@ -9,6 +9,11 @@ use std::collections::{BTreeMap, BTreeSet}; /// Capability names are credential-free stable identifiers. Absence always means the /// client must keep the corresponding operation unavailable. #[derive(Clone, Debug, Default, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct ServerCapabilities { enabled: BTreeSet, } @@ -33,6 +38,14 @@ impl ServerCapabilities { /// Requested public execution mode. #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum RunMode { /// Permit policy-authorized effects. Execute, @@ -44,6 +57,14 @@ pub enum RunMode { /// Requested breadth for a durable Research run. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum ResearchDepth { /// Fast, narrow evidence pass. Quick, @@ -56,6 +77,14 @@ pub enum ResearchDepth { /// Explicit evidence lane enabled for Research. #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum ResearchSourceKind { /// Selected Workspace repository evidence. Repo, @@ -67,6 +96,14 @@ pub enum ResearchSourceKind { /// Released canonical Plan lifecycle. #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum PlanStatus { /// The exact revision remains editable. Draft, @@ -80,6 +117,14 @@ pub enum PlanStatus { /// Requested handoff for one exact Plan revision. #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum PlanExecutionStrategy { /// Consume the Plan in one normal run. Direct, @@ -92,6 +137,14 @@ pub enum PlanExecutionStrategy { /// Typed continuation of a Plan released by a caller-owned source run. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum PlanRunAction { /// Refine one exact draft revision in Plan Mode. Revise { @@ -113,6 +166,14 @@ pub enum PlanRunAction { /// Durable public run lifecycle. #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum RunStatus { /// Durable but not yet executing. Queued, @@ -136,6 +197,14 @@ pub enum RunStatus { /// Whether an effectful outcome is known. #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum OutcomeCertainty { /// Durable evidence establishes the outcome. Known, @@ -145,6 +214,14 @@ pub enum OutcomeCertainty { /// One v1alpha1 run input part. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum InputContentPart { /// Visible user text. Text(String), @@ -154,6 +231,14 @@ pub enum InputContentPart { /// Projection used when a canonical session prefix starts a child conversation. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum RunBranchContextMode { /// Preserve the exact provider transcript, including tool-call correlation. #[default] @@ -167,6 +252,11 @@ pub enum RunBranchContextMode { /// Canonical session prefix used to start a separate child conversation. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct RunBranch { /// Caller-owned run whose session supplies the canonical context. pub source_run_id: String, @@ -179,6 +269,11 @@ pub struct RunBranch { /// Request to create one durable run. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct CreateRunRequest { /// Qualified Agent Plugin skills selected for this message; never a tool grant. pub plugin_skill_ids: Vec, @@ -210,6 +305,11 @@ pub struct CreateRunRequest { /// Bounded released terminal output. #[derive(Clone, Debug, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct RunResult { /// Complete visible assistant output. pub output: String, @@ -235,6 +335,11 @@ pub struct RunResult { /// Bounded user-safe terminal failure. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct RunFailure { /// Stable machine-readable reason. pub reason: String, @@ -252,6 +357,11 @@ pub struct RunFailure { /// Durable cancellation evidence. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct RunCancellation { /// Turn at which cancellation became terminal; zero means before turn one. pub turn: u32, @@ -269,6 +379,14 @@ pub struct RunCancellation { /// Exactly one terminal run payload. #[derive(Clone, Debug, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum RunTerminal { /// Successful released result. Result(RunResult), @@ -280,6 +398,11 @@ pub enum RunTerminal { /// Durable bounded run summary. #[derive(Clone, Debug, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct Run { /// Qualified Agent Plugin skills requested for this run. pub plugin_skill_ids: Vec, @@ -317,6 +440,11 @@ pub struct Run { /// Response from durable run creation. #[derive(Clone, Debug, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct CreateRunResponse { /// Allocated durable run. pub run: Run, @@ -324,6 +452,11 @@ pub struct CreateRunResponse { /// Request for one run. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct GetRunRequest { /// Exact stable run identifier. pub run_id: String, @@ -331,6 +464,11 @@ pub struct GetRunRequest { /// Response containing one run and its currently pending interactions. #[derive(Clone, Debug, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct GetRunResponse { /// Current bounded run summary. pub run: Run, @@ -340,6 +478,11 @@ pub struct GetRunResponse { /// Bounded stable page request. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct PageRequest { /// Requested page size; zero selects the server default. pub page_size: u32, @@ -349,6 +492,11 @@ pub struct PageRequest { /// Stable page continuation. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct PageResponse { /// Opaque next-page token, empty when the page is terminal. pub next_page_token: String, @@ -356,6 +504,11 @@ pub struct PageResponse { /// Stable run listing request. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct ListRunsRequest { /// Optional exact session filter. pub session_id: Option, @@ -369,6 +522,11 @@ pub struct ListRunsRequest { /// Stable page of run summaries. #[derive(Clone, Debug, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct ListRunsResponse { /// Deterministically ordered run summaries. pub runs: Vec, @@ -378,6 +536,14 @@ pub struct ListRunsResponse { /// Timeline lane used by a curated session activity. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum SessionActivityLane { /// User, assistant, and model-turn activity. Agent, @@ -389,6 +555,14 @@ pub enum SessionActivityLane { /// Human-readable activity kind used by the event table. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum SessionActivityKind { /// Released human or application input. User, @@ -402,6 +576,14 @@ pub enum SessionActivityKind { /// Released lifecycle state for a curated activity. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum SessionActivityStatus { /// Accepted but not started. Requested, @@ -421,6 +603,11 @@ pub enum SessionActivityStatus { /// One bounded released inspector value. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct SessionActivityContent { /// Rendering hint: `text` or `json`. pub format: String, @@ -430,6 +617,11 @@ pub struct SessionActivityContent { /// One curated logical activity for a caller-owned session. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct SessionActivity { /// Stable logical identifier used for live merging. pub activity_id: String, @@ -471,6 +663,11 @@ pub struct SessionActivity { /// Caller-scoped session activity query addressed through an owned run. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct ListSessionActivityRequest { /// Any caller-owned run in the requested session. pub source_run_id: String, @@ -488,6 +685,11 @@ pub struct ListSessionActivityRequest { /// One eventually consistent newest-first activity page. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct ListSessionActivityResponse { /// Curated activities matching the request. pub activities: Vec, @@ -503,6 +705,11 @@ pub struct ListSessionActivityResponse { /// Replay-and-tail request. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct WatchRunRequest { /// Exact run identifier. pub run_id: String, @@ -512,6 +719,11 @@ pub struct WatchRunRequest { /// Idempotent cooperative cancellation request. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct CancelRunRequest { /// Exact run identifier. pub run_id: String, @@ -521,6 +733,11 @@ pub struct CancelRunRequest { /// Cancellation response. #[derive(Clone, Debug, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct CancelRunResponse { /// Current durable run summary. pub run: Run, @@ -528,6 +745,11 @@ pub struct CancelRunResponse { /// Idempotent request to archive one terminal thread. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct ArchiveThreadRequest { /// Any exact run identifier belonging to the thread. pub run_id: String, @@ -537,6 +759,11 @@ pub struct ArchiveThreadRequest { /// Idempotent request to restore one archived thread. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct RestoreThreadRequest { /// Any exact run identifier belonging to the thread. pub run_id: String, @@ -546,6 +773,11 @@ pub struct RestoreThreadRequest { /// Current archive lifecycle for one durable thread. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct ThreadLifecycle { /// Durable session identity shared by the thread's runs. pub session_id: String, @@ -555,6 +787,14 @@ pub struct ThreadLifecycle { /// Caller-visible interaction class. #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum InteractionKind { /// Ordinary user input. UserPrompt, @@ -564,6 +804,14 @@ pub enum InteractionKind { /// Durable interaction lifecycle. #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum InteractionStatus { /// Awaiting one response. Pending, @@ -577,6 +825,11 @@ pub enum InteractionStatus { /// Exact suggested prompt choice. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct PromptChoice { /// Opaque server-issued identifier that must be echoed unchanged. pub choice_id: String, @@ -586,6 +839,11 @@ pub struct PromptChoice { /// Released ordinary prompt content. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct UserPromptInteraction { /// User-visible question. pub question: String, @@ -597,6 +855,14 @@ pub struct UserPromptInteraction { /// Non-authoritative approval risk metadata. #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum ApprovalRisk { /// Bounded reversible impact. Low, @@ -611,6 +877,11 @@ pub use colossus_api::CommandApprovalContext; /// Released approval content. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct ApprovalInteraction { /// Prepared command display, absent on historical and non-command interactions. pub command_context: Option, @@ -629,6 +900,14 @@ pub struct ApprovalInteraction { /// Exactly one released interaction representation. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum InteractionContent { /// Ordinary user prompt. UserPrompt(UserPromptInteraction), @@ -638,6 +917,11 @@ pub enum InteractionContent { /// Durable caller-bound prompt or approval. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct Interaction { /// Opaque one-use identifier. pub interaction_id: String, @@ -661,6 +945,14 @@ pub struct Interaction { /// Prompt answer that preserves opaque choice integrity. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum PromptAnswer { /// Select and echo one exact displayed choice. Choice(PromptChoice), @@ -670,6 +962,14 @@ pub enum PromptAnswer { /// One-use interaction answer. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum InteractionAnswer { /// Ordinary prompt answer. Prompt(PromptAnswer), @@ -684,6 +984,11 @@ pub enum InteractionAnswer { /// Request that consumes one interaction exactly once. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct RespondInteractionRequest { /// Owning run. pub run_id: String, @@ -699,6 +1004,11 @@ pub struct RespondInteractionRequest { /// Response after consuming one interaction. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct RespondInteractionResponse { /// Terminal interaction with an empty response etag. pub interaction: Interaction, @@ -706,6 +1016,14 @@ pub struct RespondInteractionResponse { /// Released tool activity state. #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum ToolActivityState { /// Strict call validated. Requested, @@ -725,6 +1043,11 @@ pub enum ToolActivityState { /// Bounded tool lifecycle metadata. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct ToolActivity { /// Provider call identifier. pub call_id: String, @@ -742,6 +1065,11 @@ pub struct ToolActivity { /// Normalized provider accounting. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct TokenUsage { /// Input token count. pub input_tokens: u64, @@ -757,6 +1085,14 @@ pub struct TokenUsage { /// Released message role. #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum MessageRole { /// Human or application input. User, @@ -770,6 +1106,14 @@ pub enum MessageRole { /// Public artifact purpose. #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum ArtifactPurpose { /// Agent run input. RunInput, @@ -785,6 +1129,14 @@ pub enum ArtifactPurpose { /// Public artifact state. #[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum ArtifactState { /// Staged upload incomplete. Uploading, @@ -800,6 +1152,11 @@ pub enum ArtifactState { /// Safe metadata for an opaque artifact. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct ArtifactReference { /// Stable opaque identifier. pub artifact_id: String, @@ -821,6 +1178,11 @@ pub struct ArtifactReference { /// Complete bounded artifact upload supplied by an SDK caller. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct UploadArtifactRequest { /// Display name only; never interpreted as a server path. pub file_name: String, @@ -836,6 +1198,11 @@ pub struct UploadArtifactRequest { /// Downloaded released artifact. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct DownloadedArtifact { /// Verified public metadata. pub artifact: ArtifactReference, @@ -845,6 +1212,14 @@ pub struct DownloadedArtifact { /// Released message content part. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum MessageContentPart { /// Visible text. Text(String), @@ -854,6 +1229,11 @@ pub enum MessageContentPart { /// Newly durable released session message. #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct SessionMessage { /// Owning session. pub session_id: String, @@ -871,6 +1251,11 @@ pub struct SessionMessage { /// One durable replayable run update. #[derive(Clone, Debug, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr(feature = "serialization", serde(deny_unknown_fields))] pub struct RunUpdate { /// Owning run identifier. pub run_id: String, @@ -884,6 +1269,14 @@ pub struct RunUpdate { /// Released public run-feed update. #[derive(Clone, Debug, PartialEq)] +#[cfg_attr( + feature = "serialization", + derive(serde::Serialize, serde::Deserialize) +)] +#[cfg_attr( + feature = "serialization", + serde(rename_all = "snake_case", deny_unknown_fields) +)] pub enum RunUpdateKind { /// Safe provider recovery progress. ProviderRetry(ProviderRetry), diff --git a/crates/colossus-sidecar-protocol/src/protocol.rs b/crates/colossus-sidecar-protocol/src/protocol.rs index 00d1f6432..c4f6c0dcb 100644 --- a/crates/colossus-sidecar-protocol/src/protocol.rs +++ b/crates/colossus-sidecar-protocol/src/protocol.rs @@ -21,7 +21,7 @@ use uuid::Uuid; use zeroize::Zeroizing; /// Exact bootstrap protocol version. -pub const PROTOCOL_VERSION: u16 = 11; +pub const PROTOCOL_VERSION: u16 = 12; /// Exact desktop-to-TUI inherited-channel protocol version. pub const DESKTOP_TUI_PROTOCOL_VERSION: u16 = 3; /// Fixed child descriptor from which the bundled TUI reads native authentication. @@ -1459,6 +1459,8 @@ pub struct BootstrapRequest { pub grant: BootstrapGrant, /// Optional native-only credential allowed to answer effect approvals and nothing else. pub approval_broker_grant: Option, + /// Optional independent application grant for an explicitly enrolled cloud connector. + pub connector_grant: Option, /// Provider credentials referenced by opaque `host:` identifiers. pub host_credentials: Vec, /// Optional worker IPC key supplied by a native host for its bundled TUI. @@ -1498,6 +1500,26 @@ impl BootstrapRequest { return Err(ProtocolError::InvalidFrame); } self.grant.validate()?; + if let Some(connector) = &self.connector_grant { + connector.validate()?; + if connector.application_id == self.grant.application_id + || connector + .allowed_roles + .iter() + .any(|role| !self.grant.allowed_roles.contains(role)) + || connector + .allowed_tools + .iter() + .any(|tool| !self.grant.allowed_tools.contains(tool)) + || connector.scopes.iter().any(|scope| { + !(self.grant.scopes.contains(scope) + || scope == "approvals:respond" && self.approval_broker_grant.is_some()) + }) + { + return Err(ProtocolError::InvalidFrame); + } + } + self.workspace_identity.validate()?; if let Some(grant) = &self.approval_broker_grant { grant.validate_approval_broker(&self.grant)?; @@ -1756,6 +1778,8 @@ pub struct AckRequest { pub credential_id: String, /// Pending approval-broker credential identifier, when one was requested. pub approval_broker_credential_id: Option, + /// Independent pending cloud connector credential identity. + pub connector_credential_id: Option, } /// Child frames sent to the native host. @@ -1796,8 +1820,12 @@ pub struct ReadyResponse { pub bearer: SecretString, /// Non-secret pending approval-broker credential identifier. pub approval_broker_credential_id: Option, + /// Independent pending cloud connector credential identity. + pub connector_credential_id: Option, /// Approval-broker bearer delivered once through this inherited channel. pub approval_broker_bearer: Option, + /// Independent connector bearer delivered only through native bootstrap IPC. + pub connector_bearer: Option, } impl ReadyResponse { @@ -1819,6 +1847,10 @@ impl ReadyResponse { &self.approval_broker_bearer, ) || self.approval_broker_credential_id.as_deref() == Some(self.credential_id.as_str()) + || !matching_optional_credential(&self.connector_credential_id, &self.connector_bearer) + || self.connector_credential_id.as_deref() == Some(self.credential_id.as_str()) + || (self.connector_credential_id.is_some() + && self.connector_credential_id == self.approval_broker_credential_id) { return Err(ProtocolError::InvalidFrame); } @@ -1861,6 +1893,8 @@ pub struct ActivatedResponse { pub credential_id: String, /// Activated approval-broker credential identifier, when one was requested. pub approval_broker_credential_id: Option, + /// Independent pending cloud connector credential identity. + pub connector_credential_id: Option, } /// Sanitized startup failure codes suitable for a native status surface. @@ -2078,6 +2112,7 @@ mod tests { allowed_roles: vec!["primary".into()], allowed_tools: vec!["session.list".into()], }, + connector_grant: None, approval_broker_grant: Some(BootstrapGrant { application_id: "app:desktop".into(), scopes: vec![APPROVALS_RESPOND_SCOPE.into()], @@ -2862,7 +2897,9 @@ mod tests { credential_id: primary_id.clone(), bearer: SecretString::new("primary-secret").expect("secret"), approval_broker_credential_id: Some(broker_id), + connector_credential_id: None, approval_broker_bearer: Some(SecretString::new("approval-secret").expect("secret")), + connector_bearer: None, }; ready.validate().expect("paired delivery"); let debug = format!("{ready:?}"); @@ -2871,6 +2908,7 @@ mod tests { let duplicated = ReadyResponse { approval_broker_credential_id: Some(primary_id), + connector_credential_id: None, ..ready }; assert_eq!(duplicated.validate(), Err(ProtocolError::InvalidFrame)); diff --git a/crates/colossus-sidecar/src/managed.rs b/crates/colossus-sidecar/src/managed.rs index 31834b942..02d9f4741 100644 --- a/crates/colossus-sidecar/src/managed.rs +++ b/crates/colossus-sidecar/src/managed.rs @@ -340,6 +340,17 @@ async fn run(request: BootstrapRequest, input: &mut std::io::Stdin) -> Result<() .clone() .map(|grant| approval_broker_grant(&request.grant, grant)) .transpose()?; + let connector_grant = request + .connector_grant + .clone() + .map(|grant| { + connector_application_grant( + &request.grant, + request.approval_broker_grant.is_some(), + grant, + ) + }) + .transpose()?; let public_directory = prepare_public_directory(&instance_dir)?; let tls = TlsIdentity::from_seed(TlsKeySeed::random().map_err(|_| FailureCode::PublicApiSetup)?) @@ -365,19 +376,34 @@ async fn run(request: BootstrapRequest, input: &mut std::io::Stdin) -> Result<() .map_err(|_| FailureCode::PublicApiSetup)? .to_owned(); let grants = std::iter::once(primary_grant) - .chain(approval_broker_grant) + .chain(approval_broker_grant.clone()) + .chain(connector_grant) .collect::>(); let mut issued = credentials .issue_pending_batch(&grants) .map_err(|_| FailureCode::PublicApiSetup)?; let primary_issued = issued.remove(0); - let approval_broker_issued = issued.pop(); + let approval_broker_issued = if approval_broker_grant.is_some() { + Some(issued.remove(0)) + } else { + None + }; + let connector_issued = issued.pop(); + let connector_credential_id = connector_issued + .as_ref() + .map(|credential| credential.credential_id().to_owned()); + let connector_bearer = connector_issued + .as_ref() + .map(|credential| SecretString::new(credential.expose_token().to_owned())) + .transpose() + .map_err(|_| FailureCode::PublicApiSetup)?; let credential_id = primary_issued.credential_id().to_owned(); let approval_broker_credential_id = approval_broker_issued .as_ref() .map(|credential| credential.credential_id().to_owned()); let credential_ids = std::iter::once(credential_id.clone()) .chain(approval_broker_credential_id.clone()) + .chain(connector_credential_id.clone()) .collect::>(); let bearer = match SecretString::new(primary_issued.expose_token().to_owned()) { Ok(bearer) => bearer, @@ -409,7 +435,9 @@ async fn run(request: BootstrapRequest, input: &mut std::io::Stdin) -> Result<() credential_id: credential_id.clone(), bearer, approval_broker_credential_id: approval_broker_credential_id.clone(), + connector_credential_id: connector_credential_id.clone(), approval_broker_bearer, + connector_bearer, }; if ready.validate().is_err() { let _ = credentials.revoke_batch(&credential_ids); @@ -425,6 +453,7 @@ async fn run(request: BootstrapRequest, input: &mut std::io::Stdin) -> Result<() } drop(primary_issued); drop(approval_broker_issued); + drop(connector_issued); let ack = match read_frame::<_, ParentFrame>(input) { Ok(ParentFrame::Ack(ack)) => ack, @@ -433,6 +462,10 @@ async fn run(request: BootstrapRequest, input: &mut std::io::Stdin) -> Result<() return Err(FailureCode::CredentialActivation); } }; + if ack.connector_credential_id != connector_credential_id { + let _ = credentials.revoke_batch(&credential_ids); + return Err(FailureCode::CredentialActivation); + } if let Err(error) = validate_ack( &ack, &request.exchange_id, @@ -482,6 +515,7 @@ async fn run(request: BootstrapRequest, input: &mut std::io::Stdin) -> Result<() exchange_id: request.exchange_id, credential_id: credential_id.clone(), approval_broker_credential_id: approval_broker_credential_id.clone(), + connector_credential_id: connector_credential_id.clone(), }), ) }; @@ -1202,6 +1236,30 @@ fn approval_broker_grant( application_grant(broker) } +fn connector_application_grant( + primary: &BootstrapGrant, + has_broker: bool, + connector: BootstrapGrant, +) -> Result { + if connector.application_id == primary.application_id + || connector + .allowed_roles + .iter() + .any(|role| !primary.allowed_roles.contains(role)) + || connector + .allowed_tools + .iter() + .any(|tool| !primary.allowed_tools.contains(tool)) + || connector.scopes.iter().any(|scope| { + !(primary.scopes.contains(scope) + || scope == colossus_api::scopes::APPROVALS_RESPOND && has_broker) + }) + { + return Err(FailureCode::InvalidBootstrap); + } + application_grant(connector) +} + fn validate_ack( ack: &AckRequest, exchange_id: &str, @@ -2203,6 +2261,7 @@ mod tests { exchange_id: exchange.clone(), credential_id: credential.clone(), approval_broker_credential_id: Some(broker.clone()), + connector_credential_id: None, }; assert_eq!( validate_ack(&ack, &exchange, &credential, Some(&broker)), diff --git a/crates/colossus-sidecar/tests/native_lifecycle.rs b/crates/colossus-sidecar/tests/native_lifecycle.rs index f9fef8166..1dfe64bcf 100644 --- a/crates/colossus-sidecar/tests/native_lifecycle.rs +++ b/crates/colossus-sidecar/tests/native_lifecycle.rs @@ -201,7 +201,20 @@ async fn verified_sidecar_bootstraps_pinned_grpc_and_closes_by_guardian_eof() { .with_worker_ipc_authentication( Secret::new(vec![0x5a; 32]).expect("bounded worker authentication"), ) - .expect("worker authentication bootstrap"); + .expect("worker authentication bootstrap") + .with_connector_grant( + SidecarApplicationGrant::new( + "app:cloud-connector-acceptance", + [ + ApiScope::new(scopes::RUNS_EXECUTE).unwrap(), + ApiScope::new(scopes::RUNS_READ).unwrap(), + ], + ["primary".into()], + Vec::::new(), + ) + .unwrap(), + ) + .expect("independent connector grant"); let lifecycle = NativeSidecarLifecycle::new(bootstrap); assert_eq!(lifecycle.status(), NativeSidecarStatus::Starting); let options = SidecarOptions::new( @@ -273,6 +286,51 @@ async fn verified_sidecar_bootstraps_pinned_grpc_and_closes_by_guardian_eof() { .expect("create run"); let run_id = created.run.run_id; assert!(!run_id.is_empty()); + let connector = client + .connector_runs() + .expect("independent native cloud client"); + assert!(client.instance_id().is_some()); + let rejected = connector + .get_run(GetRunRequest { + run_id: run_id.clone(), + }) + .await; + assert!( + rejected.is_err(), + "cloud caller must not inherit primary Desktop runs" + ); + let cloud_run = connector + .create_run(CreateRunRequest { + plugin_skill_ids: vec![], + input: vec![InputContentPart::Text("cloud caller self-test".into())], + session_id: None, + end_user_id: None, + role: "primary".into(), + mode: RunMode::Execute, + research_depth: None, + research_sources: vec![], + plan_action: None, + branch: None, + max_turns: 1, + idempotency_key: IdempotencyKey::new("independent-cloud-run").unwrap(), + }) + .await + .expect("cloud application executes beneath its native grant"); + assert!( + client + .get_run(GetRunRequest { + run_id: cloud_run.run.run_id.clone() + }) + .await + .is_err(), + "primary caller must not inherit cloud runs" + ); + connector + .get_run(GetRunRequest { + run_id: cloud_run.run.run_id, + }) + .await + .expect("cloud caller reads its own run"); assert_eq!( wait_for_terminal_run(&client, &run_id).await, RunStatus::Completed diff --git a/crates/colossus-sidecar/tests/windows_lifecycle.rs b/crates/colossus-sidecar/tests/windows_lifecycle.rs index 7edfc80bd..3fe738d07 100644 --- a/crates/colossus-sidecar/tests/windows_lifecycle.rs +++ b/crates/colossus-sidecar/tests/windows_lifecycle.rs @@ -10,8 +10,8 @@ use colossus_sdk::{ ApiMajor, AppPrivateInstanceDir, BackendKind, Colossus, CreateRunRequest, GetRunRequest, InputContentPart, InstanceId, ManagedAccessProfile, ManagedExecutionBoundary, ManagedRuntimeConfig, NativeSidecarLifecycle, NativeSidecarStatus, RunMode, RunStatus, - Sha256Digest, SidecarApplicationGrant, SidecarBootstrapConfig, SidecarOptions, - VerifiedExecutable, WorkspaceIdentity, + Sha256Digest, SidecarApplicationGrant, SidecarApprovalBrokerGrant, SidecarBootstrapConfig, + SidecarOptions, VerifiedExecutable, WorkspaceIdentity, }; use colossus_windows_native::{BoundPath, create_private_directory}; use colossus_worker_protocol::{WorkerApprovalMode, WorkerControlClient, worker_ipc_endpoint}; @@ -144,6 +144,27 @@ async fn verified_sidecar_bootstraps_pinned_grpc_runs_echo_and_closes() { .expect("bootstrap") .with_expected_workspace_identity(workspace_identity(&workspace)) .expect("expected workspace identity") + .with_approval_broker_grant( + SidecarApprovalBrokerGrant::new( + "app:native-sidecar-windows-acceptance", + ["primary".into()], + ) + .unwrap(), + ) + .expect("approval broker") + .with_connector_grant( + SidecarApplicationGrant::new( + "app:cloud-windows-acceptance", + [ + ApiScope::new(scopes::RUNS_EXECUTE).unwrap(), + ApiScope::new(scopes::RUNS_READ).unwrap(), + ], + ["primary".into()], + Vec::::new(), + ) + .unwrap(), + ) + .expect("independent cloud grant") .with_risk_auto_approvals() .with_worker_ipc_authentication(colossus_sdk::Secret::new(vec![0x5a; 32]).expect("worker key")) .expect("worker authentication"); @@ -224,6 +245,53 @@ async fn verified_sidecar_bootstraps_pinned_grpc_runs_echo_and_closes() { .expect("create run"); let run_id = created.run.run_id; assert!(!run_id.is_empty()); + let connector = client + .connector_runs() + .expect("dedicated Windows cloud client"); + assert!(client.instance_id().is_some()); + assert!( + connector + .get_run(GetRunRequest { + run_id: run_id.clone() + }) + .await + .is_err(), + "cloud cannot inherit Desktop runs" + ); + let cloud_run = connector + .create_run(CreateRunRequest { + plugin_skill_ids: vec![], + input: vec![InputContentPart::Text( + "Windows cloud grant self-test".into(), + )], + session_id: None, + end_user_id: None, + role: "primary".into(), + mode: RunMode::Execute, + research_depth: None, + research_sources: vec![], + plan_action: None, + branch: None, + max_turns: 1, + idempotency_key: IdempotencyKey::new("windows-cloud-once").unwrap(), + }) + .await + .expect("cloud grant executes"); + assert!( + client + .get_run(GetRunRequest { + run_id: cloud_run.run.run_id.clone() + }) + .await + .is_err(), + "Desktop cannot inherit cloud runs" + ); + connector + .get_run(GetRunRequest { + run_id: cloud_run.run.run_id, + }) + .await + .expect("cloud reads its own run"); assert_eq!( wait_for_terminal_run(&client, &run_id).await, RunStatus::Completed diff --git a/deny.toml b/deny.toml index 12b1f6a38..8856a1c23 100644 --- a/deny.toml +++ b/deny.toml @@ -10,7 +10,12 @@ targets = [ all-features = true [advisories] -ignore = [] +ignore = [ + # OIDC verifies signatures with public keys only; certificate issuance is + # Ed25519. No application path performs RSA private-key operations. + # See ADR 0006. Remove when OpenID Connect drops the affected dependency. + { id = "RUSTSEC-2023-0071", reason = "OIDC public-key verification is outside the private-key timing attack; no application RSA private-key operations" }, +] [licenses] allow = [ diff --git a/deploy/cloud/Dockerfile b/deploy/cloud/Dockerfile new file mode 100644 index 000000000..734bb7645 --- /dev/null +++ b/deploy/cloud/Dockerfile @@ -0,0 +1,32 @@ +# syntax=docker/dockerfile:1 +FROM node:22.18.0-bookworm-slim@sha256:752ea8a2f758c34002a0461bd9f1cee4f9a3c36d48494586f60ffce1fc708e0e AS web +WORKDIR /source +COPY apps/ui apps/ui +COPY apps/web apps/web +RUN cd apps/web && npm ci --ignore-scripts --legacy-peer-deps --install-links && npm run build + +FROM rust:1.96.0-bookworm@sha256:5e2214abe154fe26e39f64488952e5c991eeed1d6d6da7cc8381ae83927f0cfc AS build +WORKDIR /source +COPY . . +ARG PROFILE=release +ENV CARGO_BUILD_JOBS=2 +ENV CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_DEV_INCREMENTAL=false +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/source/target \ + cargo build --locked --profile ${PROFILE} -p colossus-cloud-server -p colossus-connector -p colossus-cli --bins && \ + if [ "$PROFILE" = dev ]; then output=debug; else output="$PROFILE"; fi && \ + mkdir /artifacts && cp target/${output}/colossus-cloud-server target/${output}/colossus-connector target/${output}/colossus /artifacts/ && \ + strip /artifacts/* + +FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS runtime +RUN apt-get update && apt-get install --yes --no-install-recommends ca-certificates libdbus-1-3 libsecret-1-0 && rm -rf /var/lib/apt/lists/* && \ + groupadd --gid 10001 colossus && useradd --uid 10001 --gid colossus --home-dir /var/lib/colossus --create-home colossus && \ + chmod 0700 /var/lib/colossus +COPY --from=build /artifacts/ /usr/local/bin/ +COPY --from=web /source/apps/web/dist/ /opt/colossus/web/ +USER 10001:10001 +ENV COLOSSUS_HOME=/var/lib/colossus +EXPOSE 8080 8443 +ENTRYPOINT ["colossus-cloud-server"] +CMD ["/etc/colossus/cloud.json"] diff --git a/deploy/cloud/kubernetes/cloud.json b/deploy/cloud/kubernetes/cloud.json new file mode 100644 index 000000000..b4370c4d6 --- /dev/null +++ b/deploy/cloud/kubernetes/cloud.json @@ -0,0 +1,36 @@ +{ + "http_bind": "0.0.0.0:8080", + "grpc_bind": "0.0.0.0:8443", + "public_origin": "https://colossus.example.com", + "grpc_endpoint": "https://runtimes.colossus.example.com", + "ca_certificate": "/etc/colossus/tls/ca.pem", + "ca_key": "/etc/colossus/tls/ca-key.pem", + "server_certificate": "/etc/colossus/tls/server.pem", + "server_key": "/etc/colossus/tls/server-key.pem", + "oidc": { + "issuer": "https://identity.example.com/realms/colossus", + "client_id": "colossus-cloud", + "client_secret_file": "/etc/colossus/oidc/client-secret" + }, + "memberships": [ + { + "subject": "replace-with-oidc-subject", + "project_id": "engineering", + "permissions": ["read", "execute", "control", "approve", "administer"] + } + ], + "web_root": "/opt/colossus/web", + "storage": { + "kind": "postgres", + "config": { + "connectionVariable": "COLOSSUS_CLOUD_DATABASE", + "schema": "colossus_cloud", + "tls": {"kind": "webpki_roots"}, + "statementTimeoutMs": 5000 + }, + "key_variable": "COLOSSUS_CLOUD_JOURNAL_KEY", + "anchor_path": "/var/lib/colossus/cloud.anchor" + }, + "signing_key_variable": "COLOSSUS_CLOUD_SIGNING_KEY", + "local_development": false +} diff --git a/deploy/cloud/kubernetes/control-plane.yaml b/deploy/cloud/kubernetes/control-plane.yaml new file mode 100644 index 000000000..66612b31f --- /dev/null +++ b/deploy/cloud/kubernetes/control-plane.yaml @@ -0,0 +1,106 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: colossus-cloud-anchor +spec: + accessModes: [ReadWriteOnce] + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: colossus-cloud +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: colossus-cloud + template: + metadata: + labels: + app: colossus-cloud + spec: + automountServiceAccountToken: false + terminationGracePeriodSeconds: 45 + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + initContainers: + - name: private-anchor-directory + image: colossus-cloud + command: [/bin/sh, -ec] + args: ["mkdir -p /state/cloud && chown 10001:10001 /state/cloud && chmod 0700 /state/cloud"] + securityContext: + runAsUser: 0 + runAsNonRoot: false + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: [ALL], add: [CHOWN, FOWNER]} + volumeMounts: + - {name: anchor, mountPath: /state} + containers: + - name: control-plane + image: colossus-cloud + imagePullPolicy: IfNotPresent + ports: + - {name: http, containerPort: 8080} + - {name: connector, containerPort: 8443} + env: + - name: COLOSSUS_CLOUD_DATABASE + valueFrom: {secretKeyRef: {name: colossus-cloud-state, key: database}} + - name: COLOSSUS_CLOUD_JOURNAL_KEY + valueFrom: {secretKeyRef: {name: colossus-cloud-state, key: journal-key}} + - name: COLOSSUS_CLOUD_SIGNING_KEY + valueFrom: {secretKeyRef: {name: colossus-cloud-state, key: signing-key}} + resources: + requests: {cpu: 250m, memory: 256Mi} + limits: {cpu: "2", memory: 1Gi} + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: {drop: [ALL]} + startupProbe: + httpGet: {path: /health/ready, port: http} + periodSeconds: 2 + failureThreshold: 60 + readinessProbe: + httpGet: {path: /health/ready, port: http} + periodSeconds: 5 + livenessProbe: + httpGet: {path: /health/live, port: http} + periodSeconds: 10 + volumeMounts: + - {name: config, mountPath: /etc/colossus/cloud.json, subPath: cloud.json, readOnly: true} + - {name: tls, mountPath: /etc/colossus/tls, readOnly: true} + - {name: oidc, mountPath: /etc/colossus/oidc, readOnly: true} + - {name: anchor, mountPath: /var/lib/colossus, subPath: cloud} + - {name: temporary, mountPath: /tmp} + volumes: + - name: config + configMap: {name: colossus-cloud-config} + - name: tls + secret: {secretName: colossus-cloud-tls, defaultMode: 288} + - name: oidc + secret: {secretName: colossus-cloud-oidc, defaultMode: 288} + - name: anchor + persistentVolumeClaim: {claimName: colossus-cloud-anchor} + - name: temporary + emptyDir: {sizeLimit: 64Mi} +--- +apiVersion: v1 +kind: Service +metadata: + name: colossus-cloud +spec: + selector: {app: colossus-cloud} + ports: + - {name: http, port: 8080, targetPort: http} + - {name: connector, port: 8443, targetPort: connector} diff --git a/deploy/cloud/kubernetes/ingress.yaml b/deploy/cloud/kubernetes/ingress.yaml new file mode 100644 index 000000000..0567e7f99 --- /dev/null +++ b/deploy/cloud/kubernetes/ingress.yaml @@ -0,0 +1,40 @@ +# Example for ingress-nginx. Its controller must enable --enable-ssl-passthrough. +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: colossus-browser + annotations: + nginx.ingress.kubernetes.io/proxy-buffering: "off" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" +spec: + ingressClassName: nginx + tls: + - hosts: [colossus.example.com] + secretName: colossus-browser-tls + rules: + - host: colossus.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: {name: colossus-cloud, port: {name: http}} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: colossus-runtime + annotations: + nginx.ingress.kubernetes.io/ssl-passthrough: "true" + nginx.ingress.kubernetes.io/backend-protocol: HTTPS +spec: + ingressClassName: nginx + rules: + - host: runtimes.colossus.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: {name: colossus-cloud, port: {name: connector}} diff --git a/deploy/cloud/kubernetes/kustomization.yaml b/deploy/cloud/kubernetes/kustomization.yaml new file mode 100644 index 000000000..e657da27e --- /dev/null +++ b/deploy/cloud/kubernetes/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: colossus +resources: + - namespace.yaml + - control-plane.yaml + - ingress.yaml +configMapGenerator: + - name: colossus-cloud-config + files: + - cloud.json +images: + - name: colossus-cloud + newName: registry.example.com/colossus/cloud + newTag: replace-with-your-built-image diff --git a/deploy/cloud/kubernetes/namespace.yaml b/deploy/cloud/kubernetes/namespace.yaml new file mode 100644 index 000000000..7b2e483c7 --- /dev/null +++ b/deploy/cloud/kubernetes/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: colossus diff --git a/deploy/cloud/linux-desktop.Dockerfile b/deploy/cloud/linux-desktop.Dockerfile new file mode 100644 index 000000000..fe8960d28 --- /dev/null +++ b/deploy/cloud/linux-desktop.Dockerfile @@ -0,0 +1,30 @@ +# Reproducible Linux Desktop package/acceptance builder, without host credentials. +FROM node:22.18.0-bookworm-slim@sha256:752ea8a2f758c34002a0461bd9f1cee4f9a3c36d48494586f60ffce1fc708e0e AS node +FROM rust:1.96.0-bookworm@sha256:5e2214abe154fe26e39f64488952e5c991eeed1d6d6da7cc8381ae83927f0cfc AS build-base +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx +RUN apt-get update && apt-get install -y --no-install-recommends \ + clang cmake pkg-config libssl-dev libsecret-1-dev libwebkit2gtk-4.1-dev libasound2-dev \ + libayatana-appindicator3-dev librsvg2-dev libxdo-dev patchelf \ + dbus-x11 gnome-keyring xvfb xauth xdotool python3 \ + && rm -rf /var/lib/apt/lists/* +ENV CARGO_BUILD_JOBS=4 +WORKDIR /build + +FROM build-base AS package +COPY . . +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/build/target \ + --mount=type=cache,target=/build/apps/desktop/src-tauri/target \ + npm ci --prefix apps/desktop --ignore-scripts --legacy-peer-deps --install-links \ + && git init --quiet \ + && COLOSSUS_DESKTOP_RELEASE_CHANNEL=developer_preview COLOSSUS_DESKTOP_TEAM_ID=UNSIGNED sh scripts/package-desktop-linux +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/build/target \ + --mount=type=cache,target=/build/apps/desktop/src-tauri/target \ + xvfb-run -a dbus-run-session -- sh scripts/test-desktop-linux-native +FROM scratch AS artifacts +COPY --from=package /build/dist/desktop-linux/ / diff --git a/deploy/cloud/local/compose.yml b/deploy/cloud/local/compose.yml new file mode 100644 index 000000000..c6429cfb3 --- /dev/null +++ b/deploy/cloud/local/compose.yml @@ -0,0 +1,24 @@ +services: + oidc: + image: quay.io/keycloak/keycloak:26.8.0@sha256:b0f60d489d51c5d113390bdf5461d4c06e6051be026c05549f2e1e10ec352bcc + command: [start-dev, --import-realm] + ports: ["127.0.0.1:8180:8080"] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: local-admin + KC_BOOTSTRAP_ADMIN_PASSWORD: local-only-admin + KC_HOSTNAME: http://127.0.0.1:8180 + volumes: ["./realm.json:/opt/keycloak/data/import/colossus.json:ro"] + mem_limit: 1g + postgres: + image: postgres:17.9@sha256:2a0d0fe14825b0939f78a8cad5cd4e6aa68bf94d0e5dd96e24b6d23af4315545 + ports: ["127.0.0.1:5433:5432"] + environment: + POSTGRES_USER: colossus + POSTGRES_PASSWORD: local-only-database + POSTGRES_DB: colossus_cloud + healthcheck: + test: [CMD-SHELL, "pg_isready -U colossus -d colossus_cloud"] + interval: 2s + timeout: 2s + retries: 30 + mem_limit: 512m diff --git a/deploy/cloud/local/realm.json b/deploy/cloud/local/realm.json new file mode 100644 index 000000000..e64a8aea3 --- /dev/null +++ b/deploy/cloud/local/realm.json @@ -0,0 +1,45 @@ +{ + "realm": "colossus", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "accessTokenLifespan": 1800, + "clients": [ + { + "clientId": "colossus-cloud", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "redirectUris": [ + "http://127.0.0.1:5180/auth/callback", + "http://127.0.0.1:8090/auth/callback" + ], + "webOrigins": [ + "http://127.0.0.1:5180", + "http://127.0.0.1:8090" + ], + "attributes": { + "pkce.code.challenge.method": "S256" + } + } + ], + "users": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "username": "alice", + "enabled": true, + "emailVerified": true, + "firstName": "Alice", + "lastName": "Local", + "email": "alice@local.test", + "credentials": [ + { + "type": "password", + "value": "local-only-password", + "temporary": false + } + ] + } + ] +} diff --git a/docs/admin/cloud-control-plane.md b/docs/admin/cloud-control-plane.md new file mode 100644 index 000000000..1f9d1f3f2 --- /dev/null +++ b/docs/admin/cloud-control-plane.md @@ -0,0 +1,228 @@ +--- +title: Connect runtimes to Colossus Cloud +description: Deploy the Kubernetes control plane and explicitly enroll CLI or Desktop runtimes. +audience: operator +type: how-to +--- + +# Connect runtimes to Colossus Cloud + +Colossus Cloud starts and controls tasks on explicitly enrolled runtimes. A runtime +opens an outbound connection; its workspace and daemon need no inbound public port. +The cloud host owns project membership and durable placement. The local runtime +continues to own provider credentials, tools, policy, approvals, sandboxing and audit. + +## Deploy on Kubernetes + +Use the source deployment under `deploy/cloud/kubernetes`. Set the built image in +`kustomization.yaml`, replace both example hostnames, and configure `cloud.json` with +your OIDC issuer, relying-party client, exact subject/project memberships and PostgreSQL +schema. Register the exact browser `/auth/callback` URL with the identity provider. +Sign-in uses authorization code, S256 PKCE, state and nonce; project permissions are +explicit independent `read`, `execute`, `control`, `approve` and `administer` grants. + +Create the namespace and supply Secrets through your normal secret manager: + +```sh +kubectl create namespace colossus +kubectl -n colossus create secret generic colossus-cloud-state \ + --from-file=database=/secure/cloud/database-url \ + --from-file=journal-key=/secure/cloud/journal-key \ + --from-file=signing-key=/secure/cloud/signing-key +kubectl -n colossus create secret generic colossus-cloud-oidc \ + --from-file=client-secret=/secure/cloud/oidc-client-secret +kubectl -n colossus create secret generic colossus-cloud-tls \ + --from-file=ca.pem=/secure/cloud/ca.pem \ + --from-file=ca-key.pem=/secure/cloud/ca-key.pem \ + --from-file=server.pem=/secure/cloud/server.pem \ + --from-file=server-key.pem=/secure/cloud/server-key.pem +kubectl -n colossus create secret tls colossus-browser-tls \ + --cert=/secure/cloud/browser.pem --key=/secure/cloud/browser-key.pem +kubectl apply -k deploy/cloud/kubernetes +kubectl -n colossus rollout status deployment/colossus-cloud +``` + +The journal encryption key and Ed25519 signing seed are separate random 32-byte hex +values. Keep their values out of Git and command arguments. Preserve the anchor PVC +with the database, and follow [state recovery](../develop/state-recovery.md) when restoring +both. PostgreSQL verifies TLS with WebPKI roots by default; private database CAs use +the existing adapter's `custom_ca` configuration and a mounted PEM bundle. + +This deployment uses one host replica and `Recreate` updates. Session and connection +ownership are process-local; horizontal scaling requires a separate ownership design. +Do not increase replicas or attach an HPA to this deployment. + +Browser HTTPS terminates at the trusted ingress. The separate runtime hostname on +port 443 passes TCP/TLS through to port 8443, preserving HTTP/2 and client certificates. +The runtime server leaf must cover that hostname and chain to the configured connector +CA. The checked-in ingress example uses ingress-nginx's +[SSL passthrough](https://kubernetes.github.io/ingress-nginx/user-guide/tls/#ssl-passthrough); +enable its controller flag or use your environment's equivalent TCP forwarding. +Keep browser SSE buffering disabled. `/health/live` reports the host process; +`/health/ready` probes canonical storage. SIGTERM closes streams and drains the host. + +## Enroll a CLI daemon + +Start an existing configured worker with an owner-private public API directory. +Stop it briefly to enroll a dedicated application using the same directory and +configuration, then restart it. Offline enrollment refuses another active owner. + +```sh +colossus --workspace /work/project --config /secure/runtime.yaml worker \ + --public-api-dir /secure/runtime-api \ + --enroll-application app:colossus-cloud-cli \ + --scope runs:execute --scope runs:read --scope runs:control \ + --scope prompts:respond --scope approvals:respond \ + --role primary --tool user.ask --tool filesystem.read \ + --credential-keyring-service dev.colossus.cloud \ + --credential-keyring-account engineering +colossus --workspace /work/project --config /secure/runtime.yaml worker \ + --public-api-dir /secure/runtime-api +``` + +Choose tools and roles explicitly beneath the runtime's existing ceilings. The native +credential store receives the bearer directly; enrollment output contains public metadata +only. Public API administration is supported on Windows, macOS and Linux with their +native owner-private directory checks. + +Create an owner-private `connector.json` containing only public paths, pins and native +credential references. Use the exact values returned by local application enrollment: + +```json +{ + "descriptor": "/secure/runtime-api/endpoint.json", + "certificate": "/secure/runtime-api/certificate.pem", + "instance_id": "replace-with-enrolled-local-instance", + "certificate_sha256": "replace-with-exact-64-character-local-pin", + "keyring_service": "dev.colossus.cloud", + "keyring_account": "engineering" +} +``` + +Sign into the web app, select the project, open **Runtime fleet**, and create an +invitation with the reviewed label and role ceiling. On the runtime machine, run: + +```sh +colossus cloud enroll --local-config /secure/connector.json \ + --enrollment-url https://colossus.example.com/api/enroll --name engineering +colossus cloud run --local-config /secure/connector.json --name engineering +``` + +In another terminal, inspect or stop that exact named CLI connection: + +```sh +colossus cloud status --name engineering +colossus cloud disconnect --name engineering +colossus cloud revoke --name engineering +``` + +Status reports a bounded, owner-private, non-secret heartbeat independently of the +encrypted credential vault. A stale running heartbeat is reported as `unknown`. +Disconnect requests are bound to one connector process generation; an old request +cannot stop a later connection. Revocation disconnects first, then uses the native +client certificate to revoke only its own enrolled node. Retry a failed revocation +with the same enrollment; forgetting credentials alone does not revoke cloud authority. + +Paste the invitation into the stdin prompt. It expires after ten minutes and is consumed +once. The connector generates and retains its private key locally. Enrollment binds the +cloud node to the independently verified local instance. The separate `colossus-connector` +binary accepts the same commands without the `cloud` prefix. + +## Enroll Desktop + +Select a Managed Local workspace and open its **Cloud** settings. Paste the web +invitation and enrollment URL, then review the native confirmation showing the cloud +origin and execution/approval authority. Desktop uses a separate native-held application +credential; the renderer receives connection status and public identities only. +External daemon targets use their separately enrolled CLI connector. + +Desktop can disconnect, reconnect, revoke, and forget its enrollment. Closing the cloud +connection leaves accepted tasks running locally. Removing a local enrollment does +not revoke it remotely. Use **Revoke enrollment** in Desktop or **Revoke runtime** in +the web fleet to remove cloud authority. Desktop revocation requires native confirmation. + +## Install and supervise runtime connections + +CLI and standalone connectors use the same outbound protocol on Windows, macOS and +Linux. Use the platform's native credential authority for interactive installations: +Windows Credential Manager, macOS Keychain, or Linux Secret Service. A graphical +credential authority must be unlocked for enrollment, connection and rotation. Use +the explicit sealed headless authority below for unattended hosts that lack one. + +Run the daemon and connector as the same selected operating-system user, with distinct +process lifetimes. On Linux, supervise both with systemd; on macOS, use launchd; +on Windows, use your service supervisor or Task Scheduler. Preserve the configured +owner-private runtime and Colossus home directories across restarts. Select the exact +enrollment name and local configuration in the connector's supervised command. Inject +headless wrapping keys through the supervisor's protected secret environment. + +For concurrent CLI connectors, give each daemon/connector pair its own owner-private +`COLOSSUS_HOME`. A native vault retains one process's exclusive lease while the +connector is running; named enrollments in one home are selected serially. Desktop +shares its native vault within the application and can connect multiple workspaces. + +Use `cloud disconnect` or Ctrl-C for graceful connector shutdown. This leaves the +daemon and accepted runs alive. Stop the worker separately with its normal graceful +shutdown when ending local execution. Revoke before uninstalling a enrolled connector, +then use `cloud forget` to delete its native enrollment. Desktop's embedded connector +and Managed Local runtime end when Desktop exits; persistent execution requires a +separately supervised CLI daemon. + +Linux Desktop is distributed as an unsigned Developer Preview `.deb` for Debian 12 +compatible systems, in native x86-64 and ARM64 builds. Verify the accompanying SHA-256 +checksum and install the package with your distribution's package manager. It includes +sealed sidecar, CLI and search executables and requires GTK 3, WebKitGTK 4.1, Secret +Service and ALSA. A running unlocked Secret Service is required for native credentials. +Launch `colossus-desktop` or the desktop menu entry. Revoke enrolled workspaces before +removing the package. User workspace state remains in the configured Colossus home. + +## Use an explicit headless authority + +Linux servers without Secret Service can inject an operator-owned random 32-byte hex +wrapping key through a secret manager. Set the worker's +`--public-api-vault-key-variable COLOSSUS_HEADLESS_KEY` for both offline enrollment and +normal hosting. Its public API credentials are sealed beneath the public API directory's +`headless-credentials` child. Add the non-secret selector to `connector.json`: + +```json +{ + "headless_authority": { + "directory": "/secure/runtime-api/headless-credentials", + "key_variable": "COLOSSUS_HEADLESS_KEY" + } +} +``` + +Retain all the earlier connector fields. Supply +`--vault-key-variable COLOSSUS_HEADLESS_KEY` to connector enrollment and lifecycle +commands. The injected key protects the vault's small key envelope; the daemon bearer +and connector TLS private key remain sealed on disk. There is no fallback to plaintext +or another credential store when that authority is missing or changed. + +## Verify and recover + +The fleet shows a ready node after its authenticated heartbeat. Submit a short task, +open it, and verify live output, pending prompts and approvals, and its terminal state. +Approval requires the independent project `approve` permission plus the native local +grant. Cancellation and ordinary prompt responses require `control`. + +Connector certificates last thirty days and renew with a new key at seven days +remaining. `colossus cloud renew --name engineering` explicitly rotates a certificate; +use the same native vault selector for headless deployments. Interrupted enrollment and +renewal retain their exact pending exchange for retry. Expired or revoked enrollment +requires operator reconciliation or a new reviewed invitation. + +Cloud and connector restarts replay the same task/run allocation and exclusive durable +output cursor. Disconnection never moves accepted work to another machine. An unknown +outcome stays explicit; inspect the original local run before deciding whether to submit +new work. Never automatically retry it with a fresh task identity. + +Each node admits sixteen active tasks. Each task retains at most 4096 output events and +16 MiB of released output. Reaching that bound marks the web task explicitly and switches +to snapshot/control updates; full runtime output remains local. Sessions are bounded by +the verified ID-token expiry and eight hours; sign in again to resume observation. + +Cloud sandbox provisioning, artifact/context transfer and scheduled cloud workflows +require separate ownership contracts. Existing local sandbox and policy behavior still +applies to every task. Source and acceptance commands are in +[cloud development](../develop/cloud-control-plane.md). diff --git a/docs/develop/adr/0006-cloud-control-plane.md b/docs/develop/adr/0006-cloud-control-plane.md new file mode 100644 index 000000000..365390100 --- /dev/null +++ b/docs/develop/adr/0006-cloud-control-plane.md @@ -0,0 +1,96 @@ +--- +title: "ADR 0006: Cloud control plane and outbound connections" +description: Cloud protocol, project authority, enrollment trust, native grants, lifecycle and durable recovery decisions. +audience: developer +type: concept +--- + +# ADR 0006: Cloud control plane and outbound connections + +- Status: accepted +- Date: 2026-10-05 + +## Context + +Workstations and independently supervised daemons must accept cloud work behind NAT +without exposing the runtime's loopback API. A browser cannot hold runtime credentials +or inherit the Desktop caller's authority. Stream reconnection alone does not establish +whether a mutation was accepted or whether output was retained durably. + +## Decision + +Use a separate cloud application and a reusable native connector. `colossus-cloud` +owns durable project/node/task operations; `colossus-cloud-server` composes OIDC, +HTTP/JSON, SSE, certificate issuance and an independent redb/PostgreSQL journal. +`colossus-cloud-protocol` owns a separate major-versioned Protobuf bidirectional stream +with closed typed SDK operations. `colossus-connector` implements enrollment and the +outbound stream for CLI, standalone and native Desktop hosts. + +Browser authentication uses OIDC authorization code with PKCE, verified issuer, +audience, signature, nonce, expiry and access-token hash. Explicit subject/project +memberships grant independent read, execution, control, approval and administration +permissions. Opaque browser sessions use protected cookies and same-origin CSRF checks. +Browser code receives released views and one-use invitations, never native bearers, +provider keys or machine private keys. + +Machine connections use mutual TLS 1.3 over HTTP/2, normally on port 443. Kubernetes +connector ingress preserves end-to-end TLS. One-use, expiring invitation redemption +binds a node to one project, independently verified local runtime instance and signed +CSR. Certificate renewal persists its identity and CSR before exchange, reconciles a +lost reply and atomically replaces the host fingerprint. Revocation is durable and +rechecked during active connections. Certificate authority and connector keys are +Ed25519 and remain native/operator-held. + +Each connector authenticates the pinned local public API as a dedicated enrolled +application. Native Desktop bootstrap provisions separate primary, approval-broker and +connector grants, delivered and activated atomically. Cloud roles intersect immutable +local roles, scopes and tool ceilings. Runtime policy, audit, approvals, sandboxing, +effect recovery and output release remain runtime-owned. No private worker RPC, +arbitrary filesystem/network tunnel or generic renderer capability crosses this boundary. + +Allocate each task to one node and one local run, with caller/project-scoped stable +idempotency keys and durable command receipts. Persist allocation, receipt and event +cursor changes atomically, and checkpoint signed storage before acknowledging them. +Exact retries reconcile existing state; changed duplicates and cursor gaps fail. +Unknown outcomes stay explicit and retain their admission slot. They never cause +automatic execution on another node. + +Output resumes after the highest contiguous acknowledged sequence. Bound frames, +queued messages, active tasks, retained events and bytes, and connection/request +timeouts. Reaching retention limits is explicit; the exact run's snapshots and controls +continue. HTTP mutations acknowledge durable queuing; the UI checks runtime receipts +before confirming an approval response or cancellation. Shutdown drains for a bounded +period. Disconnecting a connector does not cancel accepted work. + +Channel startup has a ten-second deadline across TCP, TLS and HTTP/2, including +certificate renewal and revocation. Opening stream response headers and receiving its +first welcome have separate ten-second deadlines; an established stream remains live +under heartbeat and backpressure bounds. An authenticated peer withholding HTTP/2 +progress must return the same connector to bounded retry without changing run identity. + +Desktop-managed sidecars stop with Desktop. Persistent work uses an independently +supervised daemon and connector under one explicitly chosen OS-user identity. Windows +uses protected discovery and native credentials; macOS uses Keychain; graphical Linux +uses Secret Service and native GTK credential entry. Headless Linux explicitly injects +a wrapping-key authority and stores only authenticated encrypted envelopes. Missing, +changed or unavailable authority fails closed. + +## Consequences + +The web frontend reuses React/TypeScript and Colossus UI. Kubernetes manifests and +portable container images avoid a cloud-provider dependency. Linux Desktop preview +packages bind the sealed executable manifest into the running ELF and verify every +bundled executable digest. They report unsigned distribution accurately and do not +advertise a stable automatic update channel. + +The OpenID Connect dependency brings `rsa`, affected by +[RUSTSEC-2023-0071](https://rustsec.org/advisories/RUSTSEC-2023-0071.html). The advisory +concerns private-key timing leakage. Colossus uses this dependency only to verify +provider signatures with public keys; it has no RSA private-key signing or decryption +path. Keep the exact documented supply-chain exception limited to that use and remove +it when the dependency no longer contains the affected version. Adding any RSA +private-key operation requires revisiting this decision and exception. + +Sandbox provisioning, scheduling, cloud artifact transfer and cross-run context +mapping remain separate extensions. See [cloud development](../cloud-control-plane.md) +and [operations](../../admin/cloud-control-plane.md) for current setup and acceptance. diff --git a/docs/develop/adr/index.md b/docs/develop/adr/index.md index 20b4ebff7..6063844e0 100644 --- a/docs/develop/adr/index.md +++ b/docs/develop/adr/index.md @@ -15,6 +15,7 @@ not duplicate current configuration or operator procedures. - [ADR 0003: Desktop browser boundary](0003-desktop-browser-boundary.md) - [ADR 0004: Desktop Git inspection](0004-desktop-git-inspection.md) - [ADR 0005: Desktop file search and diffs](0005-desktop-file-diffs.md) +- [ADR 0006: Cloud control plane and outbound connections](0006-cloud-control-plane.md) Use the next sequential number. Record context, decision, consequences, status, and the date; link to canonical current documentation rather than copying it. diff --git a/docs/develop/application-sdk.md b/docs/develop/application-sdk.md index fbedd89d6..f9f96bf3c 100644 --- a/docs/develop/application-sdk.md +++ b/docs/develop/application-sdk.md @@ -238,6 +238,13 @@ CodeDirectory identity to match before `SIGCONT`. On Linux, it executes the veri bytes from a sealed, non-writable `memfd`. Platforms without an equivalent mechanism fail Managed Local startup closed. +Linux native startup retains only the host's local Unix D-Bus session locator after +clearing the child's environment. The SDK accepts one filesystem or abstract Unix +socket and verifies the bus peer's OS user before launch; it rejects remote transports, +autolaunch, aliases, and ambiguous addresses. Without an explicit session locator it +uses `/run/user//bus`. This locator carries no secret. The native Secret Service +remains required; missing session authority fails startup without a credential fallback. + The selected macOS workspace is also persistent object authority rather than a saved path. Desktop hashes the device, inode, and birth timestamp obtained from a securely opened directory descriptor and includes that versioned identity in its private state diff --git a/docs/develop/architecture.md b/docs/develop/architecture.md index 1fd9e59ee..c38731a17 100644 --- a/docs/develop/architecture.md +++ b/docs/develop/architecture.md @@ -75,10 +75,18 @@ infrastructure adapters implement ports and are assembled only by the runtime. | Security and catalog | `colossus-access`, `colossus-policy`, `colossus-tools` | Capability metadata, decisions, permits, and strict tool schemas | | Infrastructure | `colossus-provider`, `colossus-codex-auth`, `colossus-credentials`, journal/projection crates, `colossus-sandbox`, `colossus-integrations`, `colossus-mcp`, `colossus-plugins`, `colossus-bundles`, `colossus-search` | External systems, authentication, plugin OCI/lifecycle, release bundles, and storage adapters | | Public API and SDK | `colossus-api-proto`, `colossus-api`, `colossus-api-runtime`, `colossus-grpc`, `colossus-sdk` | Version public resources, authenticate applications, host durable runs, and provide transport-neutral clients | +| Cloud application | `colossus-cloud`, `colossus-cloud-protocol`, `colossus-cloud-server`, `colossus-connector` | Project authority, durable fixed-node placement, OIDC browser access, and native outbound connections through the public SDK | | Composition and interfaces | `colossus-runtime`, `colossus-worker-protocol`, `colossus-worker`, `colossus-cli`, `colossus-tui`, `colossus-presentation` | Narrow private transport contracts, wire services, host application contracts, and released-data rendering | ## Boundary rules +The [cloud control plane](cloud-control-plane.md) is a separate application composition. +Its journal and OIDC project authority are independent of runtime storage and policy. +Each connector uses a dedicated native application grant; it cannot borrow the Desktop +primary or approval-broker credential. Cloud roles intersect that grant and never +expand local tool, policy, or sandbox authority. The closed remote operation set does +not expose private worker RPC, arbitrary network forwarding, or renderer credentials. + Manual Desktop credentials and platform-backed MCP OAuth share the `colossus-credentials` adapter. `HostSecret` (64 KiB) and `VaultRecord` (1 MiB) are non-serializable, zeroizing contracts. `CredentialVault` and its opaque record diff --git a/docs/develop/cloud-control-plane.md b/docs/develop/cloud-control-plane.md new file mode 100644 index 000000000..a49cfff06 --- /dev/null +++ b/docs/develop/cloud-control-plane.md @@ -0,0 +1,152 @@ +--- +title: Develop and test the cloud control plane +description: Run the real local OIDC, browser, host and runtime connection stack and its acceptance checks. +audience: developer +type: how-to +--- + +# Develop and test the cloud control plane + +The stack uses Rust for the host and connector, React/TypeScript with the shared Colossus +UI for the browser, existing redb/PostgreSQL journal adapters, and configurable OIDC. +`colossus-cloud` owns durable project/node/task operations. `colossus-cloud-protocol` +owns the closed versioned connection contract. `colossus-cloud-server` hosts HTTP/OIDC, +SSE and end-to-end TLS 1.3 mutual-TLS gRPC. `colossus-connector` uses only the public SDK +under a dedicated native application grant. Desktop embeds that connector; CLI exposes +it through `cloud`, and the standalone binary owns the same lifecycle commands. + +## Run the local stack + +Start the disposable identity provider and PostgreSQL fixtures: + +```sh +docker compose -f deploy/cloud/local/compose.yml up -d +cargo run -p colossus-cloud-server --example init_local -- /private/tmp/colossus-cloud +cargo run -p colossus-cloud-server -- /private/tmp/colossus-cloud/cloud.json +``` + +On Linux, select an absolute owner-private path such as `/tmp/colossus-cloud`. +The example generates a private local CA and server key, plaintext development redb, +loopback HTTP on 8090 and mutual-TLS gRPC on 8443. It refuses to overwrite an existing +configuration. Development HTTP/plaintext allowances require explicit loopback-only +configuration and are rejected for production endpoints. + +In another terminal: + +```sh +npm ci --prefix apps/web --ignore-scripts --legacy-peer-deps --install-links +npm run dev --prefix apps/web +``` + +Open `http://127.0.0.1:5180` and sign in as `alice` with `local-only-password`. The fixture +realm/client and explicit subject membership match the generated local configuration. +Those checked-in credentials exist only for this disposable loopback fixture. + +Generate an isolated credential-free Echo runtime: + +```sh +cargo run -p colossus-cloud-server --example init_runtime -- /private/tmp/colossus-runtime +cargo build --locked -p colossus-cli -p colossus-connector --bins +``` + +Follow [runtime enrollment](../admin/cloud-control-plane.md#enroll-a-cli-daemon) with +the generated runtime config and API directory. Use `--tool echo` for the Echo fixture. +Cloud enrollment to the loopback host additionally requires `--allow-loopback-http`. +Start the worker and connector in separate terminals. The browser should show the +runtime ready, then show a submitted Echo task complete with its released output. + +For Desktop, prepare current verified binaries before launching the native app: + +```sh +cargo xtask desktop prepare --profile debug +npm run tauri --prefix apps/desktop -- dev -- --locked +``` + +Select an isolated Managed Local workspace and use its **Cloud** settings. Native +confirmation, keyboard/focus behavior, and actual platform credential storage require +an unlocked graphical desktop. Renderer fixtures do not establish those properties. + +Build the combined Kubernetes image from the repository root: + +```sh +docker build -f deploy/cloud/Dockerfile -t colossus-cloud:local . +kubectl kustomize deploy/cloud/kubernetes +``` + +The default image uses the release profile. `--build-arg PROFILE=dev` builds a stripped +debug image for local Linux acceptance. Builder/runtime images and local fixtures are +digest pinned. Production deployment and Secret configuration belong to the +[operator guide](../admin/cloud-control-plane.md). + +Build and validate Linux Desktop in its disposable native builder: + +```sh +docker build -f deploy/cloud/linux-desktop.Dockerfile --target artifacts \ + --output type=local,dest=/tmp/colossus-linux-desktop . +``` + +The host architecture selects x86-64 or ARM64. This exports an unsigned Developer +Preview Debian package and its checksum. The builder verifies the native masked +credential dialog under an isolated Xvfb display, including validation, clipboard +preservation and cancellation. The package seals its sidecar/CLI/search digests into +the main ELF executable. Linux packaging also runs in the full pre-merge acceptance +gate. A native Linux checkout with the builder's listed dependencies can use +`COLOSSUS_DESKTOP_RELEASE_CHANNEL=developer_preview COLOSSUS_DESKTOP_TEAM_ID=UNSIGNED +sh scripts/package-desktop-linux` after installing Desktop npm dependencies. + +## Acceptance and completion checks + +```sh +cargo test --locked -p colossus-cloud -p colossus-cloud-protocol +cargo test --locked -p colossus-cloud-server --lib +cargo test --locked -p colossus-credentials --test headless +cargo test --locked -p colossus-sidecar --test native_lifecycle -- --ignored +cargo xtask check web +cargo xtask check rust +cargo xtask check desktop +cargo xtask check docs +cargo xtask pr --base origin/main +``` + +The server acceptance owns a signed local OIDC issuer, real browser HTTP/SSE, encrypted +native enrollment records, the production mutual-TLS transport and connector, and a +real runtime using a deterministic credential-free model endpoint. It verifies prompt +responses, policy-bound file-write approval, cancellation, certificate rotation, durable +host restart, and revocation. No external model key or provider service is required. +The native sidecar acceptance requires a usable OS credential store and loopback sockets; +it proves independently granted cloud and Desktop callers cannot read each other's runs. + +The ignored `managed_sidecar_cloud_run_isolated_and_application_exit_disconnects` +server acceptance uses `COLOSSUS_ACCEPTANCE_SIDECAR` to select a prepared native sidecar. +It signs in through OIDC, enrolls its independently bootstrapped cloud grant, submits +a browser HTTP task through the mutual-TLS connection, receives released native output, +and verifies application exit disconnects cloud readiness without changing the durable +run identity. The Linux package builder and native macOS/Windows pre-merge jobs run +this check with the platform credential store. + +The operator-owned PostgreSQL variant runs the same transport/runtime/restart flow +against the local fixture. Inject `COLOSSUS_CLOUD_TEST_DATABASE` with its connection +URL and fresh independent 32-byte hex values for `COLOSSUS_CLOUD_TEST_JOURNAL_KEY` and +`COLOSSUS_CLOUD_TEST_SIGNING_KEY`, then run: + +```sh +cargo test --locked -p colossus-cloud-server --lib postgres_oidc_to_runtime_and_recover -- --ignored +``` + +Each invocation owns a uniquely named `cloud_e2e_` schema in the disposable fixture +database. Never point this acceptance command at an operator's production database. + +Repository tests additionally cover exact idempotent receipts, project isolation, +contiguous output and changed-duplicate rejection, role ceilings, admission bounds, +certificate renewal ACK reconciliation, OIDC signature/issuer/audience/nonce/expiry +validation and CSRF. The headless acceptance runs isolated processes to prove sealed +authority survives restart and fails closed when its wrapping key changes. + +Use the actual web app for desktop/mobile, light/dark, keyboard/dialog and browser +console acceptance. Test native enrollment confirmation separately on the supported +platform. The Desktop gate includes the cloud web gate, and changes under `apps/web` +select that gate through the existing CI classification contract. + +Keep local invitations, private keys, screenshots, logs and task plans in ignored +development output or owner-private temporary directories. Remove disposable fixtures +with `docker compose -f deploy/cloud/local/compose.yml down` after acceptance. diff --git a/docs/develop/testing.md b/docs/develop/testing.md index 85166ce70..eb86f68f9 100644 --- a/docs/develop/testing.md +++ b/docs/develop/testing.md @@ -37,6 +37,7 @@ protects a current boundary; it does not mean every future assertion is permanen | `colossus-api` | Keep server composition and public API lifecycle tests. | | `colossus-audit` | Keep journal export, retry, recovery, and live WORM acceptance tests. | | `colossus-cli` | Keep command-level smoke suites because they exercise public parsing and embedded/worker boundaries; remove a suite only when its command or contract is removed. | +| `colossus-cloud`, `colossus-cloud-protocol`, `colossus-cloud-server`, `colossus-connector` | Keep project/placement/receipt/cursor/renewal invariants and the signed OIDC-to-runtime transport acceptance; run the operator-owned PostgreSQL variant described in [cloud development](cloud-control-plane.md). | | `colossus-codex-auth` | Keep OAuth/device-flow parsing, storage, and redaction tests. | | `colossus-context` | Keep compaction budgets, snapshots, and deterministic fallback unit tests. | | `colossus-contracts` | Keep serialization, validation, and stable contract-shape tests. | diff --git a/release/verify-release-readiness.sh b/release/verify-release-readiness.sh index 7c10637f5..0c222574c 100755 --- a/release/verify-release-readiness.sh +++ b/release/verify-release-readiness.sh @@ -77,10 +77,12 @@ run cargo deny --locked check -D warnings advisories # registry release in quickwit-oss/tantivy#3034. Remove this exact exception # with that upgrade. cargo-deny does not report this informational advisory, so # its advisory policy remains unmodified. -run cargo audit -D warnings --ignore RUSTSEC-2026-0253 --file Cargo.lock +# OpenID Connect verifies public signatures only; no RSA private-key operations. +# Keep this exact exception aligned with deny.toml and ADR 0006. +run cargo audit -D warnings --ignore RUSTSEC-2026-0253 --ignore RUSTSEC-2023-0071 --file Cargo.lock run cargo deny --manifest-path fuzz/Cargo.toml --config deny.toml --locked check -A license-not-encountered licenses sources bans -run cargo deny --manifest-path fuzz/Cargo.toml --config deny.toml --locked check -D warnings advisories +run cargo deny --manifest-path fuzz/Cargo.toml --config deny.toml --locked check -D warnings -A advisory-not-detected advisories run cargo audit -D warnings --file fuzz/Cargo.lock printf 'local release-readiness verification passed\n' diff --git a/scripts/ci/classify-changes.sh b/scripts/ci/classify-changes.sh index 208b90a84..2b9e66845 100755 --- a/scripts/ci/classify-changes.sh +++ b/scripts/ci/classify-changes.sh @@ -41,7 +41,7 @@ for changed_path in "$@"; do esac case "$changed_path" in - apps/desktop/* | apps/ui/* | crates/colossus-native-dictation/* | crates/colossus-tui/* | release/dictation/* | scripts/stage-dictation-model.mjs | release/ripgrep.json | scripts/desktop-dev | scripts/package-desktop-macos | scripts/package-desktop-windows.ps1 | scripts/stage-ripgrep.mjs | scripts/patch-desktop-manifest-binding.mjs | scripts/prepare-desktop-binaries | scripts/write-desktop-bundle-manifest.mjs | scripts/verify-desktop-bundle.mjs | scripts/verify-desktop-unsigned-archive.mjs | crates/colossus-cli/* | crates/colossus-darwin-process/* | crates/colossus-sdk/* | crates/colossus-sidecar/* | crates/colossus-sidecar-protocol/*) + apps/desktop/* | apps/web/* | apps/ui/* | crates/colossus-native-dictation/* | crates/colossus-tui/* | release/dictation/* | scripts/stage-dictation-model.mjs | release/ripgrep.json | scripts/desktop-dev | scripts/package-desktop-macos | scripts/package-desktop-windows.ps1 | scripts/stage-ripgrep.mjs | scripts/patch-desktop-manifest-binding.mjs | scripts/prepare-desktop-binaries | scripts/write-desktop-bundle-manifest.mjs | scripts/verify-desktop-bundle.mjs | scripts/verify-desktop-unsigned-archive.mjs | crates/colossus-cli/* | crates/colossus-darwin-process/* | crates/colossus-sdk/* | crates/colossus-sidecar/* | crates/colossus-sidecar-protocol/*) desktop_required=true ;; esac diff --git a/scripts/ci/desktop-manifest-binding.test.mjs b/scripts/ci/desktop-manifest-binding.test.mjs index 7f3afc93f..878eb9fb0 100644 --- a/scripts/ci/desktop-manifest-binding.test.mjs +++ b/scripts/ci/desktop-manifest-binding.test.mjs @@ -25,7 +25,7 @@ const placeholder = `${prefix}${"0".repeat(64)}${suffix}`; function manifest({ targetTriple = "aarch64-apple-darwin", sidecar = "colossus-sidecar", - cli = "colossus", + cli = "colossus", ripgrep = "rg", } = {}) { return `${JSON.stringify({ @@ -199,7 +199,7 @@ test("rejects non-Mach-O executables and noncanonical manifests", () => { chmodSync(executablePath, 0o755); let result = run(executablePath, manifestPath); assert.notEqual(result.status, 0); - assert.match(result.stderr, /not a supported Mach-O or PE image/u); + assert.match(result.stderr, /not a supported Mach-O, PE, or ELF image/u); writeFileSync(executablePath, executable([placeholder]), { mode: 0o755 }); chmodSync(executablePath, 0o755); @@ -211,3 +211,29 @@ test("rejects non-Mach-O executables and noncanonical manifests", () => { rmSync(root, { recursive: true, force: true }); } }); + +test("binds a Linux ELF preview and rejects a non-executable ELF type", () => { + const { root, executablePath, manifestPath } = fixture(); + try { + writeFileSync( + manifestPath, + manifest({ targetTriple: "aarch64-unknown-linux-gnu" }), + { mode: 0o644 }, + ); + const header = Buffer.alloc(64); + header.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); + header.writeUInt16LE(3, 16); + const image = Buffer.concat([header, Buffer.from(placeholder)]); + writeFileSync(executablePath, image, { mode: 0o755 }); + chmodSync(executablePath, 0o755); + assert.equal(run(executablePath, manifestPath).status, 0); + header.writeUInt16LE(1, 16); + writeFileSync( + executablePath, + Buffer.concat([header, Buffer.from(placeholder)]), + ); + assert.notEqual(run(executablePath, manifestPath).status, 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/ci/test-contracts.sh b/scripts/ci/test-contracts.sh index a48e51ebd..76b0c5b25 100755 --- a/scripts/ci/test-contracts.sh +++ b/scripts/ci/test-contracts.sh @@ -90,6 +90,11 @@ expect_classification 'rust_required=true docs_required=false dependency_required=false sdk_required=false +desktop_required=true' apps/web/src/App.tsx +expect_classification 'rust_required=true +docs_required=false +dependency_required=false +sdk_required=false desktop_required=true' crates/colossus-darwin-process/src/lib.rs expect_classification 'rust_required=true docs_required=false diff --git a/scripts/package-desktop-linux b/scripts/package-desktop-linux new file mode 100644 index 000000000..aac2dbaaf --- /dev/null +++ b/scripts/package-desktop-linux @@ -0,0 +1,83 @@ +#!/bin/sh +set -eu +umask 077 +fail() { printf 'package-desktop-linux: %s\n' "$*" >&2; exit 1; } +[ "$(uname -s)" = Linux ] || fail "Linux packaging requires a native Linux builder" +[ "$#" -eq 0 ] || fail "usage: scripts/package-desktop-linux" +case "${COLOSSUS_DESKTOP_RELEASE_CHANNEL:-}" in + developer_preview|validation_only) ;; + *) fail "select developer_preview or validation_only explicitly" ;; +esac +[ "${COLOSSUS_DESKTOP_TEAM_ID:-}" = UNSIGNED ] || fail "set COLOSSUS_DESKTOP_TEAM_ID=UNSIGNED" +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) +repository=$(CDPATH= cd -- "$script_dir/.." && pwd -P) +target=$(rustc --print host-tuple) +case "$target" in + x86_64-unknown-linux-gnu) architecture=amd64 ;; + aarch64-unknown-linux-gnu) architecture=arm64 ;; + *) fail "unsupported native Linux target: $target" ;; +esac +desktop="$repository/apps/desktop" +[ -x "$desktop/node_modules/.bin/tauri" ] || fail "install the Desktop npm dependencies first" +version=$(node -p "JSON.parse(require('fs').readFileSync('$desktop/src-tauri/tauri.conf.json','utf8')).version") +cd "$repository" +cargo xtask desktop prepare --profile release --target "$target" +search_target=$(printf '%s' "$target" | sed 's/-gnu$/-musl/') +node scripts/stage-ripgrep.mjs --target "$search_target" --output "$desktop/src-tauri/binaries" --binary-name "rg-$target" +cd "$desktop" +"$desktop/node_modules/.bin/tauri" build --no-bundle -- --locked +cd "$repository" +native_target=${CARGO_TARGET_DIR:-$desktop/src-tauri/target} +case "$native_target" in /*) ;; *) native_target="$repository/$native_target" ;; esac +mkdir -p "$repository/dist/desktop-linux" +output=$(CDPATH= cd -- "$repository/dist/desktop-linux" && pwd -P) +stage=$(mktemp -d "$output/.package.XXXXXXXX") +trap 'rm -rf -- "$stage"' EXIT HUP INT TERM +bundle="$stage/usr/lib/colossus-desktop" +mkdir -p "$bundle" "$stage/DEBIAN" "$stage/usr/bin" "$stage/usr/share/applications" "$stage/usr/share/icons/hicolor/scalable/apps" +install -m 0755 "$native_target/release/colossus-desktop" "$bundle/colossus-desktop" +for name in colossus-sidecar colossus rg; do + install -m 0755 "$desktop/src-tauri/binaries/$name-$target" "$bundle/$name" + strip "$bundle/$name" +done +strip "$bundle/colossus-desktop" +mkdir -p "$bundle/dictation" "$bundle/ripgrep" +install -m 0644 "$desktop/src-tauri/dictation-assets/ggml-tiny.en.bin" "$desktop/src-tauri/dictation-assets/models.json" "$desktop/src-tauri/dictation-assets/LICENSE-MIT" "$bundle/dictation/" +install -m 0644 "$desktop/src-tauri/binaries/COPYING" "$desktop/src-tauri/binaries/LICENSE-MIT" "$desktop/src-tauri/binaries/UNLICENSE" "$bundle/ripgrep/" +install -m 0644 "$repository/LICENSE" "$bundle/LICENSE" +node scripts/write-desktop-bundle-manifest.mjs --target "$target" --release-channel "$COLOSSUS_DESKTOP_RELEASE_CHANNEL" --sidecar "$bundle/colossus-sidecar" --cli "$bundle/colossus" --ripgrep "$bundle/rg" --output "$bundle/colossus-bundle-manifest.json" +node scripts/patch-desktop-manifest-binding.mjs --executable "$bundle/colossus-desktop" --manifest "$bundle/colossus-bundle-manifest.json" +cat > "$stage/usr/bin/colossus-desktop" <<'LAUNCHER' +#!/bin/sh +exec /usr/lib/colossus-desktop/colossus-desktop "$@" +LAUNCHER +chmod 0755 "$stage/usr/bin/colossus-desktop" +cat > "$stage/usr/share/applications/colossus-desktop.desktop" <<'DESKTOP' +[Desktop Entry] +Type=Application +Name=Colossus Desktop +Comment=Colossus agent workspaces +Exec=colossus-desktop +Icon=colossus-desktop +Terminal=false +Categories=Development; +DESKTOP +install -m 0644 "$repository/apps/ui/assets/colossus-mark.svg" "$stage/usr/share/icons/hicolor/scalable/apps/colossus-desktop.svg" +cat > "$stage/DEBIAN/control" < +Section: devel +Priority: optional +Depends: libwebkit2gtk-4.1-0, libgtk-3-0, libayatana-appindicator3-1, libsecret-1-0, libxdo3, libssl3, libasound2 +Recommends: gnome-keyring +Description: Colossus Desktop Developer Preview + Native workspaces and explicitly enrolled cloud runtime connections. +CONTROL +find "$stage" -type d -exec chmod 0755 {} + +find "$stage" -type f ! -perm /111 -exec chmod 0644 {} + +package="$output/colossus-desktop-$version-$target.deb" +dpkg-deb --build --root-owner-group "$stage" "$package" +(cd "$output" && sha256sum "${package##*/}" > "${package##*/}.sha256") +printf 'Built Linux Desktop package: %s\n' "$package" diff --git a/scripts/patch-desktop-manifest-binding.mjs b/scripts/patch-desktop-manifest-binding.mjs index 6ef5df22d..866a27c06 100755 --- a/scripts/patch-desktop-manifest-binding.mjs +++ b/scripts/patch-desktop-manifest-binding.mjs @@ -44,13 +44,28 @@ const MACH_O_MAGICS = new Set([ ]); function isPortableExecutable(executable) { - if (executable.length < 64 || executable[0] !== 0x4d || executable[1] !== 0x5a) { + if ( + executable.length < 64 || + executable[0] !== 0x4d || + executable[1] !== 0x5a + ) { return false; } const headerOffset = executable.readUInt32LE(0x3c); return ( headerOffset <= executable.length - 4 && - executable.subarray(headerOffset, headerOffset + 4).equals(Buffer.from("PE\0\0")) + executable + .subarray(headerOffset, headerOffset + 4) + .equals(Buffer.from("PE\0\0")) + ); +} +function isElfExecutable(executable) { + return ( + executable.length >= 64 && + executable.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) && + executable[4] === 2 && + executable[5] === 1 && + [2, 3].includes(executable.readUInt16LE(16)) ); } @@ -175,9 +190,7 @@ function validateManifest(bytes) { ) || !validExecutableEntry( manifest.cli, - manifest.targetTriple.includes("-windows-") - ? "colossus.exe" - : "colossus", + manifest.targetTriple.includes("-windows-") ? "colossus.exe" : "colossus", ) || !validExecutableEntry( manifest.ripgrep, @@ -209,9 +222,10 @@ try { if ( executable.length < 4 || (!MACH_O_MAGICS.has(executable.readUInt32BE(0)) && - !isPortableExecutable(executable)) + !isPortableExecutable(executable) && + !isElfExecutable(executable)) ) { - fail("executable is not a supported Mach-O or PE image"); + fail("executable is not a supported Mach-O, PE, or ELF image"); } const bindingOffset = executable.indexOf(PLACEHOLDER_BINDING); diff --git a/scripts/test-desktop-linux-native b/scripts/test-desktop-linux-native new file mode 100644 index 000000000..8e4e03f40 --- /dev/null +++ b/scripts/test-desktop-linux-native @@ -0,0 +1,24 @@ +#!/bin/sh +# Run only inside the disposable Linux Desktop builder, never a user session. +set -eu +umask 077 +[ "$(uname -s)" = Linux ] +[ -n "${DBUS_SESSION_BUS_ADDRESS:-}" ] +native_fixture=$(mktemp -d) +trap 'rm -rf -- "$native_fixture"' EXIT HUP INT TERM +export XDG_DATA_HOME="$native_fixture/data" +mkdir -p "$XDG_DATA_HOME" +# This is a synthetic password for a new disposable keyring collection. +printf 'colossus-local-native-acceptance' | gnome-keyring-daemon --unlock --components=secrets +cargo test --locked --release -p colossus-credentials --test native_linux \ + native_secret_service_survives_process_restart -- --ignored --exact --test-threads=1 +cargo test --locked --release -p colossus-sdk --features sidecar --lib linux_credential_session +cargo test --locked --release -p colossus-sidecar --test native_lifecycle \ + verified_sidecar_bootstraps_pinned_grpc_and_closes_by_guardian_eof -- --ignored --exact +COLOSSUS_ACCEPTANCE_SIDECAR=/build/target/release/colossus-sidecar \ + cargo test --locked --release -p colossus-cloud-server --lib \ + managed_sidecar_cloud_run_isolated_and_application_exit_disconnects -- --ignored +cargo clippy --locked --release -p colossus-sdk --features sidecar --all-targets -- -D warnings +cargo test --locked --release --manifest-path apps/desktop/src-tauri/Cargo.toml \ + -p colossus-native-credential-ui --lib linux::tests::native_linux_password_entry_and_cancel \ + -- --ignored --exact --test-threads=1 diff --git a/xtask/src/checks/mod.rs b/xtask/src/checks/mod.rs index 1c4318768..4e0dc9c0c 100644 --- a/xtask/src/checks/mod.rs +++ b/xtask/src/checks/mod.rs @@ -72,6 +72,7 @@ fn component_check( sdk::check(repository, &base) } Component::Desktop => surfaces::desktop(repository), + Component::Web => surfaces::web(repository), Component::Docs => surfaces::docs(repository), Component::Dependencies => surfaces::dependencies(repository), Component::Workflows => surfaces::workflows(repository), diff --git a/xtask/src/checks/surfaces.rs b/xtask/src/checks/surfaces.rs index 99ae006c7..ed18d82f6 100644 --- a/xtask/src/checks/surfaces.rs +++ b/xtask/src/checks/surfaces.rs @@ -2,11 +2,16 @@ use std::{env, ffi::OsString}; use crate::repository::Repository; +// OpenID Connect validates provider signatures with public keys only. No RSA +// private keys are created or used; cloud certificate issuance is Ed25519. +// Keep this exact exception aligned with deny.toml and ADR 0006. +const OIDC_RSA_PRIVATE_KEY_ADVISORY: &str = "RUSTSEC-2023-0071"; + // Tantivy 0.26.1 stores `usize` keys in its lru cache, so the panicking key // destructor required by this advisory is unreachable. Upstream has merged // lru 0.18.2 for its next registry release. Keep this cargo-audit exception // exact and documented, and remove it with that upgrade. cargo-deny does not -// report this informational advisory and retains an empty ignore list. +// report this informational advisory. const TANTIVY_LRU_PANIC_SAFETY_ADVISORY: &str = "RUSTSEC-2026-0253"; pub(super) fn sidecar(repository: &Repository) -> Result<(), String> { @@ -24,6 +29,7 @@ pub(super) fn sidecar(repository: &Repository) -> Result<(), String> { } pub(super) fn desktop(repository: &Repository) -> Result<(), String> { + web(repository)?; repository .task("node") .arg("scripts/stage-dictation-model.mjs") @@ -70,6 +76,29 @@ pub(super) fn desktop(repository: &Repository) -> Result<(), String> { .run() } +pub(super) fn web(repository: &Repository) -> Result<(), String> { + repository + .task("npm") + .args([ + "ci", + "--ignore-scripts", + "--legacy-peer-deps", + "--install-links", + ]) + .current_dir("apps/web") + .run()?; + repository + .task("npm") + .args(["audit", "--audit-level=high"]) + .current_dir("apps/web") + .run()?; + repository + .task("npm") + .args(["run", "check"]) + .current_dir("apps/web") + .run() +} + pub(super) fn docs(repository: &Repository) -> Result<(), String> { repository.task("./scripts/docs-site").arg("build").run()?; repository @@ -116,6 +145,8 @@ pub(super) fn dependencies(repository: &Repository) -> Result<(), String> { "warnings", "--ignore", TANTIVY_LRU_PANIC_SAFETY_ADVISORY, + "--ignore", + OIDC_RSA_PRIVATE_KEY_ADVISORY, "--file", "Cargo.lock", ], @@ -149,6 +180,8 @@ pub(super) fn dependencies(repository: &Repository) -> Result<(), String> { "check", "-D", "warnings", + "-A", + "advisory-not-detected", "advisories", ], )?; diff --git a/xtask/src/cli.rs b/xtask/src/cli.rs index dfd5d312f..9cfa37624 100644 --- a/xtask/src/cli.rs +++ b/xtask/src/cli.rs @@ -15,6 +15,7 @@ Components: sidecar Managed Local sidecar contract tests sdk Generated API compatibility and TypeScript, Python, and Go SDKs desktop Desktop renderer audit, checks, tests, and build + web Cloud web renderer audit, checks, tests, and build docs Documentation build and verification dependencies Root, fuzz, and desktop dependency policy workflows CI classification contracts and workflow lint @@ -26,6 +27,7 @@ pub(super) enum Component { Sidecar, Sdk, Desktop, + Web, Docs, Dependencies, Workflows, @@ -38,6 +40,7 @@ impl Component { "sidecar" => Ok(Self::Sidecar), "sdk" => Ok(Self::Sdk), "desktop" => Ok(Self::Desktop), + "web" => Ok(Self::Web), "docs" => Ok(Self::Docs), "dependencies" => Ok(Self::Dependencies), "workflows" => Ok(Self::Workflows), @@ -53,6 +56,7 @@ impl fmt::Display for Component { Self::Sidecar => "sidecar", Self::Sdk => "sdk", Self::Desktop => "desktop", + Self::Web => "web", Self::Docs => "docs", Self::Dependencies => "dependencies", Self::Workflows => "workflows", diff --git a/zensical.toml b/zensical.toml index 5da6eaf65..9ae7394b6 100644 --- a/zensical.toml +++ b/zensical.toml @@ -111,6 +111,7 @@ nav = [ { "Policy and OPA" = "admin/policy-opa.md" }, { "Sandbox" = "admin/sandbox.md" }, { "Storage and worker" = "admin/storage-worker.md" }, + { "Cloud control plane" = "admin/cloud-control-plane.md" }, { "Audit, telemetry, and recovery" = "admin/audit-telemetry-recovery.md" }, { "Offline operation" = "admin/offline-airgap.md" }, { "Troubleshooting" = "admin/troubleshooting.md" }, @@ -162,10 +163,12 @@ nav = [ { "ADR 0002: Interface boundary" = "develop/adr/0002-interface-presentation-boundary.md" }, { "ADR 0004: Desktop Git inspection" = "develop/adr/0004-desktop-git-inspection.md" }, { "ADR 0005: Desktop file search and diffs" = "develop/adr/0005-desktop-file-diffs.md" }, + { "ADR 0006: Cloud control plane" = "develop/adr/0006-cloud-control-plane.md" }, ] }, { "Runtime and ports" = "develop/runtime-ports.md" }, { "OpenTelemetry implementation" = "develop/observability.md" }, { "Public API and application SDKs" = "develop/application-sdk.md" }, + { "Cloud control plane" = "develop/cloud-control-plane.md" }, { "Evaluate offline Desktop dictation" = "develop/desktop-dictation.md" }, { "State and recovery" = "develop/state-recovery.md" }, { "Security architecture" = "develop/security-architecture.md" }, From 70f65095260b98ba8c3c8eef8a66960a9d4cd6cd Mon Sep 17 00:00:00 2001 From: Alexander Rymdeko-Harvey Date: Mon, 5 Oct 2026 06:19:08 -0400 Subject: [PATCH 02/18] fix(cloud): verify local readiness and close terminal clients --- crates/colossus-connector/src/connection.rs | 39 +++++- .../src/connection/readiness_tests.rs | 112 +++++++++++++++ crates/colossus-sdk/src/native_sidecar.rs | 128 ++++++++++++++++-- docs/develop/adr/0006-cloud-control-plane.md | 6 + scripts/test-desktop-linux-native | 1 + 5 files changed, 271 insertions(+), 15 deletions(-) create mode 100644 crates/colossus-connector/src/connection/readiness_tests.rs diff --git a/crates/colossus-connector/src/connection.rs b/crates/colossus-connector/src/connection.rs index 422f12339..bf6036647 100644 --- a/crates/colossus-connector/src/connection.rs +++ b/crates/colossus-connector/src/connection.rs @@ -7,7 +7,8 @@ use colossus_cloud_protocol::{ }, }; use colossus_sdk::{ - AgentRunClient, ApiErrorCode, CancelRunRequest, GetRunRequest, WatchRunRequest, + AgentRunClient, ApiErrorCode, CancelRunRequest, GetRunRequest, ListRunsRequest, PageRequest, + WatchRunRequest, }; use futures::StreamExt; use serde::{Deserialize, Serialize}; @@ -23,6 +24,9 @@ use tonic::{ }; use zeroize::Zeroizing; +#[cfg(test)] +mod readiness_tests; + /// Non-secret persisted enrollment and local runtime identity binding. #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -184,6 +188,7 @@ impl RuntimeConnector { } } async fn connect_once(&self, status: &watch::Sender) -> Result<(), Status> { + check_local_readiness(self.runs.as_ref()).await?; let tls = ClientTlsConfig::new() .ca_certificate(Certificate::from_pem(self.config.ca_pem.clone())) .identity(Identity::from_pem( @@ -239,9 +244,9 @@ impl RuntimeConnector { loop { tokio::select! { _=heartbeat.tick()=>{ - if self.runs.is_closed() { return Err(Status::unavailable("local runtime closed")); } + check_local_readiness(self.runs.as_ref()).await?; if self.enrollment.is_some() && crate::enrollment::certificate_due(&self.config.certificate_pem).map_err(|_|Status::failed_precondition("invalid connector certificate"))? { return Ok(()); } - send(&sender,runtime_frame::Body::Heartbeat(wire::RuntimeHeartbeat{ready:!self.runs.is_closed()})).await?; + send(&sender,runtime_frame::Body::Heartbeat(wire::RuntimeHeartbeat{ready:true})).await?; }, finished=watchers.join_next(),if !watchers.is_empty()=>{ finished.ok_or_else(||Status::internal("watch lost"))?.map_err(|_|Status::internal("watch failed"))??; @@ -283,6 +288,34 @@ impl RuntimeConnector { } } } + +// An external daemon's SDK client stays open across daemon outages. Probe its +// authenticated, caller-scoped read API before advertising execution readiness. +// The one-item page is discarded locally and never forwarded to the cloud. +async fn check_local_readiness(runs: &dyn AgentRunClient) -> Result<(), Status> { + if runs.is_closed() { + return Err(Status::unavailable("local runtime closed")); + } + tokio::time::timeout( + Duration::from_secs(3), + runs.list_runs(ListRunsRequest { + session_id: None, + statuses: Vec::new(), + page: Some(PageRequest { + page_size: 1, + page_token: String::new(), + }), + include_archived: false, + }), + ) + .await + .map_err(|_| Status::deadline_exceeded("local readiness timeout"))? + .map_err(|_| Status::unavailable("local runtime unavailable"))?; + if runs.is_closed() { + return Err(Status::unavailable("local runtime closed")); + } + Ok(()) +} async fn execute(runs: &dyn AgentRunClient, command: Command) -> CloudReply { let result = match command { Command::Create { request } => match runs.create_run(*request).await { diff --git a/crates/colossus-connector/src/connection/readiness_tests.rs b/crates/colossus-connector/src/connection/readiness_tests.rs new file mode 100644 index 000000000..bd32fc361 --- /dev/null +++ b/crates/colossus-connector/src/connection/readiness_tests.rs @@ -0,0 +1,112 @@ +use super::*; +use async_trait::async_trait; +use colossus_sdk::{ + ApiError, ApiErrorReason, ApiResult, CancelRunResponse, CreateRunRequest, CreateRunResponse, + GetRunResponse, ListRunsResponse, RespondInteractionRequest, RespondInteractionResponse, + RunUpdateStream, +}; +use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; + +const HEALTHY: u8 = 0; +const UNAVAILABLE: u8 = 1; +const STALLED: u8 = 2; +const CLOSE_DURING_READ: u8 = 3; +const CLOSED: u8 = 4; + +struct LocalClient { + state: AtomicU8, + reads: AtomicUsize, +} + +impl LocalClient { + fn new(state: u8) -> Self { + Self { + state: AtomicU8::new(state), + reads: AtomicUsize::new(0), + } + } +} + +#[async_trait] +impl AgentRunClient for LocalClient { + async fn create_run(&self, _: CreateRunRequest) -> ApiResult { + unreachable!("readiness must never execute work") + } + async fn get_run(&self, _: GetRunRequest) -> ApiResult { + unreachable!("readiness must not require an existing run") + } + async fn list_runs(&self, request: ListRunsRequest) -> ApiResult { + assert_eq!( + request.page.expect("bounded readiness page").page_size, + 1, + "readiness must bound its local read" + ); + self.reads.fetch_add(1, Ordering::AcqRel); + match self.state.load(Ordering::Acquire) { + UNAVAILABLE => { + return Err(ApiError::permission_denied( + ApiErrorReason::ScopeDenied, + "private adapter detail must not reach cloud status", + )); + } + STALLED => return std::future::pending().await, + CLOSE_DURING_READ => self.state.store(CLOSED, Ordering::Release), + CLOSED => unreachable!("closed clients must not be probed"), + _ => {} + } + Ok(ListRunsResponse { + runs: Vec::new(), + page: None, + }) + } + async fn watch_run(&self, _: WatchRunRequest) -> ApiResult { + unreachable!("readiness must not open an event feed") + } + async fn cancel_run(&self, _: CancelRunRequest) -> ApiResult { + unreachable!("readiness must never cancel work") + } + async fn respond_interaction( + &self, + _: RespondInteractionRequest, + ) -> ApiResult { + unreachable!("readiness must never answer an interaction") + } + fn is_closed(&self) -> bool { + self.state.load(Ordering::Acquire) == CLOSED + } +} + +#[tokio::test] +async fn daemon_readiness_detects_outage_and_recovery_without_client_closure() { + let client = LocalClient::new(HEALTHY); + check_local_readiness(&client).await.unwrap(); + client.state.store(UNAVAILABLE, Ordering::Release); + assert!(!client.is_closed()); + let error = check_local_readiness(&client).await.unwrap_err(); + assert_eq!(error.code(), tonic::Code::Unavailable); + assert_eq!(error.message(), "local runtime unavailable"); + client.state.store(HEALTHY, Ordering::Release); + check_local_readiness(&client).await.unwrap(); + assert_eq!(client.reads.load(Ordering::Acquire), 3); +} + +#[tokio::test] +async fn daemon_readiness_bounds_a_stalled_local_transport() { + let client = LocalClient::new(STALLED); + let error = tokio::time::timeout(Duration::from_secs(4), check_local_readiness(&client)) + .await + .expect("stalled local transport must return to bounded reconnect") + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::DeadlineExceeded); + assert_eq!(error.message(), "local readiness timeout"); +} + +#[tokio::test] +async fn daemon_readiness_rejects_shutdown_before_and_during_a_probe() { + let closed = LocalClient::new(CLOSED); + assert!(check_local_readiness(&closed).await.is_err()); + assert_eq!(closed.reads.load(Ordering::Acquire), 0); + let closing = LocalClient::new(CLOSE_DURING_READ); + assert!(check_local_readiness(&closing).await.is_err()); + assert!(closing.is_closed()); +} diff --git a/crates/colossus-sdk/src/native_sidecar.rs b/crates/colossus-sdk/src/native_sidecar.rs index e3906c3f7..91181a2d1 100644 --- a/crates/colossus-sdk/src/native_sidecar.rs +++ b/crates/colossus-sdk/src/native_sidecar.rs @@ -1053,12 +1053,7 @@ impl Backend for ManagedSidecarBackend { // managed tree even while the aborted supervisor is still being joined. let running = self.state.process.lock().await.take(); self.state.closing.store(true, Ordering::Release); - if let Some(client) = &self.state.connector_runs { - client.mark_closed(); - client.release().await; - } - self.agent_runs.mark_closed(); - self.agent_runs.release().await; + close_run_clients(&self.agent_runs, self.state.connector_runs.as_deref()).await; if let Some(artifacts) = &self.artifacts { artifacts.release().await; } @@ -1174,7 +1169,7 @@ impl Drop for ManagedSidecarBackend { .status .send_replace(NativeSidecarStatus::Stopping); self.state.closing.store(true, Ordering::Release); - self.agent_runs.mark_closed(); + mark_run_clients_closed(&self.agent_runs, self.state.connector_runs.as_deref()); if let Ok(mut monitor) = self.state.monitor.lock() && let Some(task) = monitor.take() { @@ -1194,6 +1189,27 @@ struct SwitchingAgentRunClient { closed: watch::Sender, } +fn mark_run_clients_closed( + primary: &SwitchingAgentRunClient, + connector: Option<&SwitchingAgentRunClient>, +) { + primary.mark_closed(); + if let Some(connector) = connector { + connector.mark_closed(); + } +} + +async fn close_run_clients( + primary: &SwitchingAgentRunClient, + connector: Option<&SwitchingAgentRunClient>, +) { + mark_run_clients_closed(primary, connector); + primary.release().await; + if let Some(connector) = connector { + connector.release().await; + } +} + impl SwitchingAgentRunClient { fn new(initial: AgentRunTransports) -> Self { let (closed, _) = watch::channel(false); @@ -1204,12 +1220,21 @@ impl SwitchingAgentRunClient { } async fn current(&self) -> ApiResult { + if self.is_closed() { + return Err(sidecar_closed_error()); + } let current = self.current.read().await; + if self.is_closed() { + return Err(sidecar_closed_error()); + } current.clone().ok_or_else(sidecar_closed_error) } async fn replace(&self, next: AgentRunTransports) { - *self.current.write().await = Some(next); + let mut current = self.current.write().await; + if !self.is_closed() { + *current = Some(next); + } } async fn release(&self) { @@ -1218,6 +1243,11 @@ impl SwitchingAgentRunClient { fn mark_closed(&self) { self.closed.send_replace(true); + // Drop cannot await, but normally owns no competing transport read. If + // a read is in flight, the closed signal still rejects every later call. + if let Ok(mut current) = self.current.try_write() { + current.take(); + } } } @@ -1401,8 +1431,7 @@ async fn supervise(state: Arc) { exited.guardian.take(); if exited.force_kill_and_cleanup().is_err() { exited.close_transports().await; - state.agent_runs.mark_closed(); - state.agent_runs.release().await; + close_run_clients(&state.agent_runs, state.connector_runs.as_deref()).await; if let Some(artifacts) = &state.artifacts { artifacts.release().await; } @@ -1467,8 +1496,7 @@ async fn supervise(state: Arc) { // During a recoverable crash, the transport must fail naturally with a // retryable UNAVAILABLE so durable watches reopen against the replacement. exited.close_transports().await; - state.agent_runs.mark_closed(); - state.agent_runs.release().await; + close_run_clients(&state.agent_runs, state.connector_runs.as_deref()).await; if let Some(artifacts) = &state.artifacts { artifacts.release().await; } @@ -3144,6 +3172,82 @@ mod tests { assert!(client.is_closed()); } + #[tokio::test] + async fn terminal_shutdown_closes_both_run_clients_and_rejects_late_replacement() { + let primary_transport = Arc::new(UnusedAgentRuns); + let connector_transport = Arc::new(UnusedAgentRuns); + let primary_weak = Arc::downgrade(&primary_transport); + let connector_weak = Arc::downgrade(&connector_transport); + let primary = SwitchingAgentRunClient::new(AgentRunTransports { + primary: primary_transport, + approval_broker: None, + }); + let connector = SwitchingAgentRunClient::new(AgentRunTransports { + primary: connector_transport, + approval_broker: None, + }); + + close_run_clients(&primary, Some(&connector)).await; + + assert!(primary_weak.upgrade().is_none()); + assert!(connector_weak.upgrade().is_none()); + for client in [&primary, &connector] { + timeout(Duration::from_millis(100), client.wait_closed()) + .await + .expect("permanent shutdown must wake both clients"); + let replacement = Arc::new(UnusedAgentRuns); + let replacement_weak = Arc::downgrade(&replacement); + client + .replace(AgentRunTransports { + primary: replacement, + approval_broker: None, + }) + .await; + assert!(replacement_weak.upgrade().is_none()); + assert!(client.is_closed()); + assert_eq!( + client.current().await.err().expect("closed client").code, + ApiErrorCode::Unavailable + ); + } + } + + #[tokio::test] + async fn drop_shutdown_signals_both_clients_even_when_transports_are_in_use() { + let primary = SwitchingAgentRunClient::new(AgentRunTransports { + primary: Arc::new(UnusedAgentRuns), + approval_broker: None, + }); + let connector = SwitchingAgentRunClient::new(AgentRunTransports { + primary: Arc::new(UnusedAgentRuns), + approval_broker: None, + }); + // Drop cannot await these locks. Its closed signal must still prevent + // callers from acquiring a surviving transport after permanent shutdown. + let primary_guard = primary.current.write().await; + let connector_guard = connector.current.write().await; + mark_run_clients_closed(&primary, Some(&connector)); + + for client in [&primary, &connector] { + timeout(Duration::from_millis(100), client.wait_closed()) + .await + .expect("Drop must wake both clients despite lock contention"); + let result = timeout(Duration::from_millis(100), client.current()) + .await + .expect("closed client must not wait for a transport lock"); + assert_eq!( + result.err().expect("closed client").code, + ApiErrorCode::Unavailable + ); + } + + drop(primary_guard); + drop(connector_guard); + close_run_clients(&primary, Some(&connector)).await; + assert!(primary.current.read().await.is_none()); + assert!(connector.current.read().await.is_none()); + } + #[tokio::test] async fn explicit_close_releases_switching_transport_owners() { let primary = Arc::new(UnusedAgentRuns); diff --git a/docs/develop/adr/0006-cloud-control-plane.md b/docs/develop/adr/0006-cloud-control-plane.md index 365390100..1948cc344 100644 --- a/docs/develop/adr/0006-cloud-control-plane.md +++ b/docs/develop/adr/0006-cloud-control-plane.md @@ -68,6 +68,12 @@ first welcome have separate ten-second deadlines; an established stream remains under heartbeat and backpressure bounds. An authenticated peer withholding HTTP/2 progress must return the same connector to bounded retry without changing run identity. +Before connecting and every five-second heartbeat, the connector probes the local +runtime's authenticated caller-scoped read API with a three-second deadline and a +one-item page. It discards the page locally. An open SDK client alone does not establish +daemon readiness: unavailable or stalled local service closes the cloud stream and +returns to bounded reconnect, retaining the same enrollment and accepted work. + Desktop-managed sidecars stop with Desktop. Persistent work uses an independently supervised daemon and connector under one explicitly chosen OS-user identity. Windows uses protected discovery and native credentials; macOS uses Keychain; graphical Linux diff --git a/scripts/test-desktop-linux-native b/scripts/test-desktop-linux-native index 8e4e03f40..d648234a2 100644 --- a/scripts/test-desktop-linux-native +++ b/scripts/test-desktop-linux-native @@ -13,6 +13,7 @@ printf 'colossus-local-native-acceptance' | gnome-keyring-daemon --unlock --comp cargo test --locked --release -p colossus-credentials --test native_linux \ native_secret_service_survives_process_restart -- --ignored --exact --test-threads=1 cargo test --locked --release -p colossus-sdk --features sidecar --lib linux_credential_session +cargo test --locked --release -p colossus-sdk --features sidecar --lib native_sidecar::tests:: cargo test --locked --release -p colossus-sidecar --test native_lifecycle \ verified_sidecar_bootstraps_pinned_grpc_and_closes_by_guardian_eof -- --ignored --exact COLOSSUS_ACCEPTANCE_SIDECAR=/build/target/release/colossus-sidecar \ From 6c24cb0d2aef8fad4d6b9a298dd57c8286d10889 Mon Sep 17 00:00:00 2001 From: Alexander Rymdeko-Harvey Date: Mon, 5 Oct 2026 08:01:57 -0400 Subject: [PATCH 03/18] test(cloud): avoid keychain prompts during native cleanup --- .../src/auth/tests/transport/managed.rs | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/colossus-cloud-server/src/auth/tests/transport/managed.rs b/crates/colossus-cloud-server/src/auth/tests/transport/managed.rs index 9133295c2..c925aef3c 100644 --- a/crates/colossus-cloud-server/src/auth/tests/transport/managed.rs +++ b/crates/colossus-cloud-server/src/auth/tests/transport/managed.rs @@ -15,15 +15,37 @@ impl Drop for NativeKeys { format!("signing-key:checkpoint-{}", self.0), format!("journal-anchor:journal-{}", self.0), ] { - if let Ok(entry) = - keyring::Entry::new("com.obscuritylabs.colossus.managed-runtime", &account) - { - let _ = entry.delete_credential(); - } + delete_native_test_key(&account); } } } +#[cfg(target_os = "macos")] +fn delete_native_test_key(account: &str) { + // The keyring delete implementation first reads the secret, which can open + // an access prompt for keys created by the sidecar. Delete only this test's + // UUID-scoped metadata, matching native_lifecycle's macOS cleanup. + let _ = std::process::Command::new("/usr/bin/security") + .env_clear() + .args([ + "delete-generic-password", + "-s", + "com.obscuritylabs.colossus.managed-runtime", + "-a", + account, + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); +} + +#[cfg(not(target_os = "macos"))] +fn delete_native_test_key(account: &str) { + if let Ok(entry) = keyring::Entry::new("com.obscuritylabs.colossus.managed-runtime", account) { + let _ = entry.delete_credential(); + } +} + fn digest(path: &Path) -> [u8; 32] { let mut file = File::open(path).unwrap(); let mut hash = Sha256::new(); From c46b16bbe4635fe010b1fce63195287ae20a544c Mon Sep 17 00:00:00 2001 From: Alexander Rymdeko-Harvey Date: Mon, 5 Oct 2026 08:57:22 -0400 Subject: [PATCH 04/18] fix(cloud): scale connection UI with shared typography --- .../src/components/cloud-connection.css | 57 +++-- .../src/dev/cloud-connection-preview.tsx | 7 +- apps/ui/styles/theme.css | 3 + apps/web/src/style.css | 223 +++++++++--------- 4 files changed, 160 insertions(+), 130 deletions(-) diff --git a/apps/desktop/src/components/cloud-connection.css b/apps/desktop/src/components/cloud-connection.css index 93faafd15..27761ec32 100644 --- a/apps/desktop/src/components/cloud-connection.css +++ b/apps/desktop/src/components/cloud-connection.css @@ -1,7 +1,10 @@ .cloud-settings { - max-width: 720px; - padding: 16px 8px; + max-width: var(--settings-content-max); + padding: var(--settings-section-gap) 0; color: var(--text); + font-family: var(--font-sans); + font-size: var(--font-size-body); + line-height: var(--line-height-body); } .cloud-settings header { display: flex; @@ -12,39 +15,44 @@ .cloud-settings-icon { display: flex; padding: 13px; - border-radius: 12px; + border-radius: var(--radius-lg); border: 1px solid var(--border); background: var(--surface-raised); color: var(--blue); } .cloud-settings h2 { - font-size: 20px; + font-size: var(--font-size-heading); + line-height: var(--line-height-tight); margin: 0 0 7px; } .cloud-settings header p { - font-size: 12px; + font-size: var(--font-size-body-sm); color: var(--muted); margin: 0; } .cloud-settings-state { display: flex; + flex-wrap: wrap; align-items: center; gap: 9px; padding: 15px 18px; background: var(--surface-muted); border: 1px solid var(--border); - border-radius: 9px; - font-size: 12px; + border-radius: var(--radius); + font-size: var(--font-size-body-sm); } .cloud-settings-state > span:last-child:not(.cloud-state-dot) { margin-left: auto; color: var(--muted); + overflow-wrap: anywhere; + min-width: 0; } .cloud-state-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); + flex-shrink: 0; } .cloud-state-dot.connected { background: var(--green); @@ -54,7 +62,7 @@ grid-template-columns: 130px minmax(0, 1fr); gap: 14px; margin: 22px 0; - font-size: 12px; + font-size: var(--font-size-body-sm); } .cloud-settings dt { color: var(--muted); @@ -63,7 +71,7 @@ margin: 0; overflow-wrap: anywhere; font-family: var(--font-mono); - font-size: 11px; + font-size: var(--font-size-caption); } .cloud-settings-actions { display: flex; @@ -71,8 +79,8 @@ flex-wrap: wrap; } .cloud-settings-note { - font-size: 11px; - line-height: 1.7; + font-size: var(--font-size-caption); + line-height: var(--line-height-body); color: var(--muted); margin: 18px 0; } @@ -84,16 +92,20 @@ .cloud-settings label { display: grid; gap: 8px; - font-size: 12px; + font-size: var(--font-size-control); + font-weight: 500; } .cloud-settings input { background: var(--surface-control); border: 1px solid var(--border); - border-radius: 7px; + border-radius: var(--radius-sm); color: var(--text); padding: 11px 12px; width: 100%; font: inherit; + font-weight: 400; + min-height: var(--control-height); + min-width: 0; } .cloud-settings input:focus-visible { outline: 2px solid var(--blue); @@ -107,7 +119,7 @@ gap: 11px; padding: 17px 18px; border: 1px solid var(--border); - border-radius: 9px; + border-radius: var(--radius); background: var(--surface-muted); color: var(--muted); margin-top: 26px; @@ -117,8 +129,8 @@ margin-top: 2px; } .cloud-settings aside p { - font-size: 11px; - line-height: 1.8; + font-size: var(--font-size-caption); + line-height: var(--line-height-body); margin: 0; } .cloud-settings-error { @@ -126,6 +138,15 @@ border: 1px solid var(--red-border); background: var(--red-soft); padding: 13px; - border-radius: 8px; - font-size: 12px; + border-radius: var(--radius-sm); + font-size: var(--font-size-body-sm); +} +@media (max-width: 520px) { + .cloud-settings dl { + grid-template-columns: minmax(0, 1fr); + gap: 6px; + } + .cloud-settings dd + dt { + margin-top: 8px; + } } diff --git a/apps/desktop/src/dev/cloud-connection-preview.tsx b/apps/desktop/src/dev/cloud-connection-preview.tsx index 942a8e689..5df866846 100644 --- a/apps/desktop/src/dev/cloud-connection-preview.tsx +++ b/apps/desktop/src/dev/cloud-connection-preview.tsx @@ -50,7 +50,12 @@ export default function CloudPreview() { } return (
-

+

Desktop visual preview · Native enrollment is tested separately.

diff --git a/apps/ui/styles/theme.css b/apps/ui/styles/theme.css index c9f4fbd13..f4b158de6 100644 --- a/apps/ui/styles/theme.css +++ b/apps/ui/styles/theme.css @@ -15,6 +15,7 @@ Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + --font-size-overline: 0.6875rem; --font-size-caption: 0.75rem; --font-size-body-sm: 0.8125rem; --font-size-body: 0.875rem; @@ -22,6 +23,8 @@ --font-size-heading-sm: 0.9375rem; --font-size-heading: 1.125rem; --font-size-title: 1.5rem; + --font-size-page-title: 1.875rem; + --font-size-display: 2.75rem; --line-height-tight: 1.25; --line-height-body: 1.5; diff --git a/apps/web/src/style.css b/apps/web/src/style.css index a1be1d8c4..7c6896c8d 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -6,8 +6,8 @@ body { background: var(--canvas); color: var(--text); font-family: var(--font-sans); - font-size: 14px; - line-height: 1.5; + font-size: var(--font-size-body); + line-height: var(--line-height-body); } button, input, @@ -22,8 +22,8 @@ button { border: 1px solid transparent; background: var(--blue); color: var(--text-on-accent); - border-radius: 8px; - min-height: 38px; + border-radius: var(--radius-sm); + min-height: var(--control-height); padding: 8px 14px; display: inline-flex; align-items: center; @@ -67,7 +67,7 @@ button.danger { color: var(--red); } button.small { - font-size: 12px; + font-size: var(--font-size-caption); min-height: 32px; padding: 5px 10px; } @@ -76,10 +76,11 @@ button.small { } input, textarea { + font-weight: 400; color: var(--text); background: var(--surface-muted); border: 1px solid var(--border); - border-radius: 7px; + border-radius: var(--radius-sm); padding: 10px 12px; width: 100%; min-width: 0; @@ -110,20 +111,20 @@ p { margin: 0; } h1 { - font-size: 30px; + font-size: var(--font-size-page-title); letter-spacing: -1px; - line-height: 1.2; + line-height: var(--line-height-tight); font-weight: 600; color: var(--text-strong); } h2 { - font-size: 18px; + font-size: var(--font-size-heading); font-weight: 600; letter-spacing: -0.3px; color: var(--text-strong); } h3 { - font-size: 14px; + font-size: var(--font-size-body); font-weight: 600; } .muted { @@ -132,7 +133,7 @@ h3 { .mono, code { font-family: var(--font-mono); - font-size: 12px; + font-size: var(--font-size-caption); } .truncate { overflow: hidden; @@ -141,7 +142,7 @@ code { } .eyebrow { color: var(--muted); - font-size: 10px; + font-size: var(--font-size-overline); font-weight: 600; letter-spacing: 1.25px; } @@ -176,12 +177,12 @@ code { white-space: nowrap; } .brand strong { - font-size: 20px; + font-size: var(--font-size-heading); font-weight: 650; letter-spacing: -0.7px; } .brand > span { - font-size: 8px; + font-size: var(--font-size-overline); letter-spacing: 1.3px; color: var(--muted); border: 1px solid var(--border-strong); @@ -217,7 +218,7 @@ code { } .nav-count { margin-left: auto; - font-size: 11px; + font-size: var(--font-size-caption); color: var(--muted); } .rail-bottom { @@ -228,7 +229,7 @@ code { gap: 10px; padding: 20px 9px; color: var(--muted); - font-size: 11px; + font-size: var(--font-size-caption); border-top: 1px solid var(--border); } .authority-note > svg { @@ -236,13 +237,13 @@ code { margin-top: 1px; } .authority-note strong { - font-size: 11px; + font-size: var(--font-size-caption); color: var(--text); font-weight: 500; } .authority-note p { margin-top: 4px; - line-height: 1.6; + line-height: var(--line-height-body); } .rail-footer { display: flex; @@ -265,7 +266,7 @@ code { align-items: center; justify-content: space-between; background: var(--canvas); - font-size: 12px; + font-size: var(--font-size-caption); } .topbar strong { font-weight: 500; @@ -279,7 +280,7 @@ code { align-items: center; gap: 8px; color: var(--muted); - font-size: 11px; + font-size: var(--font-size-caption); } .dot { display: inline-block; @@ -312,7 +313,7 @@ code { } .section-heading p { color: var(--muted); - font-size: 13px; + font-size: var(--font-size-body-sm); margin-top: 10px; } .section-heading > button { @@ -322,7 +323,7 @@ code { display: grid; grid-template-columns: 1fr 1fr 1fr; border: 1px solid var(--border); - border-radius: 10px; + border-radius: var(--radius); overflow: hidden; margin-bottom: 24px; background: var(--surface-muted); @@ -342,28 +343,28 @@ code { margin-top: 4px; } .stats-strip span { - font-size: 11px; + font-size: var(--font-size-caption); display: grid; gap: 4px; } .stats-strip strong { - font-size: 26px; - line-height: 1.3; + font-size: var(--font-size-title); + line-height: var(--line-height-body); color: var(--text-strong); font-weight: 500; } .stats-strip small { - font-size: 15px; + font-size: var(--font-size-heading-sm); font-weight: 400; color: var(--muted); } .stats-strip .stat-text { - font-size: 16px; + font-size: var(--font-size-heading); padding-top: 7px; } .new-task-panel { border: 1px solid var(--border-strong); - border-radius: 12px; + border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; margin-bottom: 38px; @@ -378,7 +379,7 @@ code { background: transparent; padding: 22px 24px; min-height: 100px; - font-size: 14px; + font-size: var(--font-size-body); resize: none; } .composer-controls { @@ -393,23 +394,23 @@ code { max-width: 230px; } .composer-controls .dropdown-select-trigger { - font-size: 11px; + font-size: var(--font-size-caption); } .composer-hint { margin-left: auto; color: var(--faint); - font-size: 10px; + font-size: var(--font-size-overline); white-space: nowrap; } .composer-controls > button { - font-size: 12px; + font-size: var(--font-size-caption); min-height: 34px; padding: 6px 11px; flex-shrink: 0; } .tasks-panel { border: 1px solid var(--border); - border-radius: 10px; + border-radius: var(--radius); background: var(--surface-muted); overflow: hidden; } @@ -425,7 +426,7 @@ code { gap: 4px; } .filter-group > button { - font-size: 12px; + font-size: var(--font-size-caption); min-height: 30px; padding: 4px 11px; background: transparent; @@ -448,7 +449,7 @@ code { border: 0; background: transparent; padding: 5px; - font-size: 12px; + font-size: var(--font-size-caption); } .search input:focus { outline-offset: 0; @@ -465,7 +466,7 @@ code { .table-labels { padding: 14px 22px; color: var(--faint); - font-size: 9px; + font-size: var(--font-size-overline); letter-spacing: 1px; border-bottom: 1px solid var(--border); } @@ -478,7 +479,7 @@ code { border-bottom: 1px solid var(--border); text-align: left; font-weight: 400; - font-size: 12px; + font-size: var(--font-size-caption); } .task-row:last-child { border: 0; @@ -491,7 +492,7 @@ code { min-width: 0; } .task-title strong { - font-size: 13px; + font-size: var(--font-size-body-sm); font-weight: 500; white-space: nowrap; overflow: hidden; @@ -499,7 +500,7 @@ code { display: block; } .task-title > span { - font-size: 10px; + font-size: var(--font-size-overline); color: var(--faint); display: block; margin-top: 5px; @@ -514,7 +515,7 @@ code { color: var(--muted); } .task-row time { - font-size: 11px; + font-size: var(--font-size-caption); color: var(--muted); } .task-row > svg { @@ -527,7 +528,7 @@ code { justify-self: start; border: 1px solid var(--border); border-radius: 5px; - font-size: 10px; + font-size: var(--font-size-overline); padding: 3px 7px; color: var(--muted); background: var(--surface); @@ -564,7 +565,7 @@ code { } .empty-icon { border: 1px solid var(--border); - border-radius: 12px; + border-radius: var(--radius-lg); background: var(--surface); padding: 15px; display: flex; @@ -573,17 +574,17 @@ code { color: var(--muted); } .empty-state h2 { - font-size: 17px; + font-size: var(--font-size-heading); } .empty-state p { max-width: 390px; color: var(--muted); - font-size: 12px; - line-height: 1.8; + font-size: var(--font-size-caption); + line-height: var(--line-height-body); } .empty-state button { margin-top: 12px; - font-size: 12px; + font-size: var(--font-size-caption); } .empty-state.compact { padding: 35px; @@ -593,7 +594,7 @@ code { justify-content: space-between; padding: 14px 42px; border-top: 1px solid var(--border); - font-size: 9px; + font-size: var(--font-size-overline); letter-spacing: 0.25px; color: var(--muted); } @@ -607,8 +608,8 @@ code { background: color-mix(in srgb, var(--red) 7%, var(--surface)); padding: 13px 16px; color: var(--text); - border-radius: 8px; - font-size: 12px; + border-radius: var(--radius-sm); + font-size: var(--font-size-caption); display: flex; align-items: center; justify-content: space-between; @@ -618,7 +619,7 @@ code { .alert button { background: transparent; color: var(--muted); - font-size: 19px; + font-size: var(--font-size-heading); padding: 0; min-height: 20px; } @@ -629,7 +630,7 @@ code { } .node-card { border: 1px solid var(--border); - border-radius: 12px; + border-radius: var(--radius-lg); background: var(--surface-muted); padding: 23px; } @@ -642,7 +643,7 @@ code { .node-icon { background: var(--surface-raised); border: 1px solid var(--border); - border-radius: 9px; + border-radius: var(--radius); width: 44px; height: 44px; display: flex; @@ -651,7 +652,7 @@ code { color: var(--muted); } .node-id { - font-size: 10px; + font-size: var(--font-size-overline); margin-top: 7px; overflow: hidden; text-overflow: ellipsis; @@ -665,7 +666,7 @@ dl { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 12px 10px; - font-size: 11px; + font-size: var(--font-size-caption); margin: 0; } dt { @@ -682,12 +683,12 @@ dd { padding: 2px 6px; display: inline-block; margin-right: 3px; - font-size: 10px; + font-size: var(--font-size-overline); } .node-capabilities { margin-top: 20px; color: var(--muted); - font-size: 11px; + font-size: var(--font-size-caption); } .node-capabilities summary { cursor: pointer; @@ -699,7 +700,7 @@ dd { } .node-capabilities li { margin: 7px 0; - font-size: 10px; + font-size: var(--font-size-overline); overflow-wrap: anywhere; } .node-card footer { @@ -709,13 +710,13 @@ dd { border-top: 1px solid var(--border); margin-top: 18px; padding-top: 12px; - font-size: 10px; + font-size: var(--font-size-overline); } .enrollment-dialog { color: var(--text); background: var(--surface); border: 1px solid var(--border-strong); - border-radius: 14px; + border-radius: var(--radius-lg); width: min(510px, calc(100vw - 32px)); padding: 28px; max-height: calc(100vh - 40px); @@ -733,22 +734,22 @@ dd { margin-bottom: 20px; } .enrollment-dialog h2 { - font-size: 22px; + font-size: var(--font-size-title); margin-bottom: 8px; } .enrollment-dialog > p { - font-size: 12px; + font-size: var(--font-size-caption); margin-bottom: 22px; } .enrollment-dialog label { - font-size: 12px; + font-size: var(--font-size-caption); font-weight: 500; display: grid; gap: 7px; margin-bottom: 18px; } .enrollment-dialog label small { - font-size: 11px; + font-size: var(--font-size-caption); font-weight: 400; color: var(--muted); } @@ -757,31 +758,31 @@ dd { } .enrollment-instructions { border: 1px solid var(--border); - border-radius: 8px; + border-radius: var(--radius-sm); padding: 17px; margin: 20px 0; background: var(--surface-muted); } .enrollment-instructions h3 { - font-size: 12px; + font-size: var(--font-size-caption); margin-bottom: 6px; } .enrollment-instructions p { - font-size: 11px; + font-size: var(--font-size-caption); color: var(--muted); - line-height: 1.8; + line-height: var(--line-height-body); margin-bottom: 15px; } .enrollment-instructions p:last-child { margin-bottom: 0; } .enrollment-instructions code { - font-size: 10px; + font-size: var(--font-size-overline); overflow-wrap: anywhere; } .detail .back { padding-left: 0; - font-size: 12px; + font-size: var(--font-size-caption); margin-bottom: 24px; } .detail-header { @@ -791,7 +792,7 @@ dd { gap: 20px; } .detail-header h1 { - font-size: 27px; + font-size: var(--font-size-title); max-width: 850px; overflow-wrap: anywhere; margin-top: 12px; @@ -800,14 +801,14 @@ dd { letter-spacing: 0.5px; } .detail-header .status { - font-size: 11px; + font-size: var(--font-size-caption); } .detail-meta { display: flex; align-items: center; gap: 20px; color: var(--muted); - font-size: 11px; + font-size: var(--font-size-caption); padding: 22px 0 30px; } .detail-meta > span:first-child { @@ -820,7 +821,7 @@ dd { } .output-panel { border: 1px solid var(--border); - border-radius: 11px; + border-radius: var(--radius); background: var(--surface-muted); overflow: hidden; } @@ -833,16 +834,16 @@ dd { color: var(--muted); } .panel-heading h2 { - font-size: 13px; + font-size: var(--font-size-body-sm); } .panel-heading > span { - font-size: 10px; + font-size: var(--font-size-overline); margin-left: auto; } .run-output { font-family: var(--font-sans); - font-size: 14px; - line-height: 1.8; + font-size: var(--font-size-body); + line-height: var(--line-height-body); white-space: pre-wrap; overflow-wrap: anywhere; padding: 30px; @@ -859,13 +860,13 @@ dd { color: var(--text); } .output-empty p { - font-size: 12px; + font-size: var(--font-size-caption); max-width: 480px; margin: auto; } .event-log { border-top: 1px solid var(--border); - font-size: 11px; + font-size: var(--font-size-caption); } .event-log summary { padding: 15px 22px; @@ -896,12 +897,12 @@ dd { } .event-log time { margin-left: auto; - font-size: 10px; + font-size: var(--font-size-overline); } .interaction { background: color-mix(in srgb, var(--amber) 4%, var(--surface)); border: 1px solid color-mix(in srgb, var(--amber) 35%, var(--border)); - border-radius: 10px; + border-radius: var(--radius); padding: 23px; margin-bottom: 22px; } @@ -913,8 +914,8 @@ dd { margin-bottom: 13px; } .interaction p { - font-size: 12px; - line-height: 1.7; + font-size: var(--font-size-caption); + line-height: var(--line-height-body); margin-bottom: 17px; } .interaction dl { @@ -939,7 +940,7 @@ dd { } .interaction .muted { margin: 13px 0 0; - font-size: 11px; + font-size: var(--font-size-caption); } .startup { display: flex; @@ -949,7 +950,7 @@ dd { gap: 18px; min-height: 100vh; color: var(--muted); - font-size: 12px; + font-size: var(--font-size-caption); } .sign-in { display: grid; @@ -1000,7 +1001,7 @@ dd { position: absolute; border: 1px solid var(--border-strong); background: var(--surface); - border-radius: 13px; + border-radius: var(--radius-lg); width: 54px; height: 54px; display: flex; @@ -1030,18 +1031,18 @@ dd { margin-bottom: 68px; } .sign-in-copy .eyebrow { - font-size: 9px; + font-size: var(--font-size-overline); margin-bottom: 16px; } .sign-in-copy h1 { - font-size: 46px; + font-size: var(--font-size-display); letter-spacing: -2px; - line-height: 1.13; + line-height: var(--line-height-tight); margin-bottom: 22px; } .sign-in-copy > p:not(.eyebrow) { - font-size: 14px; - line-height: 1.8; + font-size: var(--font-size-body); + line-height: var(--line-height-body); color: var(--muted); max-width: 380px; } @@ -1053,8 +1054,8 @@ dd { background: var(--blue); color: var(--text-on-accent); padding: 14px 18px; - border-radius: 8px; - font-size: 13px; + border-radius: var(--radius-sm); + font-size: var(--font-size-body-sm); font-weight: 600; max-width: 380px; margin-top: 38px; @@ -1064,7 +1065,7 @@ dd { gap: 7px; align-items: center; color: var(--faint); - font-size: 10px; + font-size: var(--font-size-overline); margin-top: 20px; } .spin { @@ -1123,7 +1124,7 @@ dd { padding: 45px; } .sign-in-copy h1 { - font-size: 40px; + font-size: var(--font-size-display); } } @media (max-width: 800px) { @@ -1173,7 +1174,7 @@ dd { padding: 14px 20px; } .section-heading h1 { - font-size: 25px; + font-size: var(--font-size-title); } .section-heading > button { font-size: 0; @@ -1191,13 +1192,13 @@ dd { display: none; } .stats-strip span { - font-size: 10px; + font-size: var(--font-size-overline); } .stats-strip strong { - font-size: 22px; + font-size: var(--font-size-title); } .stats-strip .stat-text { - font-size: 13px; + font-size: var(--font-size-body-sm); } .composer-controls { flex-wrap: wrap; @@ -1219,7 +1220,7 @@ dd { display: none; } .connection-indicator { - font-size: 10px; + font-size: var(--font-size-overline); } .sign-in { grid-template-columns: 1fr; @@ -1236,10 +1237,10 @@ dd { margin-bottom: 60px; } .sign-in-copy h1 { - font-size: 42px; + font-size: var(--font-size-display); } .detail-header h1 { - font-size: 23px; + font-size: var(--font-size-title); } .detail-meta { flex-wrap: wrap; @@ -1260,13 +1261,13 @@ dd { } .filter-group > button { padding: 4px 8px; - font-size: 10px; + font-size: var(--font-size-overline); } .search { max-width: 140px; } .search input { - font-size: 10px; + font-size: var(--font-size-overline); } .detail-header { align-items: flex-start; @@ -1313,22 +1314,22 @@ dd { } .topbar { height: 50px; - font-size: 10px; + font-size: var(--font-size-overline); } .topbar .slash { margin: 0 8px; } .connection-indicator { - font-size: 9px; + font-size: var(--font-size-overline); } .content { padding: 26px 16px; } .section-heading p { - font-size: 11px; + font-size: var(--font-size-caption); } .section-heading h1 { - font-size: 24px; + font-size: var(--font-size-title); } .section-heading { margin-bottom: 24px; @@ -1338,7 +1339,7 @@ dd { margin-bottom: 18px; } .stats-strip .stat-text { - font-size: 11px; + font-size: var(--font-size-caption); } .new-task-panel .composer-input { padding: 17px; @@ -1348,7 +1349,7 @@ dd { padding: 10px; } .workspace-footer { - font-size: 8px; + font-size: var(--font-size-overline); padding: 12px 16px; } .task-row { @@ -1370,7 +1371,7 @@ dd { padding: 42px 18px; } .sign-in-copy h1 { - font-size: 38px; + font-size: var(--font-size-display); } .interaction form { flex-direction: column; From 35b451623598cf84a693d2420cdaf698964e6cba Mon Sep 17 00:00:00 2001 From: Alexander Rymdeko-Harvey Date: Tue, 6 Oct 2026 18:39:34 -0400 Subject: [PATCH 05/18] feat(control-plane): add runtime operations and shared workspaces Add persisted identity and project roles, enrolled runtime operations and shared conversation, project and workspace surfaces. Package version-bound server, documentation and VS Code artifacts with verified publication. --- .github/workflows/control-plane-image.yml | 83 + .github/workflows/documentation-candidate.yml | 123 + .github/workflows/documentation-image.yml | 112 + .github/workflows/release.yml | 130 +- CHANGELOG.md | 9 + Cargo.lock | 193 +- Cargo.toml | 6 + api/colossus/api/v1alpha1/agent_run.proto | 62 + apps/desktop/package-lock.json | 2237 ++++++- apps/desktop/package.json | 6 +- apps/desktop/src-tauri/Cargo.lock | 19 +- apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/build.rs | 3 + .../src-tauri/capabilities/main-chat.json | 3 + apps/desktop/src-tauri/src/cloud_connector.rs | 174 +- .../src-tauri/src/control_plane_profiles.rs | 178 + .../src/control_plane_profiles/tests.rs | 173 + apps/desktop/src-tauri/src/lib.rs | 8 +- .../src/components/CatalogInventory.tsx | 460 +- .../src/components/CloudConnectionPane.tsx | 153 +- .../components/ControlPlaneSettingsPane.tsx | 250 + .../src/components/ManagedSettingsPane.tsx | 7 +- .../src/components/MarkdownContent.test.ts | 4 +- .../src/components/MarkdownContent.tsx | 260 +- .../src/components/RunTimeline.test.ts | 4 +- apps/desktop/src/components/RunTimeline.tsx | 185 +- .../src/components/SessionWorkspace.test.ts | 18 +- apps/desktop/src/components/WorkComposer.tsx | 5 +- apps/desktop/src/components/WorkSidebar.tsx | 492 +- .../src/components/WorkSurface.test.ts | 3 +- apps/desktop/src/components/WorkSurface.tsx | 35 +- .../src/components/catalog-inventory.css | 432 +- .../src/dev/cloud-connection-preview.tsx | 46 +- apps/desktop/src/main.tsx | 4 + apps/desktop/src/styles.css | 240 +- apps/desktop/vite.config.ts | 20 + apps/ui/README.md | 103 +- apps/ui/assets/shadcn-LICENSE.txt | 21 + apps/ui/components.json | 21 + apps/ui/package.json | 21 +- apps/ui/src/charts.ts | 14 + apps/ui/src/components/ActivityLineChart.tsx | 173 + apps/ui/src/components/ApplicationFrame.tsx | 74 + apps/ui/src/components/CatalogInventory.tsx | 483 ++ apps/ui/src/components/ControlPlaneFrame.tsx | 131 + apps/ui/src/components/Controls.tsx | 30 + apps/ui/src/components/Conversation.tsx | 119 + .../src/components/ConversationActivity.tsx | 73 + .../src/components/ConversationComposer.tsx | 171 + apps/ui/src/components/DataTable.tsx | 405 ++ apps/ui/src/components/MarkdownContent.tsx | 321 + apps/ui/src/components/RadioGroup.tsx | 100 + apps/ui/src/components/WorkWelcome.tsx | 51 + apps/ui/src/components/WorkspaceSidebar.tsx | 156 + apps/ui/src/components/ui/badge.tsx | 35 + apps/ui/src/components/ui/button.tsx | 46 + apps/ui/src/components/ui/chart.tsx | 138 + apps/ui/src/components/ui/dropdown-menu.tsx | 64 + apps/ui/src/components/ui/input.tsx | 12 + apps/ui/src/components/ui/table.tsx | 57 + apps/ui/src/conversation.ts | 26 + apps/ui/src/index.ts | 30 + apps/ui/src/lib/utils.ts | 7 + apps/ui/styles/application.css | 109 + apps/ui/styles/catalog.css | 443 ++ apps/ui/styles/charts.css | 202 + apps/ui/styles/control-plane.css | 246 + apps/ui/styles/controls.css | 106 + apps/ui/styles/conversation.css | 623 ++ apps/ui/styles/data-table.css | 142 + apps/ui/styles/shadcn.css | 48 + apps/ui/styles/theme.css | 4 + apps/ui/styles/work-welcome.css | 64 + apps/ui/styles/workspace-sidebar.css | 200 + apps/ui/test/boundary.test.mjs | 18 +- apps/ui/tsconfig.json | 15 + apps/vscode/.vscodeignore | 2 + apps/vscode/package-lock.json | 3255 ++++++++++- apps/vscode/package.json | 5 +- apps/vscode/scripts/build.mjs | 47 +- apps/vscode/scripts/capture-preview.mjs | 3 +- apps/vscode/scripts/preview.mjs | 3 +- apps/vscode/src/extension.ts | 2 +- apps/vscode/test/browser/markdown.spec.ts | 12 +- apps/vscode/test/browser/sidebar.spec.ts | 39 +- apps/vscode/test/manifest.test.mjs | 26 + apps/vscode/webview/conversation.ts | 41 +- apps/vscode/webview/main.tsx | 126 +- apps/vscode/webview/markdown.ts | 123 +- apps/vscode/webview/style.css | 21 + apps/web/index.html | 2 +- apps/web/package-lock.json | 5170 ++++++++++++++--- apps/web/package.json | 3 + apps/web/src/ActivityChart.test.tsx | 72 + apps/web/src/Administration.tsx | 649 +++ apps/web/src/AgentScope.test.tsx | 211 + apps/web/src/AgentScope.tsx | 856 +++ apps/web/src/App.routes.test.tsx | 309 + apps/web/src/App.tsx | 1353 +++-- apps/web/src/Appearance.tsx | 183 + apps/web/src/DataTable.test.tsx | 296 + apps/web/src/DocumentationLink.test.tsx | 47 + apps/web/src/DocumentationLink.tsx | 50 + apps/web/src/Fleet.tsx | 512 +- apps/web/src/Home.tsx | 355 ++ apps/web/src/Interactions.tsx | 26 +- apps/web/src/Projects.test.tsx | 153 + apps/web/src/Projects.tsx | 898 +++ apps/web/src/RunComposer.tsx | 213 + apps/web/src/SignIn.tsx | 141 + apps/web/src/TaskDetail.tsx | 48 +- apps/web/src/TaskTable.tsx | 212 + apps/web/src/ThreadDetail.test.tsx | 505 ++ apps/web/src/ThreadDetail.tsx | 763 +++ apps/web/src/ThreadRunActivity.tsx | 263 + apps/web/src/Threads.tsx | 388 ++ apps/web/src/WebLink.tsx | 19 + apps/web/src/api.test.ts | 23 + apps/web/src/api.ts | 92 +- apps/web/src/control-api.test.ts | 24 + apps/web/src/control-api.ts | 195 + apps/web/src/conversation.test.ts | 159 + apps/web/src/conversation.ts | 150 + apps/web/src/main.tsx | 8 + apps/web/src/navigation.test.tsx | 156 + apps/web/src/navigation.tsx | 146 + apps/web/src/resources.test.tsx | 55 + apps/web/src/resources.ts | 82 + apps/web/src/routes.test.ts | 67 + apps/web/src/routes.ts | 147 + apps/web/src/style.css | 2759 +++++---- apps/web/vite.config.ts | 27 +- .../colossus/api/v1alpha1/agent_run.proto | 62 + .../colossus-api-runtime/src/interactions.rs | 2 +- crates/colossus-api-runtime/src/service.rs | 79 +- .../src/service/posture.rs | 142 + .../src/service/sharing.rs | 378 ++ .../colossus-api-runtime/src/service_tests.rs | 58 + .../src/service_tests/sharing.rs | 311 + crates/colossus-api/src/lib.rs | 6 + crates/colossus-api/src/posture.rs | 180 + crates/colossus-api/src/runs.rs | 46 + crates/colossus-api/src/sharing.rs | 49 + crates/colossus-cli/src/commands.rs | 3 +- crates/colossus-cli/tests/ci_contract.rs | 18 +- crates/colossus-cli/tests/release_contract.rs | 6 +- crates/colossus-cloud-postgres/Cargo.toml | 38 + crates/colossus-cloud-postgres/README.md | 170 + .../examples/audit_checkpoint.rs | 180 + .../examples/fleet_load.rs | 11 + .../examples/load/harness.rs | 526 ++ .../00000000000001_cloud_relational/down.sql | 24 + .../00000000000001_cloud_relational/up.sql | 343 ++ .../down.sql | 2 + .../up.sql | 3 + .../00000000000003_coalesced_hints/down.sql | 1 + .../00000000000003_coalesced_hints/up.sql | 10 + .../00000000000004_project_hints/down.sql | 2 + .../00000000000004_project_hints/up.sql | 10 + .../00000000000005_pending_commands/down.sql | 2 + .../00000000000005_pending_commands/up.sql | 4 + .../down.sql | 1 + .../00000000000006_thread_sync_status/up.sql | 8 + .../00000000000007_identity_projects/down.sql | 6 + .../00000000000007_identity_projects/up.sql | 94 + .../colossus-cloud-postgres/src/canonical.rs | 46 + .../colossus-cloud-postgres/src/checkpoint.rs | 299 + crates/colossus-cloud-postgres/src/config.rs | 77 + .../colossus-cloud-postgres/src/connection.rs | 129 + .../colossus-cloud-postgres/src/entities.rs | 514 ++ .../src/identity_guard.rs | 68 + crates/colossus-cloud-postgres/src/lib.rs | 31 + .../src/maintenance.rs | 105 + crates/colossus-cloud-postgres/src/metrics.rs | 36 + .../src/observability.rs | 83 + .../src/operational.rs | 422 ++ .../colossus-cloud-postgres/src/profiling.rs | 92 + crates/colossus-cloud-postgres/src/store.rs | 361 ++ crates/colossus-cloud-postgres/src/tests.rs | 732 +++ .../src/tests/identity.rs | 170 + .../colossus/cloud/v1alpha1/connector.proto | 26 + .../colossus-cloud-protocol/src/messages.rs | 93 + crates/colossus-cloud-server/Cargo.toml | 11 +- .../examples/init_local.rs | 34 +- crates/colossus-cloud-server/src/admin.rs | 488 ++ crates/colossus-cloud-server/src/auth.rs | 407 +- .../src/auth/accounts.rs | 170 + .../src/auth/bootstrap.rs | 360 ++ .../colossus-cloud-server/src/auth/local.rs | 171 + .../colossus-cloud-server/src/auth/tests.rs | 207 +- .../src/auth/tests/local.rs | 458 ++ .../src/auth/tests/markers.rs | 166 + .../src/auth/tests/transport.rs | 36 +- .../src/auth/tests/transport/managed.rs | 11 +- .../src/bin/colossus-control-plane.rs | 5 + crates/colossus-cloud-server/src/cli.rs | 68 + crates/colossus-cloud-server/src/config.rs | 208 +- .../colossus-cloud-server/src/connection.rs | 380 +- crates/colossus-cloud-server/src/http.rs | 522 +- crates/colossus-cloud-server/src/http/spa.rs | 124 + .../src/http/spa/tests.rs | 179 + crates/colossus-cloud-server/src/lib.rs | 7 + crates/colossus-cloud-server/src/main.rs | 32 +- crates/colossus-cloud-server/src/migration.rs | 574 ++ .../src/migration/tests.rs | 100 + .../src/observability.rs | 207 + crates/colossus-cloud-server/src/server.rs | 189 +- crates/colossus-cloud-server/src/settings.rs | 73 + crates/colossus-cloud/Cargo.toml | 4 +- crates/colossus-cloud/src/accounts.rs | 229 + crates/colossus-cloud/src/identity.rs | 4 +- crates/colossus-cloud/src/lib.rs | 10 +- crates/colossus-cloud/src/observability.rs | 385 ++ crates/colossus-cloud/src/repository.rs | 225 +- .../colossus-cloud/src/repository/accounts.rs | 378 ++ .../colossus-cloud/src/repository/delivery.rs | 310 +- .../src/repository/enrollment.rs | 17 +- .../colossus-cloud/src/repository/history.rs | 208 + .../src/repository/inventory.rs | 482 ++ crates/colossus-cloud/src/repository/nodes.rs | 45 +- .../colossus-cloud/src/repository/projects.rs | 172 + .../colossus-cloud/src/repository/renewal.rs | 10 +- crates/colossus-cloud/src/repository/tasks.rs | 121 +- .../colossus-cloud/src/repository/threads.rs | 814 +++ crates/colossus-cloud/src/settings.rs | 381 ++ crates/colossus-cloud/src/storage.rs | 1210 ++++ crates/colossus-cloud/src/tests.rs | 598 +- crates/colossus-cloud/src/types.rs | 150 +- crates/colossus-connector/Cargo.toml | 1 - crates/colossus-connector/src/cli.rs | 53 +- crates/colossus-connector/src/cli_control.rs | 10 + crates/colossus-connector/src/connection.rs | 157 +- .../src/connection/readiness_tests.rs | 23 + crates/colossus-connector/src/discovery.rs | 66 + crates/colossus-connector/src/enrollment.rs | 70 +- crates/colossus-connector/src/history.rs | 148 + crates/colossus-connector/src/inventory.rs | 78 + crates/colossus-connector/src/lib.rs | 11 +- crates/colossus-connector/src/main.rs | 2 +- crates/colossus-connector/src/outbound.rs | 132 + crates/colossus-grpc/src/agent_run.rs | 117 + crates/colossus-grpc/src/server.rs | 5 + crates/colossus-grpc/src/system.rs | 10 +- crates/colossus-runtime/src/diagnostics.rs | 15 + crates/colossus-sdk/Cargo.toml | 2 + crates/colossus-sdk/src/backend.rs | 75 + crates/colossus-sdk/src/grpc.rs | 121 + crates/colossus-sdk/src/lib.rs | 7 +- crates/colossus-sdk/src/native_sidecar.rs | 27 + .../src/native_sidecar_windows.rs | 15 + crates/colossus-sdk/src/sharing.rs | 32 + deploy/cloud/Dockerfile | 8 +- deploy/cloud/kubernetes/cloud.json | 23 +- deploy/cloud/kubernetes/control-plane.yaml | 49 +- deploy/cloud/kubernetes/kustomization.yaml | 1 + deploy/control-plane/Dockerfile | 32 + .../kubernetes/control-plane.json | 45 + .../kubernetes/control-plane.yaml | 93 + .../kubernetes/documentation.yaml | 63 + deploy/control-plane/kubernetes/ingress.yaml | 46 + .../kubernetes/kustomization.yaml | 20 + .../control-plane/kubernetes/namespace.yaml | 4 + deploy/documentation/.gitignore | 2 + deploy/documentation/Dockerfile | 38 + deploy/documentation/build-config.py | 60 + deploy/documentation/nginx.conf | 59 + deploy/documentation/smoke.mjs | 218 + docs/admin/cloud-control-plane.md | 403 +- docs/admin/documentation-hosting.md | 90 + docs/develop/adr/0006-cloud-control-plane.md | 52 +- docs/develop/application-sdk.md | 24 + docs/develop/architecture.md | 9 +- docs/develop/ci-cd.md | 45 +- docs/develop/cloud-control-plane.md | 191 +- docs/develop/documentation.md | 123 +- docs/develop/releasing.md | 121 +- docs/develop/toolchain-inventory.md | 2 +- scripts/ci/control-plane-assets.test.mjs | 95 + scripts/ci/documentation-candidate.mjs | 240 + scripts/ci/documentation-candidate.test.mjs | 302 + scripts/ci/publish-control-plane-image.sh | 122 + scripts/ci/publish-documentation-image.sh | 141 + scripts/ci/release-source-version.test.mjs | 6 +- scripts/ci/test-contracts.sh | 2 + scripts/ci/verify-control-plane-assets.mjs | 110 + scripts/generate-doc-redirects | 19 +- sdk/generated-inputs.sha256 | 2 +- sdk/go/README.md | 11 + .../gen/colossus/api/v1alpha1/agent_run.pb.go | 843 ++- .../api/v1alpha1/agent_run_grpc.pb.go | 144 +- sdk/go/generated-output.sha256 | 2 +- sdk/go/posture.go | 279 + sdk/go/posture_test.go | 62 + sdk/go/sharing_test.go | 40 + sdk/python/README.md | 12 + sdk/python/generated-output.sha256 | 2 +- sdk/python/src/colossus_sdk/__init__.py | 24 + sdk/python/src/colossus_sdk/client.py | 18 + sdk/python/src/colossus_sdk/posture.py | 234 + sdk/python/tests/test_posture.py | 95 + sdk/python/tests/test_sharing.py | 52 + sdk/typescript/README.md | 16 +- sdk/typescript/generated-output.sha256 | 2 +- sdk/typescript/src/index.ts | 17 +- sdk/typescript/src/posture.ts | 343 ++ sdk/typescript/test/posture.test.ts | 115 + sdk/typescript/test/sharing.test.ts | 56 + zensical.toml | 1 + 308 files changed, 49583 insertions(+), 6553 deletions(-) create mode 100644 .github/workflows/control-plane-image.yml create mode 100644 .github/workflows/documentation-candidate.yml create mode 100644 .github/workflows/documentation-image.yml create mode 100644 apps/desktop/src-tauri/src/control_plane_profiles.rs create mode 100644 apps/desktop/src-tauri/src/control_plane_profiles/tests.rs create mode 100644 apps/desktop/src/components/ControlPlaneSettingsPane.tsx create mode 100644 apps/ui/assets/shadcn-LICENSE.txt create mode 100644 apps/ui/components.json create mode 100644 apps/ui/src/charts.ts create mode 100644 apps/ui/src/components/ActivityLineChart.tsx create mode 100644 apps/ui/src/components/ApplicationFrame.tsx create mode 100644 apps/ui/src/components/CatalogInventory.tsx create mode 100644 apps/ui/src/components/ControlPlaneFrame.tsx create mode 100644 apps/ui/src/components/Controls.tsx create mode 100644 apps/ui/src/components/Conversation.tsx create mode 100644 apps/ui/src/components/ConversationActivity.tsx create mode 100644 apps/ui/src/components/ConversationComposer.tsx create mode 100644 apps/ui/src/components/DataTable.tsx create mode 100644 apps/ui/src/components/MarkdownContent.tsx create mode 100644 apps/ui/src/components/RadioGroup.tsx create mode 100644 apps/ui/src/components/WorkWelcome.tsx create mode 100644 apps/ui/src/components/WorkspaceSidebar.tsx create mode 100644 apps/ui/src/components/ui/badge.tsx create mode 100644 apps/ui/src/components/ui/button.tsx create mode 100644 apps/ui/src/components/ui/chart.tsx create mode 100644 apps/ui/src/components/ui/dropdown-menu.tsx create mode 100644 apps/ui/src/components/ui/input.tsx create mode 100644 apps/ui/src/components/ui/table.tsx create mode 100644 apps/ui/src/conversation.ts create mode 100644 apps/ui/src/lib/utils.ts create mode 100644 apps/ui/styles/application.css create mode 100644 apps/ui/styles/catalog.css create mode 100644 apps/ui/styles/charts.css create mode 100644 apps/ui/styles/control-plane.css create mode 100644 apps/ui/styles/controls.css create mode 100644 apps/ui/styles/conversation.css create mode 100644 apps/ui/styles/data-table.css create mode 100644 apps/ui/styles/shadcn.css create mode 100644 apps/ui/styles/work-welcome.css create mode 100644 apps/ui/styles/workspace-sidebar.css create mode 100644 apps/ui/tsconfig.json create mode 100644 apps/web/src/ActivityChart.test.tsx create mode 100644 apps/web/src/Administration.tsx create mode 100644 apps/web/src/AgentScope.test.tsx create mode 100644 apps/web/src/AgentScope.tsx create mode 100644 apps/web/src/App.routes.test.tsx create mode 100644 apps/web/src/Appearance.tsx create mode 100644 apps/web/src/DataTable.test.tsx create mode 100644 apps/web/src/DocumentationLink.test.tsx create mode 100644 apps/web/src/DocumentationLink.tsx create mode 100644 apps/web/src/Home.tsx create mode 100644 apps/web/src/Projects.test.tsx create mode 100644 apps/web/src/Projects.tsx create mode 100644 apps/web/src/RunComposer.tsx create mode 100644 apps/web/src/SignIn.tsx create mode 100644 apps/web/src/TaskTable.tsx create mode 100644 apps/web/src/ThreadDetail.test.tsx create mode 100644 apps/web/src/ThreadDetail.tsx create mode 100644 apps/web/src/ThreadRunActivity.tsx create mode 100644 apps/web/src/Threads.tsx create mode 100644 apps/web/src/WebLink.tsx create mode 100644 apps/web/src/control-api.test.ts create mode 100644 apps/web/src/control-api.ts create mode 100644 apps/web/src/conversation.test.ts create mode 100644 apps/web/src/conversation.ts create mode 100644 apps/web/src/navigation.test.tsx create mode 100644 apps/web/src/navigation.tsx create mode 100644 apps/web/src/resources.test.tsx create mode 100644 apps/web/src/resources.ts create mode 100644 apps/web/src/routes.test.ts create mode 100644 apps/web/src/routes.ts create mode 100644 crates/colossus-api-runtime/src/service/posture.rs create mode 100644 crates/colossus-api-runtime/src/service/sharing.rs create mode 100644 crates/colossus-api-runtime/src/service_tests/sharing.rs create mode 100644 crates/colossus-api/src/posture.rs create mode 100644 crates/colossus-api/src/sharing.rs create mode 100644 crates/colossus-cloud-postgres/Cargo.toml create mode 100644 crates/colossus-cloud-postgres/README.md create mode 100644 crates/colossus-cloud-postgres/examples/audit_checkpoint.rs create mode 100644 crates/colossus-cloud-postgres/examples/fleet_load.rs create mode 100644 crates/colossus-cloud-postgres/examples/load/harness.rs create mode 100644 crates/colossus-cloud-postgres/migrations/00000000000001_cloud_relational/down.sql create mode 100644 crates/colossus-cloud-postgres/migrations/00000000000001_cloud_relational/up.sql create mode 100644 crates/colossus-cloud-postgres/migrations/00000000000002_operational_retention/down.sql create mode 100644 crates/colossus-cloud-postgres/migrations/00000000000002_operational_retention/up.sql create mode 100644 crates/colossus-cloud-postgres/migrations/00000000000003_coalesced_hints/down.sql create mode 100644 crates/colossus-cloud-postgres/migrations/00000000000003_coalesced_hints/up.sql create mode 100644 crates/colossus-cloud-postgres/migrations/00000000000004_project_hints/down.sql create mode 100644 crates/colossus-cloud-postgres/migrations/00000000000004_project_hints/up.sql create mode 100644 crates/colossus-cloud-postgres/migrations/00000000000005_pending_commands/down.sql create mode 100644 crates/colossus-cloud-postgres/migrations/00000000000005_pending_commands/up.sql create mode 100644 crates/colossus-cloud-postgres/migrations/00000000000006_thread_sync_status/down.sql create mode 100644 crates/colossus-cloud-postgres/migrations/00000000000006_thread_sync_status/up.sql create mode 100644 crates/colossus-cloud-postgres/migrations/00000000000007_identity_projects/down.sql create mode 100644 crates/colossus-cloud-postgres/migrations/00000000000007_identity_projects/up.sql create mode 100644 crates/colossus-cloud-postgres/src/canonical.rs create mode 100644 crates/colossus-cloud-postgres/src/checkpoint.rs create mode 100644 crates/colossus-cloud-postgres/src/config.rs create mode 100644 crates/colossus-cloud-postgres/src/connection.rs create mode 100644 crates/colossus-cloud-postgres/src/entities.rs create mode 100644 crates/colossus-cloud-postgres/src/identity_guard.rs create mode 100644 crates/colossus-cloud-postgres/src/lib.rs create mode 100644 crates/colossus-cloud-postgres/src/maintenance.rs create mode 100644 crates/colossus-cloud-postgres/src/metrics.rs create mode 100644 crates/colossus-cloud-postgres/src/observability.rs create mode 100644 crates/colossus-cloud-postgres/src/operational.rs create mode 100644 crates/colossus-cloud-postgres/src/profiling.rs create mode 100644 crates/colossus-cloud-postgres/src/store.rs create mode 100644 crates/colossus-cloud-postgres/src/tests.rs create mode 100644 crates/colossus-cloud-postgres/src/tests/identity.rs create mode 100644 crates/colossus-cloud-server/src/admin.rs create mode 100644 crates/colossus-cloud-server/src/auth/accounts.rs create mode 100644 crates/colossus-cloud-server/src/auth/bootstrap.rs create mode 100644 crates/colossus-cloud-server/src/auth/local.rs create mode 100644 crates/colossus-cloud-server/src/auth/tests/local.rs create mode 100644 crates/colossus-cloud-server/src/auth/tests/markers.rs create mode 100644 crates/colossus-cloud-server/src/bin/colossus-control-plane.rs create mode 100644 crates/colossus-cloud-server/src/cli.rs create mode 100644 crates/colossus-cloud-server/src/http/spa.rs create mode 100644 crates/colossus-cloud-server/src/http/spa/tests.rs create mode 100644 crates/colossus-cloud-server/src/migration.rs create mode 100644 crates/colossus-cloud-server/src/migration/tests.rs create mode 100644 crates/colossus-cloud-server/src/observability.rs create mode 100644 crates/colossus-cloud-server/src/settings.rs create mode 100644 crates/colossus-cloud/src/accounts.rs create mode 100644 crates/colossus-cloud/src/observability.rs create mode 100644 crates/colossus-cloud/src/repository/accounts.rs create mode 100644 crates/colossus-cloud/src/repository/history.rs create mode 100644 crates/colossus-cloud/src/repository/inventory.rs create mode 100644 crates/colossus-cloud/src/repository/projects.rs create mode 100644 crates/colossus-cloud/src/repository/threads.rs create mode 100644 crates/colossus-cloud/src/settings.rs create mode 100644 crates/colossus-cloud/src/storage.rs create mode 100644 crates/colossus-connector/src/discovery.rs create mode 100644 crates/colossus-connector/src/history.rs create mode 100644 crates/colossus-connector/src/inventory.rs create mode 100644 crates/colossus-connector/src/outbound.rs create mode 100644 crates/colossus-sdk/src/sharing.rs create mode 100644 deploy/control-plane/Dockerfile create mode 100644 deploy/control-plane/kubernetes/control-plane.json create mode 100644 deploy/control-plane/kubernetes/control-plane.yaml create mode 100644 deploy/control-plane/kubernetes/documentation.yaml create mode 100644 deploy/control-plane/kubernetes/ingress.yaml create mode 100644 deploy/control-plane/kubernetes/kustomization.yaml create mode 100644 deploy/control-plane/kubernetes/namespace.yaml create mode 100644 deploy/documentation/.gitignore create mode 100644 deploy/documentation/Dockerfile create mode 100644 deploy/documentation/build-config.py create mode 100644 deploy/documentation/nginx.conf create mode 100644 deploy/documentation/smoke.mjs create mode 100644 docs/admin/documentation-hosting.md create mode 100644 scripts/ci/control-plane-assets.test.mjs create mode 100644 scripts/ci/documentation-candidate.mjs create mode 100644 scripts/ci/documentation-candidate.test.mjs create mode 100644 scripts/ci/publish-control-plane-image.sh create mode 100644 scripts/ci/publish-documentation-image.sh create mode 100644 scripts/ci/verify-control-plane-assets.mjs create mode 100644 sdk/go/posture.go create mode 100644 sdk/go/posture_test.go create mode 100644 sdk/go/sharing_test.go create mode 100644 sdk/python/src/colossus_sdk/posture.py create mode 100644 sdk/python/tests/test_posture.py create mode 100644 sdk/python/tests/test_sharing.py create mode 100644 sdk/typescript/src/posture.ts create mode 100644 sdk/typescript/test/posture.test.ts create mode 100644 sdk/typescript/test/sharing.test.ts diff --git a/.github/workflows/control-plane-image.yml b/.github/workflows/control-plane-image.yml new file mode 100644 index 000000000..5e3b76aa1 --- /dev/null +++ b/.github/workflows/control-plane-image.yml @@ -0,0 +1,83 @@ +name: Publish Control Plane image + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Published stable or preview release tag + required: true + type: string + pull_request: + paths: + - .github/workflows/control-plane-image.yml + - scripts/ci/*control-plane*.mjs + - scripts/ci/publish-control-plane-image.sh + +permissions: {} + +concurrency: + group: control-plane-image-${{ github.event.release.tag_name || inputs.tag || github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + contracts: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.18.0 + - run: node --test scripts/ci/control-plane-assets.test.mjs + - run: bash -n scripts/ci/publish-control-plane-image.sh + + publish: + name: Publish the exact tested multi-platform server + needs: contracts + if: >- + github.repository == 'obscuritylabs/Colossus' && + ((github.event_name == 'release' && github.event.release.draft == false) || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + actions: read + contents: read + packages: write + steps: + - name: Use the protected main publisher, not downloaded executable code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.18.0 + - name: Authenticate with a job-scoped registry token + env: + GHCR_TOKEN: ${{ github.token }} + GHCR_USER: ${{ github.actor }} + run: printf '%s' "$GHCR_TOKEN" | docker login ghcr.io --username "$GHCR_USER" --password-stdin + - name: Verify release bytes against trusted tag artifacts and publish + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: bash scripts/ci/publish-control-plane-image.sh + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: colossus-control-plane-publication + path: | + ${{ runner.temp }}/control-plane-image/publication.json + ${{ runner.temp }}/control-plane-image/assets.json + if-no-files-found: error + retention-days: 30 + - name: Remove the job-scoped registry session + if: always() + run: docker logout ghcr.io diff --git a/.github/workflows/documentation-candidate.yml b/.github/workflows/documentation-candidate.yml new file mode 100644 index 000000000..290c55b0d --- /dev/null +++ b/.github/workflows/documentation-candidate.yml @@ -0,0 +1,123 @@ +name: Documentation image candidate + +on: + push: + tags: ["v*"] + pull_request: + paths: + - .github/workflows/documentation-candidate.yml + - .github/workflows/documentation-image.yml + - .dockerignore + - deploy/documentation/** + - docs/** + - documentation/legacy-routes.tsv + - scripts/docs-site + - scripts/generate-doc-redirects + - scripts/ci/documentation-*.mjs + - scripts/ci/publish-documentation-image.sh + - scripts/ci/verify-control-plane-assets.mjs + - zensical.toml + +permissions: {} + +concurrency: + group: documentation-candidate-${{ github.ref }} + cancel-in-progress: false + +jobs: + source: + name: Verify documentation candidate source + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + tag: ${{ steps.source.outputs.tag }} + commit: ${{ steps.source.outputs.commit }} + epoch: ${{ steps.source.outputs.epoch }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.18.0 + - name: Bind the annotated release tag to protected main + id: source + env: + EVENT_NAME: ${{ github.event_name }} + SOURCE_TAG: ${{ github.ref_name }} + shell: bash + run: | + set -euo pipefail + commit=$(git rev-parse HEAD) + tag=v0.0.0-preview.1 + if [[ "$EVENT_NAME" == push ]]; then + tag="$SOURCE_TAG" + node --input-type=module - "$tag" "$commit" <<'JS' + import { validateRequest } from './scripts/ci/verify-control-plane-assets.mjs'; + validateRequest(process.argv[2], process.argv[3]); + JS + git fetch origin refs/heads/main:refs/remotes/origin/main "refs/tags/$tag:refs/tags/$tag" + test "$(git cat-file -t "$tag")" = tag + test "$(git rev-parse "$tag^{commit}")" = "$commit" + git merge-base --is-ancestor "$commit" origin/main + fi + printf 'tag=%s\ncommit=%s\nepoch=%s\n' "$tag" "$commit" "$(git show -s --format=%ct "$commit")" >> "$GITHUB_OUTPUT" + - run: node --test scripts/ci/documentation-candidate.test.mjs + - run: bash -n scripts/ci/publish-documentation-image.sh + + candidate: + name: Build and smoke linux/${{ matrix.arch }} documentation + needs: source + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + permissions: + contents: read + env: + SOURCE_TAG: ${{ needs.source.outputs.tag }} + SOURCE_COMMIT: ${{ needs.source.outputs.commit }} + SOURCE_DATE_EPOCH: ${{ needs.source.outputs.epoch }} + IMAGE_ARCH: ${{ matrix.arch }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.source.outputs.commit }} + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.18.0 + - name: Build exact-source documentation without registry credentials + run: | + docker build --platform "linux/$IMAGE_ARCH" \ + --build-arg "COLOSSUS_VERSION=$SOURCE_TAG" \ + --build-arg "COLOSSUS_REVISION=$SOURCE_COMMIT" \ + --build-arg "SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH" \ + --tag colossus-documentation:candidate \ + --file deploy/documentation/Dockerfile . + - name: Test browse, search, portable links, and hardened runtime + run: node deploy/documentation/smoke.mjs colossus-documentation:candidate + - name: Retain only the actual tested image and source-bound candidate + shell: bash + run: | + set -euo pipefail + candidate_output="$RUNNER_TEMP/documentation-candidate" + mkdir -p "$candidate_output" + docker save colossus-documentation:candidate | gzip -n > "$candidate_output/Colossus-Documentation-${SOURCE_TAG}-linux-${IMAGE_ARCH}.docker.tar.gz" + node scripts/ci/documentation-candidate.mjs create "$candidate_output" "$SOURCE_TAG" "$SOURCE_COMMIT" "$IMAGE_ARCH" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: github.event_name == 'push' + with: + name: colossus-documentation-${{ matrix.arch }} + path: ${{ runner.temp }}/documentation-candidate/* + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/documentation-image.yml b/.github/workflows/documentation-image.yml new file mode 100644 index 000000000..dcbcd8529 --- /dev/null +++ b/.github/workflows/documentation-image.yml @@ -0,0 +1,112 @@ +name: Publish Documentation image + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Published stable or preview release tag + required: true + type: string + pull_request: + paths: + - .github/workflows/documentation-image.yml + - scripts/ci/documentation-*.mjs + - scripts/ci/publish-documentation-image.sh + +permissions: {} + +concurrency: + group: documentation-image-${{ github.event.release.tag_name || inputs.tag || github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + publisher-revision: + name: Resolve the protected main publisher + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + revision: ${{ steps.publisher.outputs.revision }} + steps: + - name: Use proposed code for tests or protected main for publication + id: publisher + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + shell: bash + run: | + set -euo pipefail + if [[ "$EVENT_NAME" == pull_request ]]; then + revision="$GITHUB_SHA" + else + revision=$(gh api repos/obscuritylabs/Colossus/git/ref/heads/main --jq .object.sha) + fi + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + + contracts: + name: Verify immutable documentation publication contracts + needs: publisher-revision + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.publisher-revision.outputs.revision }} + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.18.0 + - run: node --test scripts/ci/documentation-candidate.test.mjs + - run: bash -n scripts/ci/publish-documentation-image.sh + + publish: + name: Publish the tested documentation platform index + needs: [publisher-revision, contracts] + if: >- + github.repository == 'obscuritylabs/Colossus' && + ((github.event_name == 'release' && github.event.release.draft == false) || + (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + actions: read + contents: read + packages: write + steps: + - name: Use the same protected publisher revision that passed contracts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.publisher-revision.outputs.revision }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.18.0 + - name: Authenticate with a job-scoped package token + env: + GHCR_TOKEN: ${{ github.token }} + GHCR_USER: ${{ github.actor }} + run: printf '%s' "$GHCR_TOKEN" | docker login ghcr.io --username "$GHCR_USER" --password-stdin + - name: Verify release provenance, retained tested candidates, and immutable publication + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: bash scripts/ci/publish-documentation-image.sh + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: colossus-documentation-publication-${{ github.event.release.tag_name || inputs.tag }} + path: | + ${{ runner.temp }}/documentation-image/publication.json + ${{ runner.temp }}/documentation-image/provenance.json + ${{ runner.temp }}/documentation-image/trusted/*.json + if-no-files-found: error + retention-days: 30 + - name: Remove the job-scoped registry session + if: always() + run: docker logout ghcr.io diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 176758d01..4d6fdb368 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1186,6 +1186,114 @@ jobs: compression-level: 0 retention-days: 14 + control_plane: + name: Control Plane ${{ matrix.target }} + needs: validate + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest-m + target: x86_64-unknown-linux-gnu + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Build the portable server and web image from pinned inputs + env: + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + SOURCE_COMMIT: ${{ needs.validate.outputs.source_commit }} + run: | + docker build --file deploy/control-plane/Dockerfile \ + --label org.opencontainers.image.source=https://github.com/obscuritylabs/Colossus \ + --label "org.opencontainers.image.revision=$SOURCE_COMMIT" \ + --label "org.opencontainers.image.version=$RELEASE_TAG" \ + --tag colossus-control-plane:release . + - name: Smoke the actual unprivileged image + run: | + test "$(docker image inspect colossus-control-plane:release --format '{{.Config.User}}')" = 10001:10001 + docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges colossus-control-plane:release --version + docker run --rm --network none --read-only --cap-drop ALL --security-opt no-new-privileges colossus-control-plane:release --help + - name: Package matching server, web, deployment, and offline Docker assets + env: + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + TARGET: ${{ matrix.target }} + SOURCE_DATE_EPOCH: ${{ needs.validate.outputs.source_date_epoch }} + run: | + set -euo pipefail + stage="$RUNNER_TEMP/control-plane-stage" + output="$RUNNER_TEMP/control-plane-release" + mkdir -p "$stage/bin" "$stage/web" "$stage/deploy" "$output" + container=$(docker create colossus-control-plane:release) + trap 'docker rm "$container" >/dev/null 2>&1 || true' EXIT + docker cp "$container:/usr/local/bin/colossus-control-plane" "$stage/bin/" + docker cp "$container:/opt/colossus/web/." "$stage/web/" + cp -R deploy/control-plane/. "$stage/deploy/" + cp LICENSE "$stage/" + cp docs/admin/cloud-control-plane.md "$stage/README.md" + archive="Colossus-Control-Plane-${RELEASE_TAG}-${TARGET}.tar.gz" + tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner -czf "$output/$archive" -C "$stage" . + image="Colossus-Control-Plane-${RELEASE_TAG}-${TARGET}.docker.tar.gz" + docker save colossus-control-plane:release | gzip -n > "$output/$image" + (cd "$output" && sha256sum "$archive" > "$archive.sha256" && sha256sum "$image" > "$image.sha256") + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: colossus-control-plane-${{ matrix.target }} + path: ${{ runner.temp }}/control-plane-release/* + if-no-files-found: error + retention-days: 30 + + vscode: + name: VSIX ${{ matrix.target }} + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + target: [linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64, win32-arm64] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.18.0 + cache: npm + cache-dependency-path: apps/vscode/package-lock.json + - name: Check and build the shared extension + run: | + cd sdk/typescript + npm ci --ignore-scripts + npm run build + cd ../../apps/vscode + npm ci --ignore-scripts --legacy-peer-deps --install-links + npm run check + npm run build + - name: Package the locked native binding for the exact VS Code target + env: + RELEASE_TAG: ${{ needs.validate.outputs.tag }} + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + cd apps/vscode + node scripts/package.mjs "$TARGET" + version=$(node -p "require('./package.json').version") + output="$RUNNER_TEMP/vsix-release" + mkdir -p "$output" + asset="Colossus-VSCode-${RELEASE_TAG}-${TARGET}.vsix" + cp "artifacts/colossus-${TARGET}-${version}.vsix" "$output/$asset" + (cd "$output" && sha256sum "$asset" > "$asset.sha256") + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: colossus-vsix-${{ matrix.target }} + path: ${{ runner.temp }}/vsix-release/* + if-no-files-found: error + gate: name: Colossus release gate if: always() @@ -1200,6 +1308,8 @@ jobs: desktop_macos, desktop_windows_preview, desktop_windows_signed, + control_plane, + vscode, ] runs-on: ubuntu-slim timeout-minutes: 5 @@ -1222,7 +1332,9 @@ jobs: set -euo pipefail ./scripts/ci/require-success.sh \ validate=${{ needs.validate.result }} \ - artifacts=${{ needs.artifacts.result }} + artifacts=${{ needs.artifacts.result }} \ + control_plane=${{ needs.control_plane.result }} \ + vscode=${{ needs.vscode.result }} if [ "$TEST_RELEASE" = true ]; then test "$TARGET_CHANNEL" = developer_preview test "$BOOTSTRAP_RESULT" = skipped @@ -1293,7 +1405,7 @@ jobs: set -euo pipefail case "$RELEASE_CHANNEL" in stable) - test "$(find dist -maxdepth 1 -type f | wc -l | tr -d ' ')" -eq 25 + test "$(find dist -maxdepth 1 -type f | wc -l | tr -d ' ')" -eq 45 windows="Colossus-Desktop-STABLE-${RELEASE_TAG}-x86_64-pc-windows-msvc-setup.exe" test -f "dist/$windows" test -f "dist/$windows.sha256" @@ -1314,9 +1426,9 @@ jobs: "$sdk_dir" "$RELEASE_VERSION" "$SOURCE_COMMIT" ;; developer_preview) - expected_assets=22 + expected_assets=42 if [ "$TEST_RELEASE" = true ]; then - expected_assets=18 + expected_assets=38 fi test "$(find dist -maxdepth 1 -type f | wc -l | tr -d ' ')" -eq "$expected_assets" desktop="Colossus-Desktop-DEVELOPER-PREVIEW-${RELEASE_TAG}-aarch64-apple-darwin.zip" @@ -1343,6 +1455,16 @@ jobs: test -f "dist/$bootstrap_asset" done fi + for target in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do + test -f "dist/Colossus-Control-Plane-${RELEASE_TAG}-${target}.tar.gz" + test -f "dist/Colossus-Control-Plane-${RELEASE_TAG}-${target}.tar.gz.sha256" + test -f "dist/Colossus-Control-Plane-${RELEASE_TAG}-${target}.docker.tar.gz" + test -f "dist/Colossus-Control-Plane-${RELEASE_TAG}-${target}.docker.tar.gz.sha256" + done + for target in linux-x64 linux-arm64 darwin-x64 darwin-arm64 win32-x64 win32-arm64; do + test -f "dist/Colossus-VSCode-${RELEASE_TAG}-${target}.vsix" + test -f "dist/Colossus-VSCode-${RELEASE_TAG}-${target}.vsix.sha256" + done for checksum in dist/*.sha256; do (cd dist && sha256sum --check --strict "$(basename "$checksum")") done diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c5a05dc..78bbc9f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ include breaking changes while the public API is still settling. ### Added +- Colossus Control Plane with independent PostgreSQL persistence, users, named OIDC + and optional local sign-in, explicit project roles/hierarchy, and administration. +- Fleet agents, workspace/thread navigation, retained realtime conversations, shared + Desktop/VS Code/web Markdown and composer components, and shadcn task tables. +- Project analytics, runtime-reported policy inspection and monitoring baselines, + configurable classification markings, and global Desktop connection profiles. +- Linux Control Plane server/web bundles and offline Docker images, six platform + VSIX packages, and verified multi-platform server container publication. + - Workspace workflow schedule management with definition import, reviewed creation, pause/enable controls, and independent run inspection. - Separate Workflows library with manual runs, schema-based inputs, owned history, and diff --git a/Cargo.lock b/Cargo.lock index 2a6a87af1..b4713078c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,6 +235,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -539,6 +551,18 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bb8" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457d7ed3f888dfd2c7af56d4975cade43c622f74bdcddfed6d4352f57acc6310" +dependencies = [ + "futures-util", + "parking_lot", + "portable-atomic", + "tokio", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -616,6 +640,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -681,7 +714,7 @@ version = "3.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" dependencies = [ - "darling", + "darling 0.23.0", "ident_case", "prettyplease", "proc-macro2", @@ -1211,9 +1244,9 @@ dependencies = [ name = "colossus-cloud" version = "0.11.7" dependencies = [ + "async-trait", "colossus-cloud-protocol", "colossus-contracts", - "colossus-journal-redb", "colossus-ports", "colossus-sdk", "hex", @@ -1222,9 +1255,39 @@ dependencies = [ "sha2 0.10.9", "tempfile", "thiserror 2.0.18", + "time", + "tokio", "uuid", ] +[[package]] +name = "colossus-cloud-postgres" +version = "0.11.7" +dependencies = [ + "async-trait", + "colossus-cloud", + "colossus-cloud-protocol", + "colossus-network", + "colossus-ports", + "colossus-sdk", + "diesel", + "diesel-async", + "ed25519-dalek", + "futures", + "hex", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "time", + "tokio", + "tokio-postgres", + "tokio-postgres-rustls", + "uuid", + "webpki-roots", + "zeroize", +] + [[package]] name = "colossus-cloud-protocol" version = "0.11.7" @@ -1244,13 +1307,16 @@ dependencies = [ name = "colossus-cloud-server" version = "0.11.7" dependencies = [ + "argon2", "async-trait", "axum", "base64 0.22.1", + "chacha20poly1305", "chrono", "colossus-api", "colossus-api-runtime", "colossus-cloud", + "colossus-cloud-postgres", "colossus-cloud-protocol", "colossus-connector", "colossus-contracts", @@ -1258,6 +1324,7 @@ dependencies = [ "colossus-home", "colossus-journal-postgres", "colossus-journal-redb", + "colossus-network", "colossus-policy", "colossus-ports", "colossus-runtime", @@ -1313,7 +1380,6 @@ version = "0.11.7" dependencies = [ "async-trait", "clap", - "colossus-cloud", "colossus-cloud-protocol", "colossus-contracts", "colossus-credentials", @@ -2559,14 +2625,38 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", ] [[package]] @@ -2582,13 +2672,24 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.118", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.118", ] @@ -2712,6 +2813,57 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diesel" +version = "2.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b934ddbdcb2abb9f9fc9c30bd47bcc5618b615eea1d334cda5fdf8ff9b072a" +dependencies = [ + "bitflags 2.13.0", + "byteorder", + "diesel_derives", + "downcast-rs 2.0.2", + "itoa", + "serde_json", +] + +[[package]] +name = "diesel-async" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd39af30158d444884f166fe4c58f35dc40ad71ad017bb59408a3448526ff4bd" +dependencies = [ + "bb8", + "diesel", + "futures-core", + "futures-util", + "pin-project-lite", + "tokio", + "tokio-postgres", +] + +[[package]] +name = "diesel_derives" +version = "2.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecbd51fb6c020672543641167efa4e6417ff7ad76849ed556ace3595e72de03a" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn 2.0.118", +] + [[package]] name = "digest" version = "0.10.7" @@ -2799,6 +2951,20 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling 0.21.3", + "either", + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "dunce" version = "1.0.5" @@ -3868,7 +4034,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling", + "darling 0.23.0", "indoc", "proc-macro2", "quote", @@ -5060,6 +5226,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pem" version = "3.0.6" @@ -6677,7 +6854,7 @@ version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.118", diff --git a/Cargo.toml b/Cargo.toml index 814564109..b064082bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "3" members = [ "crates/colossus-cloud", + "crates/colossus-cloud-postgres", "crates/colossus-cloud-server", "crates/colossus-connector", "crates/colossus-cloud-protocol", @@ -80,8 +81,13 @@ colossus-cloud-server = { version = "=0.11.7", path = "crates/colossus-cloud-ser axum = { version = "0.8.9", features = ["json", "tokio", "http1", "http2"] } colossus-cloud-protocol = { version = "=0.11.7", path = "crates/colossus-cloud-protocol" } colossus-cloud = { version = "=0.11.7", path = "crates/colossus-cloud" } +colossus-cloud-postgres = { version = "=0.11.7", path = "crates/colossus-cloud-postgres" } +diesel = { version = "2.3.13", default-features = false, features = ["postgres_backend", "serde_json"] } +diesel-async = { version = "0.9.2", features = ["postgres", "bb8"] } +tokio-postgres = { version = "0.7.16", features = ["with-serde_json-1"] } agent-client-protocol = "=2.2.0" colossus-bundled-plugins = { version = "=0.11.7", path = "crates/colossus-bundled-plugins" } +argon2 = { version = "0.5.3", features = ["std"] } async-trait = "0.1.89" async-recursion = "1.1.1" base64 = "0.22.1" diff --git a/api/colossus/api/v1alpha1/agent_run.proto b/api/colossus/api/v1alpha1/agent_run.proto index ee447987e..bb0e6d9cb 100644 --- a/api/colossus/api/v1alpha1/agent_run.proto +++ b/api/colossus/api/v1alpha1/agent_run.proto @@ -47,6 +47,13 @@ service AgentRunService { // RespondInteraction answers one caller-bound prompt or approval exactly once. rpc RespondInteraction(RespondInteractionRequest) returns (RespondInteractionResponse); + + // The local application explicitly releases its own workspace sessions. + rpc SetWorkspaceSharing(SetWorkspaceSharingRequest) returns (SetWorkspaceSharingResponse); + // Discovery includes only owned or explicitly shared released runs. + rpc ListVisibleRuns(ListVisibleRunsRequest) returns (ListVisibleRunsResponse); + // Caller-scoped configuration metadata; never an enforcement attestation or raw config. + rpc GetRuntimePolicyPosture(GetRuntimePolicyPostureRequest) returns (GetRuntimePolicyPostureResponse); } // RunMode selects the runtime's public execution mode. @@ -1124,3 +1131,58 @@ message StopProcessSessionResponse { // Bounded process snapshot. ProcessSessionSnapshot snapshot = 1; } + +// Change the authenticated application's local workspace disclosure relationship. +message SetWorkspaceSharingRequest { + // Exact independently enrolled recipient application identity. + string recipient_application_id = 1; + // Release source-owned sessions, or revoke future reads. + bool enabled = 2; + // Permit recipient-owned runs to continue source sessions under its own grant. + bool allow_continuation = 3; +} +// Durable local sharing posture. Enrollment alone does not enable sharing. +message SetWorkspaceSharingResponse { + // Exact recipient application identity. + string recipient_application_id = 1; + // Whether released source sessions are visible to the recipient. + bool enabled = 2; + // Whether recipient-owned runs may continue shared sessions. + bool allow_continuation = 3; +} +// Bounded discovery filters for caller-owned and explicitly shared runs. +message ListVisibleRunsRequest { + // Optional exact session identity within the released source scope. + optional string session_id = 1; + // Distinct optional lifecycle states; empty includes all states. + repeated RunStatus statuses = 2; + // Bounded page size and opaque source-stable continuation cursor. + PageRequest page = 3; + // Include shared and owned archived threads. + bool include_archived = 4; +} +// Released run and authority derived by the authenticated runtime. +message VisibleRun { + // Public run projection; never private worker data. + Run run = 1; + // Whether this caller owns cancellation and interaction response authority. + bool controllable = 2; + // Whether the caller may continue this session using its own grant. + bool continuable = 3; +} +// Bounded source-stable page of owned and explicitly shared runs. +message ListVisibleRunsResponse { + // Runtime-authorized released run projections. + repeated VisibleRun runs = 1; + // Opaque continuation cursor bound to current sharing relationships. + PageResponse page = 2; +} + +// Requests current metadata beneath the authenticated application ceiling. +message GetRuntimePolicyPostureRequest {} + +// Contains one closed, validated metadata-only RuntimePolicyPosture contract. +message GetRuntimePolicyPostureResponse { + // Strict RuntimePolicyPosture JSON, at most64KiB; unknown fields are rejected by SDKs. + bytes policy_json = 1; +} diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 3c7bad447..4f3b30301 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -26,12 +26,14 @@ "devDependencies": { "@axe-core/playwright": "4.12.1", "@playwright/test": "1.62.0", + "@tailwindcss/vite": "4.3.3", "@tauri-apps/cli": "2.11.4", "@types/node": "26.1.1", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "6.0.3", "prettier": "3.9.5", + "tailwindcss": "4.3.3", "typescript": "7.0.2", "vite": "8.1.5", "vitest": "4.1.10" @@ -58,13 +60,30 @@ "resolved": "file:../ui", "license": "Apache-2.0", "dependencies": { - "@tabler/icons-react": "3.45.0" + "@radix-ui/react-dropdown-menu": "2.1.24", + "@radix-ui/react-slot": "1.3.3", + "@tabler/icons-react": "3.45.0", + "@tanstack/react-table": "9.2.6", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "react-is": "19.2.7", + "react-markdown": "10.1.0", + "recharts": "3.10.1", + "rehype-sanitize": "6.0.0", + "remark-gfm": "4.0.1", + "tailwind-merge": "3.7.0" }, "peerDependencies": { "react": "19.2.7", "react-dom": "19.2.7" } }, + "node_modules/@colossus/ui/node_modules/react-is": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -99,6 +118,76 @@ "tslib": "^2.4.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -106,6 +195,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -151,232 +251,1419 @@ "node": ">=20" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@radix-ui/react-primitive": "2.1.10" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@rolldown/binding-darwin-arm64": { + "node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", - "cpu": [ - "arm64" - ], - "dev": true, + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz", + "integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@shikijs/core": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.3.1.tgz", + "integrity": "sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.3.1", + "@shikijs/types": "4.3.1", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.3.1.tgz", + "integrity": "sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.3.1", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.3.1.tgz", + "integrity": "sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.3.1.tgz", + "integrity": "sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.3.1", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.3.1.tgz", + "integrity": "sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.3.1.tgz", + "integrity": "sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tabler/icons": { + "version": "3.45.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.45.0.tgz", + "integrity": "sha512-jiATwV8+zGYLTZ7gMLGivCic+KtsMZXcDmufIG8umlLxoHhI6902hGYIEt0Oa9Y9SXblNzUlrisHm5jOFMxOQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.45.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.45.0.tgz", + "integrity": "sha512-t/AuKs7ALMDDTY+B9IvfZnlO0mbLlP/lJxP/HnGC49QkM8mCsTN38kYyxjXxgrb1+TeK53ioVBJqIV7n/eysXQ==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.45.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", "optional": true, "os": [ - "darwin" + "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, "os": [ - "darwin" + "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" + "android" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ - "arm" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ - "ppc64" + "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ - "s390x" + "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ - "x64" + "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "openharmony" + "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], "cpu": [ "wasm32" ], @@ -384,18 +1671,21 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=14.0.0" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -406,13 +1696,13 @@ "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -423,134 +1713,86 @@ "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 20" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, - "license": "MIT" - }, - "node_modules/@shikijs/core": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.3.1.tgz", - "integrity": "sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==", "license": "MIT", "dependencies": { - "@shikijs/primitive": "4.3.1", - "@shikijs/types": "4.3.1", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.5" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, - "engines": { - "node": ">=20" + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@shikijs/engine-javascript": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.3.1.tgz", - "integrity": "sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==", + "node_modules/@tanstack/react-store": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.2.tgz", + "integrity": "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^4.3.6" + "@tanstack/store": "0.11.2", + "use-sync-external-store": "^1.6.0" }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/langs": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.3.1.tgz", - "integrity": "sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.3.1" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" }, - "engines": { - "node": ">=20" + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@shikijs/primitive": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.3.1.tgz", - "integrity": "sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==", + "node_modules/@tanstack/react-table": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-9.2.6.tgz", + "integrity": "sha512-Wmt3sbzwz+5CtB0pqIxGw2FrNW9mF4AhJ9KnS1/VGZ8cDAEg51S4ixru2dnF0xQnY407kdYNLer3ms+n8zCN6w==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@tanstack/react-store": "^0.11.2", + "@tanstack/table-core": "9.2.6" }, "engines": { "node": ">=20" - } - }, - "node_modules/@shikijs/themes": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.3.1.tgz", - "integrity": "sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.3.1" }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@shikijs/types": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.3.1.tgz", - "integrity": "sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==", - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" }, - "engines": { - "node": ">=20" + "peerDependencies": { + "react": ">=18" } }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tabler/icons": { - "version": "3.45.0", - "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.45.0.tgz", - "integrity": "sha512-jiATwV8+zGYLTZ7gMLGivCic+KtsMZXcDmufIG8umlLxoHhI6902hGYIEt0Oa9Y9SXblNzUlrisHm5jOFMxOQA==", + "node_modules/@tanstack/store": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.2.tgz", + "integrity": "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==", "license": "MIT", "funding": { "type": "github", - "url": "https://github.com/sponsors/codecalm" + "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tabler/icons-react": { - "version": "3.45.0", - "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.45.0.tgz", - "integrity": "sha512-t/AuKs7ALMDDTY+B9IvfZnlO0mbLlP/lJxP/HnGC49QkM8mCsTN38kYyxjXxgrb1+TeK53ioVBJqIV7n/eysXQ==", + "node_modules/@tanstack/table-core": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.2.6.tgz", + "integrity": "sha512-t5LaLx8gDCgzjvwtZo5xtLklJP11D9zaKOW8q6jOCyuQYhkqpTK1heqMfgTQwDlhl0dU8f1X66Xurbj3jpCUSw==", "license": "MIT", "dependencies": { - "@tabler/icons": "3.45.0" + "@tanstack/store": "^0.11.2" + }, + "engines": { + "node": ">=20" }, "funding": { "type": "github", - "url": "https://github.com/sponsors/codecalm" - }, - "peerDependencies": { - "react": ">= 16" + "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@tauri-apps/api": { @@ -652,9 +1894,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -672,9 +1911,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -692,9 +1928,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -712,9 +1945,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -732,9 +1962,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -817,6 +2044,12 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -832,19 +2065,61 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", "license": "MIT", "dependencies": { - "@types/d3-color": "*" + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" } }, - "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, "node_modules/@types/d3-transition": { @@ -956,6 +2231,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -1492,6 +2773,18 @@ "d3-zoom": "^3.0.0" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1582,12 +2875,33 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/classcat": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -1611,6 +2925,18 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -1651,6 +2977,15 @@ "node": ">=12" } }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -1663,6 +2998,31 @@ "node": ">=12" } }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-selection": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", @@ -1672,6 +3032,42 @@ "node": ">=12" } }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-timer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", @@ -1733,6 +3129,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -1765,6 +3167,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -1778,6 +3186,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -1785,6 +3207,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -1817,6 +3251,12 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -1866,6 +3306,22 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/hast-util-sanitize": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", @@ -1964,12 +3420,31 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/immer": { + "version": "11.1.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.21.tgz", + "integrity": "sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -2026,6 +3501,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -2169,9 +3654,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2193,9 +3675,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2217,9 +3696,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2241,9 +3717,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3393,6 +4866,13 @@ "react": "^19.2.7" } }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT", + "peer": true + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -3420,6 +4900,143 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", @@ -3524,6 +5141,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -3572,9 +5195,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3637,6 +5260,43 @@ "inline-style-parser": "0.2.7" } }, + "node_modules/tailwind-merge": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3705,9 +5365,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/typescript": { "version": "7.0.2", @@ -3838,6 +5496,49 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -3875,6 +5576,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 820d093f3..718e1d46b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -34,6 +34,7 @@ "prepare:dictation": "node ../../scripts/stage-dictation-model.mjs" }, "dependencies": { + "@colossus/ui": "file:../ui", "@shikijs/core": "4.3.1", "@shikijs/engine-javascript": "4.3.1", "@shikijs/langs": "4.3.1", @@ -46,18 +47,19 @@ "react-dom": "19.2.7", "react-markdown": "10.1.0", "rehype-sanitize": "6.0.0", - "remark-gfm": "4.0.1", - "@colossus/ui": "file:../ui" + "remark-gfm": "4.0.1" }, "devDependencies": { "@axe-core/playwright": "4.12.1", "@playwright/test": "1.62.0", + "@tailwindcss/vite": "4.3.3", "@tauri-apps/cli": "2.11.4", "@types/node": "26.1.1", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@vitejs/plugin-react": "6.0.3", "prettier": "3.9.5", + "tailwindcss": "4.3.3", "typescript": "7.0.2", "vite": "8.1.5", "vitest": "4.1.10" diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 3c93a2c25..af61cc161 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -991,22 +991,6 @@ dependencies = [ "tonic-prost-build", ] -[[package]] -name = "colossus-cloud" -version = "0.11.7" -dependencies = [ - "colossus-cloud-protocol", - "colossus-contracts", - "colossus-ports", - "colossus-sdk", - "hex", - "serde", - "serde_json", - "sha2", - "thiserror 2.0.19", - "uuid", -] - [[package]] name = "colossus-cloud-protocol" version = "0.11.7" @@ -1046,7 +1030,6 @@ version = "0.11.7" dependencies = [ "async-trait", "clap", - "colossus-cloud", "colossus-cloud-protocol", "colossus-contracts", "colossus-credentials", @@ -1135,6 +1118,7 @@ dependencies = [ "colossus-windows-native", "colossus-worker-protocol", "directories", + "fs4", "futures-util", "getrandom 0.4.3", "git2", @@ -1415,6 +1399,7 @@ dependencies = [ "rustls", "security-framework", "serde", + "serde_json", "sha2", "sysinfo", "tempfile", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 12ac759b3..742ab1e1d 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -86,6 +86,7 @@ tauri-plugin-updater = { version = "=2.9.0", default-features = false, features tokio = { version = "1.49.0", features = ["io-util", "macros", "net", "process", "rt", "sync", "time"] } tempfile = "3.23.0" url = "2.5.8" +fs4 = "0.13.1" uuid = { version = "1.23.4", features = ["v4", "v7"] } zeroize = { version = "1.8.2", features = ["serde"] } git2 = { version = "0.21.0", default-features = false, features = ["vendored-libgit2"] } diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index 871bddfe3..10bc0de50 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -112,6 +112,9 @@ const COMMANDS: &[&str] = &[ "cloud_enroll", "cloud_connect", "cloud_status", + "control_plane_profiles", + "save_control_plane_profiles", + "cloud_set_workspace_sharing", "cloud_disconnect", "cloud_forget", "cloud_revoke", diff --git a/apps/desktop/src-tauri/capabilities/main-chat.json b/apps/desktop/src-tauri/capabilities/main-chat.json index 9d5f40c57..9e08b9ba2 100644 --- a/apps/desktop/src-tauri/capabilities/main-chat.json +++ b/apps/desktop/src-tauri/capabilities/main-chat.json @@ -120,6 +120,9 @@ "allow-cloud-enroll", "allow-cloud-connect", "allow-cloud-status", + "allow-control-plane-profiles", + "allow-save-control-plane-profiles", + "allow-cloud-set-workspace-sharing", "allow-cloud-disconnect", "allow-cloud-forget", "allow-cloud-revoke", diff --git a/apps/desktop/src-tauri/src/cloud_connector.rs b/apps/desktop/src-tauri/src/cloud_connector.rs index b7f5178e5..1b82c9578 100644 --- a/apps/desktop/src-tauri/src/cloud_connector.rs +++ b/apps/desktop/src-tauri/src/cloud_connector.rs @@ -32,6 +32,11 @@ pub(crate) struct CloudStatus { node_id: Option, project_id: Option, endpoint: Option, + host_id: Option, + workspace_id: Option, + shared_sessions: bool, + shared_continuation: bool, + sharing_supported: bool, } #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -57,16 +62,64 @@ fn dto(target: String, config: Option<&ConnectionConfig>, status: ConnectorStatu node_id: config.map(|config| config.node_id.clone()), project_id: config.map(|config| config.project_id.clone()), endpoint: config.map(|config| config.endpoint.clone()), + host_id: config + .and_then(|config| config.inventory.as_ref()) + .map(|inventory| inventory.host_id.clone()), + workspace_id: config + .and_then(|config| config.inventory.as_ref()) + .map(|inventory| inventory.workspace_id.clone()), + shared_sessions: config + .and_then(|config| config.inventory.as_ref()) + .is_some_and(|inventory| { + inventory.sharing == colossus_connector::WorkspaceSharing::SharedVisibleSessions + }), + shared_continuation: config.is_some_and(|config| config.shared_continuation), + sharing_supported: config + .and_then(|config| config.inventory.as_ref()) + .is_some_and(|inventory| { + inventory.deployment_kind == colossus_connector::DeploymentKind::Desktop + }), } } +pub(crate) async fn global_connections(state: &AppState) -> Vec { + let sessions = state.cloud_connections.lock().await; + let mut connections = sessions + .iter() + .map(|(target, session)| { + dto( + target.clone(), + Some(&session.config), + *session.status.borrow(), + ) + }) + .collect::>(); + connections.sort_by(|left, right| left.target_id.cmp(&right.target_id)); + connections +} async fn confirm(app: &AppHandle, message: String) -> Result<(), CommandErrorDto> { + confirm_action( + app, + message, + "Connect runtime to Colossus Control Plane", + "Connect runtime", + ) + .await +} +async fn confirm_action( + app: &AppHandle, + message: String, + title: &str, + action: &str, +) -> Result<(), CommandErrorDto> { let app = app.clone(); + let title = title.to_owned(); + let action = action.to_owned(); let accepted = tauri::async_runtime::spawn_blocking(move || { app.dialog() .message(message) - .title("Connect runtime to Colossus Cloud") + .title(title) .buttons(MessageDialogButtons::OkCancelCustom( - "Connect runtime".into(), + action, "Cancel".into(), )) .blocking_show() @@ -76,16 +129,28 @@ async fn confirm(app: &AppHandle, message: String) -> Result<(), CommandErrorDto if accepted { Ok(()) } else { - Err(failure("Cloud connection was cancelled.")) + Err(failure("The native confirmation was cancelled.")) } } async fn start( state: &AppState, target: String, - config: ConnectionConfig, + mut config: ConnectionConfig, key: Zeroizing, runs: Arc, ) -> Result { + if config.inventory.is_none() { + let inventory = colossus_connector::native_inventory( + format!("workspace:{}", config.instance_id), + "Desktop workspace".into(), + colossus_connector::DeploymentKind::Desktop, + ) + .map_err(failure)?; + config = store(state, &target)? + .set_inventory(inventory) + .await + .map_err(failure)?; + } let connector = RuntimeConnector::new(config.clone(), key, runs) .map_err(failure)? .with_enrollment_store((*store(state, &target)?).clone()); @@ -99,12 +164,14 @@ async fn start( | ConnectorStatus::Connected | ConnectorStatus::Reconnecting ) { - return Err(failure("Disconnect the existing cloud connection first.")); + return Err(failure( + "Disconnect the existing Control Plane connection first.", + )); } connections.remove(&target); } if connections.len() >= 16 { - return Err(failure("Desktop cloud connection limit reached.")); + return Err(failure("Desktop Control Plane connection limit reached.")); } let task = tauri::async_runtime::spawn(async move { if connector.run(receiver, status.clone()).await.is_err() @@ -136,7 +203,7 @@ pub(crate) async fn cloud_enroll( .selected_target(&request.target_id) .await .ok_or_else(|| failure("Select the connected runtime before enrolling it."))?; - let runs=lease.target.client.connector_runs().ok_or_else(||failure("This runtime has no dedicated cloud grant. Enroll a dedicated application with the CLI first."))?; + let runs=lease.target.client.connector_runs().ok_or_else(||failure("This runtime has no dedicated Control Plane grant. Enroll a dedicated application with the CLI first."))?; let instance = lease .target .client @@ -163,6 +230,24 @@ pub(crate) async fn cloud_enroll( ) .await .map_err(failure)?; + let (_, workspace, managed) = state.terminal_workspace_context().await; + let inventory = colossus_connector::native_inventory( + if managed { + request.target_id.clone() + } else { + format!("workspace:{}", config.instance_id) + }, + workspace + .map(|workspace| workspace.display_name) + .unwrap_or_else(|| "Desktop workspace".into()), + if managed { + colossus_connector::DeploymentKind::Desktop + } else { + colossus_connector::DeploymentKind::Cli + }, + ) + .map_err(failure)?; + let config = store.set_inventory(inventory).await.map_err(failure)?; let (_, key) = tokio::task::spawn_blocking(move || store.load()) .await .map_err(|_| failure("Enrollment vault unavailable."))? @@ -241,6 +326,79 @@ pub(crate) async fn cloud_status( )) } #[tauri::command(rename_all = "camelCase")] +pub(crate) async fn cloud_set_workspace_sharing( + app: AppHandle, + state: State<'_, AppState>, + target_id: String, + enabled: bool, + allow_continuation: bool, +) -> Result { + let _operation = state.cloud_operation.lock().await; + let lease = state + .selected_target(&target_id) + .await + .ok_or_else(|| failure("Select the workspace before changing sharing."))?; + if lease.target.consent != crate::state::TargetConsentContext::ManagedLocal { + return Err(failure( + "Configure sharing for an external daemon with its local CLI application.", + )); + } + let enrollment = store(&state, &target_id)?; + let reader = enrollment.clone(); + let (config, _) = tokio::task::spawn_blocking(move || reader.load()) + .await + .map_err(|_| failure("Enrollment vault unavailable."))? + .map_err(failure)? + .ok_or_else(|| failure("Enroll this workspace before sharing sessions."))?; + if enabled { + confirm_action(&app, format!("Share this workspace's existing and future Desktop conversations with project {} at {}? Released history will be retained in the Control Plane for authorized project members. {} Local roles, tools, policy, and approvals remain enforced. Disabling sharing stops future synchronization but does not erase previously synchronized history.", + config.project_id,config.endpoint, + if allow_continuation {"Members with execution permission may continue these conversations using the dedicated Control Plane grant."} else {"Existing Desktop runs remain read-only in the Control Plane."}),"Workspace sharing","Update sharing").await?; + } + lease + .target + .client + .agent_runs() + .set_workspace_sharing(colossus_sdk::SetWorkspaceSharingRequest { + recipient_application_id: "app:colossus-desktop-cloud".into(), + enabled, + allow_continuation: enabled && allow_continuation, + }) + .await + .map_err(|_| failure("The runtime could not commit workspace sharing."))?; + let config = enrollment + .set_sharing(enabled, allow_continuation) + .await + .map_err(failure)?; + let mut sessions = state.cloud_connections.lock().await; + let status = sessions + .get(&target_id) + .map(|session| *session.status.borrow()) + .unwrap_or(ConnectorStatus::Disconnected); + // Reconnect refreshes native inventory without cancelling any accepted work. + sessions.remove(&target_id); + drop(sessions); + if matches!( + status, + ConnectorStatus::Connected | ConnectorStatus::Connecting | ConnectorStatus::Reconnecting + ) { + let reader = enrollment.clone(); + let (_, key) = tokio::task::spawn_blocking(move || reader.load()) + .await + .map_err(|_| failure("Enrollment vault unavailable."))? + .map_err(failure)? + .ok_or_else(|| failure("Enrollment unavailable."))?; + let runs = lease + .target + .client + .connector_runs() + .ok_or_else(|| failure("Dedicated cloud grant unavailable."))?; + start(&state, target_id, config, key, runs).await + } else { + Ok(dto(target_id, Some(&config), ConnectorStatus::Disconnected)) + } +} +#[tauri::command(rename_all = "camelCase")] pub(crate) async fn cloud_revoke( app: AppHandle, state: State<'_, AppState>, @@ -255,7 +413,7 @@ pub(crate) async fn cloud_revoke( .map_err(failure)? .ok_or_else(|| failure("This runtime is not enrolled."))?; let message = format!( - "Revoke runtime {} in project {} at {}? This removes its cloud authority. Accepted tasks continue under local runtime policy.", + "Revoke runtime {} in project {} at {}? This removes its Control Plane authority. Accepted tasks continue under local runtime policy.", config.node_id, config.project_id, config.endpoint ); let accepted = tauri::async_runtime::spawn_blocking(move || { diff --git a/apps/desktop/src-tauri/src/control_plane_profiles.rs b/apps/desktop/src-tauri/src/control_plane_profiles.rs new file mode 100644 index 000000000..0e34333af --- /dev/null +++ b/apps/desktop/src-tauri/src/control_plane_profiles.rs @@ -0,0 +1,178 @@ +//! Global connection bookmarks; never credentials, enrollment, or execution authority. +use crate::{cloud_connector, dto::CommandErrorDto, state::AppState}; +use colossus_home::{ColossusHome, ConfinedRoot}; +use fs4::fs_std::FileExt; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeSet, + io::{Read, Write}, + path::Path, +}; +use tauri::State; + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ControlPlaneProfile { + id: String, + label: String, + endpoint: String, +} + +#[derive(Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Profiles { + revision: u64, + profiles: Vec, + default_profile: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProfileSnapshot { + #[serde(flatten)] + catalog: Profiles, + connections: Vec, +} + +fn failure() -> CommandErrorDto { + CommandErrorDto::invalid( + "control_plane_profiles", + "Connection settings are unavailable or changed; reload and try again.", + ) +} + +fn access(next: Option) -> Result { + access_with_home(next, ColossusHome::resolve_and_ensure) +} + +fn access_with_home( + next: Option, + resolve_home: impl FnOnce() -> Result, +) -> Result { + if let Some(next) = &next { + let mut ids = BTreeSet::new(); + let mut names = BTreeSet::new(); + if next.profiles.len() > 32 + || next.profiles.iter().any(|profile| { + let valid_id = !profile.id.is_empty() + && profile.id.len() <= 128 + && profile + .id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"-_".contains(&c)); + let valid_label = !profile.label.trim().is_empty() + && profile.label.len() <= 128 + && !profile.label.chars().any(char::is_control); + let valid_url = url::Url::parse(&profile.endpoint).is_ok_and(|url| { + let local = matches!( + url.host_str(), + Some("localhost" | "127.0.0.1" | "[::1]" | "::1") + ); + (url.scheme() == "https" || url.scheme() == "http" && local) + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() + && profile.endpoint.len() <= 2048 + }); + !valid_id + || !valid_label + || !valid_url + || !ids.insert(&profile.id) + || !names.insert(profile.label.trim().to_lowercase()) + }) + || next + .default_profile + .as_ref() + .is_some_and(|id| !ids.contains(id)) + { + return Err(CommandErrorDto::invalid( + "control_plane_profiles", + "Use unique connection names and HTTPS endpoints, or HTTP on loopback. Endpoints cannot include credentials, query parameters, or fragments.", + )); + } + } + let home = resolve_home().map_err(|_| failure())?; + let root = ConfinedRoot::bind( + home.confined_root() + .prepare_directory(Path::new("desktop-control-plane")) + .map_err(|_| failure())?, + ) + .map_err(|_| failure())?; + let lock = root + .open_file(Path::new("profiles.lock")) + .map_err(|_| failure())?; + lock.file().lock_exclusive().map_err(|_| failure())?; + let retained = root + .open_file(Path::new("profiles.json")) + .map_err(|_| failure())?; + let mut file = retained.file(); + let result = (|| { + retained.revalidate(&root).map_err(|_| failure())?; + let mut bytes = Vec::new(); + Read::by_ref(&mut file) + .take(32769) + .read_to_end(&mut bytes) + .map_err(|_| failure())?; + if bytes.len() > 32768 { + return Err(failure()); + } + let old: Profiles = if bytes.is_empty() { + Profiles::default() + } else { + serde_json::from_slice(&bytes).map_err(|_| failure())? + }; + let Some(mut next) = next else { return Ok(old) }; + if next.revision != old.revision { + return Err(failure()); + } + next.revision = old.revision.checked_add(1).ok_or_else(failure)?; + let bytes = serde_json::to_vec(&next).map_err(|_| failure())?; + if bytes.len() > 32768 { + return Err(failure()); + } + let temp_name = format!("profiles-{}.tmp", uuid::Uuid::new_v4()); + let temp = root + .open_file(Path::new(&temp_name)) + .map_err(|_| failure())?; + let mut output = temp.file(); + output.write_all(&bytes).map_err(|_| failure())?; + output.sync_all().map_err(|_| failure())?; + retained.revalidate(&root).map_err(|_| failure())?; + temp.revalidate(&root).map_err(|_| failure())?; + std::fs::rename(temp.path(), retained.path()).map_err(|_| failure())?; + Ok(next) + })(); + let _ = FileExt::unlock(lock.file()); + result +} + +#[tauri::command] +pub(crate) async fn control_plane_profiles( + state: State<'_, AppState>, +) -> Result { + let catalog = tauri::async_runtime::spawn_blocking(|| access(None)) + .await + .map_err(|_| failure())??; + Ok(ProfileSnapshot { + catalog, + connections: cloud_connector::global_connections(&state).await, + }) +} + +#[tauri::command] +pub(crate) async fn save_control_plane_profiles( + state: State<'_, AppState>, + catalog: Profiles, +) -> Result { + let catalog = tauri::async_runtime::spawn_blocking(move || access(Some(catalog))) + .await + .map_err(|_| failure())??; + Ok(ProfileSnapshot { + catalog, + connections: cloud_connector::global_connections(&state).await, + }) +} + +#[cfg(test)] +mod tests; diff --git a/apps/desktop/src-tauri/src/control_plane_profiles/tests.rs b/apps/desktop/src-tauri/src/control_plane_profiles/tests.rs new file mode 100644 index 000000000..f9e3170e5 --- /dev/null +++ b/apps/desktop/src-tauri/src/control_plane_profiles/tests.rs @@ -0,0 +1,173 @@ +use super::*; +use std::fs; + +fn home() -> (tempfile::TempDir, ColossusHome) { + #[cfg(windows)] + let directory = tempfile::tempdir_in( + std::env::var_os("LOCALAPPDATA").expect("private Windows test parent"), + ) + .expect("temporary profile parent"); + #[cfg(not(windows))] + let directory = tempfile::tempdir().expect("temporary profile parent"); + let parent = fs::canonicalize(directory.path()).expect("canonical test parent"); + let home = ColossusHome::ensure_at(parent.join("home")).expect("private test home"); + (directory, home) +} + +fn catalog(revision: u64, endpoint: &str) -> Profiles { + Profiles { + revision, + profiles: vec![ControlPlaneProfile { + id: "acceptance-profile".into(), + label: "Control Plane".into(), + endpoint: endpoint.into(), + }], + default_profile: Some("acceptance-profile".into()), + } +} + +fn rejected_before_home(catalog: Profiles) { + assert!( + access_with_home(Some(catalog), || panic!("invalid metadata resolved a home")).is_err() + ); +} + +#[test] +fn profiles_allow_https_and_strict_loopback_endpoints() { + let (_guard, home) = home(); + let mut revision = 0; + for endpoint in [ + "https://control-plane.example/api", + "http://127.0.0.1:8090", + "http://localhost:8090", + "http://[::1]:8090", + ] { + let saved = access_with_home(Some(catalog(revision, endpoint)), || Ok(home.clone())) + .expect("valid connection metadata"); + assert_eq!(saved.profiles[0].endpoint, endpoint); + revision = saved.revision; + } +} + +#[test] +fn profiles_reject_credential_urls_remote_http_and_invalid_names() { + for endpoint in [ + "http://control-plane.example", + "http://127.0.0.1.example", + "https://user@control-plane.example", + "https://user:password@control-plane.example", + "https://control-plane.example?token=synthetic", + "https://control-plane.example#fragment", + "file:///private/tmp/profiles", + ] { + rejected_before_home(catalog(0, endpoint)); + } + for id in ["", "../profile", "profile.name"] { + let mut invalid = catalog(0, "https://control-plane.example"); + invalid.profiles[0].id = id.into(); + rejected_before_home(invalid); + } + for label in [" ", "Control\nPlane"] { + let mut invalid = catalog(0, "https://control-plane.example"); + invalid.profiles[0].label = label.into(); + rejected_before_home(invalid); + } + let mut duplicate = catalog(0, "https://control-plane.example"); + duplicate.profiles.push(duplicate.profiles[0].clone()); + rejected_before_home(duplicate.clone()); + duplicate.profiles[1].id = "another-profile".into(); + duplicate.profiles[1].label = " control plane ".into(); + rejected_before_home(duplicate); + let mut unknown_default = catalog(0, "https://control-plane.example"); + unknown_default.default_profile = Some("missing-profile".into()); + rejected_before_home(unknown_default); +} + +#[test] +fn profiles_revision_cas_is_durable_across_reopen() { + let (_guard, home) = home(); + let initial = access_with_home(None, || Ok(home.clone())).expect("empty catalog"); + assert_eq!(initial.revision, 0); + assert!(initial.profiles.is_empty()); + let saved = access_with_home(Some(catalog(0, "https://first.example")), || { + Ok(home.clone()) + }) + .expect("first save"); + assert_eq!(saved.revision, 1); + assert!( + access_with_home(Some(catalog(0, "https://stale.example")), || { + Ok(home.clone()) + }) + .is_err() + ); + let reopened = ColossusHome::ensure_at(home.root()).expect("reopened private home"); + let unchanged = access_with_home(None, || Ok(reopened.clone())).expect("retained catalog"); + assert_eq!(unchanged.revision, 1); + assert_eq!(unchanged.profiles[0].endpoint, "https://first.example"); + let updated = access_with_home(Some(catalog(1, "https://second.example")), || { + Ok(reopened.clone()) + }) + .expect("next revision save"); + assert_eq!(updated.revision, 2); + let retained = access_with_home(None, || Ok(reopened)).expect("updated retained catalog"); + assert_eq!(retained.revision, 2); + assert_eq!(retained.profiles[0].endpoint, "https://second.example"); + assert_eq!( + retained.default_profile.as_deref(), + Some("acceptance-profile") + ); +} + +#[test] +fn profiles_concurrent_writers_share_one_revision() { + let (_guard, home) = home(); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let results = std::thread::scope(|scope| { + let writers = ["https://first.example", "https://second.example"].map(|endpoint| { + let home = home.clone(); + let barrier = barrier.clone(); + scope.spawn(move || { + barrier.wait(); + access_with_home(Some(catalog(0, endpoint)), || Ok(home)).is_ok() + }) + }); + writers.map(|writer| writer.join().expect("profile writer")) + }); + assert_eq!(results.into_iter().filter(|saved| *saved).count(), 1); + let retained = access_with_home(None, || Ok(home)).expect("one durable winning catalog"); + assert_eq!(retained.revision, 1); + assert!(matches!( + retained.profiles[0].endpoint.as_str(), + "https://first.example" | "https://second.example" + )); +} + +#[cfg(unix)] +#[test] +fn profiles_reject_symlinked_directory_and_catalog() { + let (_guard, home) = home(); + let outside = home.root().parent().expect("test parent").join("outside"); + fs::create_dir(&outside).expect("outside test directory"); + let profiles = home.root().join("desktop-control-plane"); + std::os::unix::fs::symlink(&outside, &profiles).expect("synthetic directory link"); + assert!(access_with_home(None, || Ok(home.clone())).is_err()); + assert_eq!(fs::read_dir(&outside).expect("untouched target").count(), 0); + fs::remove_file(&profiles).expect("remove synthetic directory link"); + access_with_home(None, || Ok(home.clone())).expect("create confined catalog"); + let outside_file = outside.join("retained.json"); + fs::write(&outside_file, b"retain exactly").expect("outside marker"); + fs::remove_file(profiles.join("profiles.json")).expect("remove empty test catalog"); + std::os::unix::fs::symlink(&outside_file, profiles.join("profiles.json")) + .expect("synthetic catalog link"); + assert!( + access_with_home(Some(catalog(0, "https://control-plane.example")), || { + Ok(home.clone()) + }) + .is_err() + ); + assert!(access_with_home(None, || Ok(home)).is_err()); + assert_eq!( + fs::read(outside_file).expect("untouched marker"), + b"retain exactly" + ); +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c277c6ff2..0a78855bb 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,7 +1,10 @@ mod cloud_connector; +mod control_plane_profiles; use cloud_connector::{ - cloud_connect, cloud_disconnect, cloud_enroll, cloud_forget, cloud_revoke, cloud_status, + cloud_connect, cloud_disconnect, cloud_enroll, cloud_forget, cloud_revoke, + cloud_set_workspace_sharing, cloud_status, }; +use control_plane_profiles::{control_plane_profiles, save_control_plane_profiles}; mod app_context; mod approval_adapter; mod browser; @@ -314,6 +317,9 @@ pub fn run() { cloud_enroll, cloud_connect, cloud_status, + control_plane_profiles, + save_control_plane_profiles, + cloud_set_workspace_sharing, cloud_disconnect, cloud_forget, cloud_revoke, diff --git a/apps/desktop/src/components/CatalogInventory.tsx b/apps/desktop/src/components/CatalogInventory.tsx index 6dcb7b700..18e77cf0d 100644 --- a/apps/desktop/src/components/CatalogInventory.tsx +++ b/apps/desktop/src/components/CatalogInventory.tsx @@ -1,459 +1,3 @@ -import { - IconActivityHeartbeat, - IconChevronRight, - IconCloud, - IconCpu, - IconDots, - IconInfoCircle, - IconKey, - IconPlus, - IconSearch, - IconTrash, - IconX, -} from "@tabler/icons-react"; -import { - Fragment, - type ReactNode, - useEffect, - useId, - useRef, - useState, -} from "react"; +export { CatalogInventory } from "@colossus/ui"; +export type { CatalogInventoryRow } from "@colossus/ui"; import "./catalog-inventory.css"; - -export interface CatalogInventoryRow { - id: string; - label: string; - name: string; - description: string; - icon?: ReactNode; - searchText: string; - connection: ReactNode; - usage: string; - details: ReactNode; - actionLabel?: string; - actionIcon?: ReactNode; - actionAriaLabel?: string; - mutationDisabled?: boolean; - onEdit: () => void; - onDelete?: (trigger: HTMLButtonElement) => void; -} - -const catalogKinds = { - model: { - title: "Models", - singular: "model", - plural: "models", - column: "Model", - description: "Choose and manage the models your workspaces use.", - connectionLabel: "Provider", - usageLabel: "Active workspaces", - Icon: IconCpu, - className: "models-settings", - }, - provider: { - title: "Providers", - singular: "provider", - plural: "providers", - column: "Provider", - description: "Saved connections for your workspaces.", - connectionLabel: "Sign-in", - usageLabel: "Configured models", - Icon: IconCloud, - className: "providers-settings", - }, - credential: { - title: "Credentials", - singular: "credential", - plural: "credentials", - column: "Credential", - description: "API keys, tokens, and client secrets for your connections.", - connectionLabel: "Status", - usageLabel: "Used by", - Icon: IconKey, - className: "credentials-settings", - }, - search: { - title: "Search services", - singular: "search service", - plural: "search services", - column: "Service", - description: - "Saved search services. Enable each service in Workspace settings.", - connectionLabel: "Credential", - usageLabel: "Active workspaces", - Icon: IconSearch, - className: "search-settings", - }, - telemetry: { - title: "Telemetry connections", - singular: "telemetry connection", - plural: "telemetry connections", - column: "Connection", - description: "Manage collectors, exported signals, and audit content.", - connectionLabel: "Exported data", - usageLabel: "Active workspaces", - Icon: IconActivityHeartbeat, - className: "telemetry-settings", - }, -} as const; - -/** Shared presentation only; catalog mutations remain with the settings owner. */ -export function CatalogInventory({ - kind, - summary, - rows, - busy, - editing, - onAdd, - children, -}: { - kind: keyof typeof catalogKinds; - summary: string; - rows: CatalogInventoryRow[]; - busy: boolean; - editing: boolean; - onAdd: () => void; - children: ReactNode; -}) { - const [query, setQuery] = useState(""); - const search = useRef(null); - const [expanded, setExpanded] = useState(null); - const id = useId(); - const { - title, - singular, - plural, - column, - description, - connectionLabel, - usageLabel, - Icon, - className, - } = catalogKinds[kind]; - const searchLabel = - kind === "search" ? "Search services" : `Search ${plural}`; - const needle = query.trim().toLocaleLowerCase(); - const visible = rows.filter((row) => - row.searchText.toLocaleLowerCase().includes(needle), - ); - - return ( -
-
-
-

{title}

-

{description}

-
- -
-

{summary}

- {children} -
-
-
- - - - - - - - - - - - - - - - - {visible.map((row) => { - const open = expanded === row.id; - const detailsId = `${id}-${row.id}-details`; - const toggle = () => setExpanded(open ? null : row.id); - return ( - - - - - - - - - - - - ); - })} - {!visible.length ? ( - - - - ) : null} - -
{column}{connectionLabel}{usageLabel} - Actions -
-
- - {row.icon ?? -
- - {row.description} -
-
-
- - {row.connection} - - - - -
- - {row.onDelete ? ( - setExpanded(row.id)} - onDelete={row.onDelete} - /> - ) : null} -
-
- - {rows.length ? `No matching ${plural}` : `No ${plural} yet`} - -

- {rows.length - ? "Try a different name or clear your search." - : `Add a ${singular} to get started.`} -

-
-
-

- {needle - ? `${visible.length} of ${rows.length} ${plural} match your search.` - : ""} -

-
- ); -} - -function CatalogActions({ - label, - busy, - onDetails, - onDelete, - deleteDisabled, -}: { - label: string; - busy: boolean; - deleteDisabled?: boolean | undefined; - onDetails: () => void; - onDelete: (trigger: HTMLButtonElement) => void; -}) { - const [open, setOpen] = useState(false); - const wrapper = useRef(null); - const trigger = useRef(null); - const menu = useRef(null); - const id = useId(); - useEffect(() => { - if (!open) return; - menu.current?.querySelector("button")?.focus(); - const dismiss = (event: PointerEvent) => { - if ( - event.target instanceof Node && - !wrapper.current?.contains(event.target) - ) - setOpen(false); - }; - document.addEventListener("pointerdown", dismiss); - return () => document.removeEventListener("pointerdown", dismiss); - }, [open]); - const close = () => { - setOpen(false); - trigger.current?.focus(); - }; - return ( -
{ - if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false); - }} - > - - {open ? ( - - ) : null} -
- ); -} diff --git a/apps/desktop/src/components/CloudConnectionPane.tsx b/apps/desktop/src/components/CloudConnectionPane.tsx index 3f962f8fa..7437028fe 100644 --- a/apps/desktop/src/components/CloudConnectionPane.tsx +++ b/apps/desktop/src/components/CloudConnectionPane.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; +import { Button, RadioGroup, TextInput } from "@colossus/ui"; import { IconCloud, IconPlugConnected, @@ -8,6 +9,7 @@ import { IconShieldLock, } from "@tabler/icons-react"; import "./cloud-connection.css"; +import type { ControlPlaneProfiles } from "./ControlPlaneSettingsPane"; interface CloudStatus { targetId: string; status: @@ -15,6 +17,11 @@ interface CloudStatus { nodeId: string | null; projectId: string | null; endpoint: string | null; + hostId?: string | null; + workspaceId?: string | null; + sharedSessions?: boolean; + sharedContinuation?: boolean; + sharingSupported?: boolean; } export function CloudConnectionPane({ targetId }: { targetId: string }) { const [status, setStatus] = useState(null), @@ -22,8 +29,27 @@ export function CloudConnectionPane({ targetId }: { targetId: string }) { [token, setToken] = useState(""), [busy, setBusy] = useState(false), [error, setError] = useState(""); + const [sharing, setSharing] = useState("private"); const operation = useRef(0), busyRef = useRef(false); + useEffect(() => { + let alive = true; + void invoke("control_plane_profiles") + .then((catalog) => { + const profile = catalog.profiles.find( + (item) => item.id === catalog.defaultProfile, + ); + if (alive && profile) + setUrl( + (current) => + current || `${profile.endpoint.replace(/\/$/, "")}/api/enroll`, + ); + }) + .catch(() => undefined); + return () => { + alive = false; + }; + }, [targetId]); useEffect(() => { let alive = true, refreshing = false; @@ -40,7 +66,7 @@ export function CloudConnectionPane({ targetId }: { targetId: string }) { setError( typeof error === "object" && error !== null && "message" in error ? String(error.message) - : "Cloud enrollment is unavailable.", + : "Control Plane enrollment is unavailable.", ); }) .finally(() => { @@ -74,7 +100,7 @@ export function CloudConnectionPane({ targetId }: { targetId: string }) { setError( typeof error === "object" && error !== null && "message" in error ? String(error.message) - : "The cloud connection could not be changed.", + : "The Control Plane connection could not be changed.", ); } finally { busyRef.current = false; @@ -84,6 +110,15 @@ export function CloudConnectionPane({ targetId }: { targetId: string }) { const active = status && ["connected", "connecting", "reconnecting"].includes(status.status); + useEffect(() => { + setSharing( + status?.sharedSessions + ? status.sharedContinuation + ? "continue" + : "read" + : "private", + ); + }, [status?.sharedSessions, status?.sharedContinuation, targetId]); return (
@@ -91,10 +126,8 @@ export function CloudConnectionPane({ targetId }: { targetId: string }) {
-

Colossus Cloud

-

- Make this runtime available to your project's cloud control plane. -

+

Colossus Control Plane

+

Make this runtime available to your project's Control Plane.

{error && ( @@ -116,62 +149,117 @@ export function CloudConnectionPane({ targetId }: { targetId: string }) { {status?.projectId && {status.projectId}} {!status && error && ( - + )} {status?.nodeId ? ( <>
-
Cloud endpoint
+
Control Plane endpoint
{status.endpoint}
Node identity
{status.nodeId}
+ {status.hostId && ( + <> +
Host identity
+
{status.hostId}
+ + )}
{active ? ( - + ) : ( - + )} - - +

{status.status === "revoked" ? "Forget this enrollment, then use a new invitation to connect again. Accepted tasks remain under local runtime policy." - : "Disconnecting leaves accepted tasks running locally. Revocation removes this runtime's cloud authority until it is enrolled again."} + : "Disconnecting leaves accepted tasks running locally. Revocation removes this runtime's Control Plane authority until it is enrolled again."}

+ {status.sharingSupported && ( +
{ + event.preventDefault(); + void action("cloud_set_workspace_sharing", { + targetId, + enabled: sharing !== "private", + allowContinuation: sharing === "continue", + }); + }} + > +
+

Desktop conversation sharing

+ +
+

+ Sharing includes existing and future conversations in this + workspace. Enabling sharing requires local confirmation. + Disabling it stops future synchronization; history already + synchronized remains in the Control Plane. +

+ +
+ )} ) : (
- + {error && ( - + )}
)} diff --git a/apps/desktop/src/components/ControlPlaneSettingsPane.tsx b/apps/desktop/src/components/ControlPlaneSettingsPane.tsx new file mode 100644 index 000000000..8149726bd --- /dev/null +++ b/apps/desktop/src/components/ControlPlaneSettingsPane.tsx @@ -0,0 +1,250 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { IconCloud } from "@tabler/icons-react"; +import { Button, CatalogInventory, TextInput } from "@colossus/ui"; + +interface Profile { + id: string; + label: string; + endpoint: string; +} +export interface ControlPlaneProfiles { + revision: number; + profiles: Profile[]; + defaultProfile: string | null; + connections: { + targetId: string; + status: string; + projectId: string | null; + endpoint: string | null; + sharedSessions: boolean; + }[]; +} + +export function ControlPlaneSettingsPane() { + const [catalog, setCatalog] = useState(null); + const [label, setLabel] = useState(""); + const [endpoint, setEndpoint] = useState(""); + const [editorOpen, setEditorOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(false); + const busyRef = useRef(false); + const [error, setError] = useState(""); + useEffect(() => { + let alive = true; + const refresh = () => { + if (busyRef.current) return; + void invoke("control_plane_profiles") + .then((value) => { + if (alive) + setCatalog((current) => + current && current.revision > value.revision ? current : value, + ); + }) + .catch(() => { + if (alive) setError("Control Plane settings are unavailable."); + }); + }; + refresh(); + const timer = setInterval(refresh, 5000); + return () => { + alive = false; + clearInterval(timer); + }; + }, []); + async function save(profiles: Profile[], defaultProfile: string | null) { + if (!catalog || busyRef.current) return; + busyRef.current = true; + setBusy(true); + setError(""); + try { + const next = await invoke( + "save_control_plane_profiles", + { + catalog: { revision: catalog.revision, profiles, defaultProfile }, + }, + ); + setCatalog(next); + setEditorOpen(false); + setEditing(null); + setLabel(""); + setEndpoint(""); + } catch (failure) { + setError( + typeof failure === "object" && failure !== null && "message" in failure + ? String(failure.message) + : "Connection settings could not be saved. Reload and try again.", + ); + } finally { + busyRef.current = false; + setBusy(false); + } + } + const profiles = catalog?.profiles ?? []; + if (!catalog) + return ( +

+ {error || "Loading Control Plane connections…"} +

+ ); + return ( + { + setEditorOpen(true); + setEditing(null); + setLabel(""); + setEndpoint(""); + }} + emptyDescription="Add an endpoint, then enroll a workspace from its Control Plane settings." + rows={profiles.map((profile) => ({ + id: profile.id, + label: profile.label, + name: profile.label, + description: profile.endpoint, + searchText: `${profile.label} ${profile.endpoint}`, + connection: + catalog.defaultProfile === profile.id ? ( + "Default" + ) : ( + + ), + usage: "Per workspace", + details: ( +

+ Workspace runtimes keep their own enrollment and sharing choice. +

+ ), + onEdit: () => { + setEditing(profile.id); + setEditorOpen(true); + setLabel(profile.label); + setEndpoint(profile.endpoint); + }, + onDelete: () => + void save( + profiles.filter((item) => item.id !== profile.id), + catalog.defaultProfile === profile.id + ? null + : catalog.defaultProfile, + ), + }))} + footer={ + catalog.connections.length ? ( +
+

Connected workspace runtimes

+ {catalog.connections.map((connection) => ( +

+ {connection.projectId ?? "Workspace"} ·{" "} + {connection.status} ·{" "} + {connection.sharedSessions + ? "Workspace history shared" + : "Local history private"} +

+ ))} +
+ ) : null + } + > + {error ? ( +

+ {error} +

+ ) : null} + {editorOpen ? ( +
{ + event.preventDefault(); + const id = editing ?? crypto.randomUUID(); + void save( + [ + ...profiles.filter((profile) => profile.id !== id), + { id, label: label.trim(), endpoint: endpoint.trim() }, + ], + catalog.defaultProfile ?? id, + ); + }} + > +
+
+
+
+ + +
+
+
+ + +
+
+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/components/ManagedSettingsPane.tsx b/apps/desktop/src/components/ManagedSettingsPane.tsx index 243d6c349..252bdbf43 100644 --- a/apps/desktop/src/components/ManagedSettingsPane.tsx +++ b/apps/desktop/src/components/ManagedSettingsPane.tsx @@ -1,4 +1,5 @@ import { CloudConnectionPane } from "./CloudConnectionPane"; +import { ControlPlaneSettingsPane } from "./ControlPlaneSettingsPane"; import { ModelFeatureControl } from "./ModelFeatureControl"; import type { ModelFeatureMode, ManagedModelCapabilities } from "../types"; import { RememberedCommands } from "./RememberedCommands"; @@ -221,6 +222,7 @@ type DesktopTab = (typeof DESKTOP_PAGES)[number]["id"]; type SettingsScope = "global" | "space"; type GlobalTab = + | "control-plane" | "providers" | "models" | "credentials" @@ -372,6 +374,7 @@ const GLOBAL_TABS: ReadonlyArray<{ group?: string; }> = [ { id: "providers", label: "Providers", group: "Global settings" }, + { id: "control-plane", label: "Control Plane", group: "Global settings" }, { id: "models", label: "Models", group: "Global settings" }, { id: "credentials", label: "Credentials", group: "Global settings" }, { id: "mcp", label: "MCP", group: "Global settings" }, @@ -384,7 +387,7 @@ const GLOBAL_TABS: ReadonlyArray<{ const SPACE_TABS: ReadonlyArray<{ id: SpaceTab; label: string }> = [ { id: "runtime", label: "Runtime" }, - { id: "cloud", label: "Cloud" }, + { id: "cloud", label: "Control Plane" }, { id: "providers", label: "Providers" }, { id: "mcp", label: "MCP" }, { id: "plugins", label: "Plugins" }, @@ -3330,6 +3333,8 @@ function GlobalSettingsBody({ ); }; const global = snapshot.globalConfiguration; + if (tab === "control-plane") return ; + if (tab === "plugins") { return ( inline code"); expect(markup).toContain('class="markdown-table-scroll"'); expect(markup).toContain(""); - expect(markup).toContain( - 'aria-label="Scrollable Markdown code block" tabindex="0"', + expect(markup).toMatch( + /aria-label="Scrollable Markdown code block [^"]+" tabindex="0"/, ); expect(markup).toContain(''); }); diff --git a/apps/desktop/src/components/MarkdownContent.tsx b/apps/desktop/src/components/MarkdownContent.tsx index d2e799afd..756c614d5 100644 --- a/apps/desktop/src/components/MarkdownContent.tsx +++ b/apps/desktop/src/components/MarkdownContent.tsx @@ -1,251 +1,27 @@ import { memo } from "react"; -import type { ComponentPropsWithoutRef, ReactNode } from "react"; -import rehypeSanitize from "rehype-sanitize"; -import ReactMarkdown from "react-markdown"; -import type { Components, UrlTransform } from "react-markdown"; -import remarkGfm from "remark-gfm"; -import { BrowserLink, webLink } from "./browser/BrowserLink"; - +import { + MarkdownContent as SharedMarkdownContent, + markdownContentPropsAreEqual, +} from "@colossus/ui/conversation"; +import { BrowserLink } from "./browser/BrowserLink"; +export { + MAX_MARKDOWN_CHARACTERS, + MAX_MARKDOWN_AST_NODES, + markdownContentPropsAreEqual, +} from "@colossus/ui/conversation"; interface MarkdownContentProps { content: string; className?: string; } - -interface MarkdownAstNode { - type: string; - children?: MarkdownAstNode[]; - depth?: number; - value?: string; -} - -interface MarkdownAstRoot extends MarkdownAstNode { - children: MarkdownAstNode[]; -} - -interface MarkdownFile { - value?: unknown; -} - -export const MAX_MARKDOWN_CHARACTERS = 16_384; -export const MAX_MARKDOWN_AST_NODES = 1_000; -const MAX_MARKDOWN_AST_DEPTH = 32; -const STRUCTURE_FALLBACK_NOTICE = - "Complex response shown as plain text for performance and safety."; - -const ALLOWED_ELEMENTS = [ - "a", - "blockquote", - "br", - "code", - "del", - "em", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "hr", - "img", - "input", - "li", - "ol", - "p", - "pre", - "strong", - "table", - "tbody", - "td", - "th", - "thead", - "tr", - "ul", -]; -const REHYPE_PLUGINS = [rehypeSanitize]; - -// Remote images remain blocked. Links become explicit native-browser actions -// only when a browser controller is available; never privileged-view navigation. -// Preserve the validated spelling for native checks against saved instructions. -const safeDestination: UrlTransform = (url, key) => - key === "href" && webLink(url) ? url : null; - -function BlockedImage({ alt }: Pick, "alt">) { - return ( - - Image omitted{alt === undefined || alt === "" ? "" : `: ${alt}`} - - ); -} - -function MarkdownTable({ - children, -}: Pick, "children">) { - return ( -
-
{children}
- - ); -} - -function MarkdownPre({ - children, -}: Pick, "children">) { - return ( -
-      {children}
-    
- ); -} - -function replaceWithPlainText(tree: MarkdownAstRoot, file: MarkdownFile): void { - const content = typeof file.value === "string" ? file.value : ""; - tree.children = [ - { - type: "blockquote", - children: [ - { - type: "paragraph", - children: [{ type: "text", value: STRUCTURE_FALLBACK_NOTICE }], - }, - ], - }, - { type: "code", value: content }, - ]; -} - -/** - * Caps the eventual React tree and keeps response headings beneath the - * assistant article heading. The character cap separately bounds parser work. - */ -function enforceStructureBudget() { - return (tree: MarkdownAstRoot, file: MarkdownFile): void => { - let nodeCount = 0; - let shallowestHeading = 7; - const stack = [{ node: tree as MarkdownAstNode, depth: 0 }]; - - while (stack.length > 0) { - const current = stack.pop(); - if (current === undefined) { - break; - } - - nodeCount += 1; - if ( - nodeCount > MAX_MARKDOWN_AST_NODES || - current.depth > MAX_MARKDOWN_AST_DEPTH - ) { - replaceWithPlainText(tree, file); - return; - } - - if ( - current.node.type === "heading" && - typeof current.node.depth === "number" - ) { - shallowestHeading = Math.min(shallowestHeading, current.node.depth); - } - - for (const child of current.node.children ?? []) { - stack.push({ node: child, depth: current.depth + 1 }); - } - } - - if (shallowestHeading > 6) { - return; - } - - const headingStack = [tree as MarkdownAstNode]; - while (headingStack.length > 0) { - const node = headingStack.pop(); - if (node === undefined) { - break; - } - if (node.type === "heading" && typeof node.depth === "number") { - node.depth = Math.min(6, 4 + node.depth - shallowestHeading); - } - headingStack.push(...(node.children ?? [])); - } - }; -} - -const REMARK_PLUGINS = [remarkGfm, enforceStructureBudget]; - -const MARKDOWN_COMPONENTS: Components = { - a: BrowserLink, - img: BlockedImage, - pre: MarkdownPre, - table: MarkdownTable, - input: ({ checked }) => ( - - ), -}; - -function joinClasses(...classes: Array): string { - return classes - .filter((value) => value !== undefined && value !== "") - .join(" "); -} - -/** - * Renders model-authored Markdown without interpreting HTML, fetching remote - * media, or allowing model output to navigate the privileged desktop webview. - */ -function MarkdownContentView({ +export const MarkdownContent = memo(function MarkdownContent({ content, className, -}: MarkdownContentProps): ReactNode { - const classes = joinClasses("markdown-content", className); - - if (content.length > MAX_MARKDOWN_CHARACTERS) { - return ( -
-

- Large response shown as plain text for performance and safety. -

-
{content}
-
- ); - } - +}: MarkdownContentProps) { return ( -
- - {content} - -
- ); -} - -export function markdownContentPropsAreEqual( - previous: MarkdownContentProps, - next: MarkdownContentProps, -): boolean { - return ( - previous.content === next.content && previous.className === next.className + ); -} - -export const MarkdownContent = memo( - MarkdownContentView, - markdownContentPropsAreEqual, -); -MarkdownContent.displayName = "MarkdownContent"; +}, markdownContentPropsAreEqual); diff --git a/apps/desktop/src/components/RunTimeline.test.ts b/apps/desktop/src/components/RunTimeline.test.ts index 309c9d945..d7ad12577 100644 --- a/apps/desktop/src/components/RunTimeline.test.ts +++ b/apps/desktop/src/components/RunTimeline.test.ts @@ -178,8 +178,8 @@ describe("RunTimeline assistant output", () => { "complete", ); - expect(markup).toContain('class="markdown-content"'); - expect(markup).toContain('

Colossus

'); + expect(markup).toContain('class="shared-markdown markdown-content"'); + expect(markup).toContain("Colossus"); expect(markup).toContain("

Ready

"); expect(markup).toContain("security"); }); diff --git a/apps/desktop/src/components/RunTimeline.tsx b/apps/desktop/src/components/RunTimeline.tsx index d29948cd2..9f3b49689 100644 --- a/apps/desktop/src/components/RunTimeline.tsx +++ b/apps/desktop/src/components/RunTimeline.tsx @@ -1,3 +1,7 @@ +import { + ConversationActivity, + ConversationEntry, +} from "@colossus/ui/conversation"; import { IconAlertTriangle, IconArrowRight, @@ -13,7 +17,6 @@ import { IconInfoCircle, IconListDetails, IconLoader2, - IconMessageCircle, IconPencil, IconPlayerPlay, IconPlayerStop, @@ -28,7 +31,6 @@ import type { ReactNode } from "react"; import { RetryStatus, currentProviderRetry } from "./RetryStatus"; -import colossusMark from "@colossus/ui/assets/colossus-mark.svg"; import { presentNotice, presentToolActivity, @@ -281,29 +283,19 @@ function Message({ message }: { message: SessionMessage }) { if (message.role === "assistant") { return null; } - const label = message.role === "user" ? "You" : readable(message.role); return ( -
- -
-
- {label} - -
-
- {message.content.map((part, index) => ( - - ))} -
-
- -
-
-
+ + } + > + {message.content.map((part, index) => ( + + ))} + ); } @@ -710,50 +702,26 @@ function RunActivity({ ].filter((part): part is string => part !== null); return ( -
0} > - - - - - Colossus - {summaryParts.join(" · ")} - - - {status.tone === "success" ? ( - - {failedActionCount > 0 ? ( - - - ) : null} - -
- {items.map((item) => ( - - ))} -
-
+ {items.map((item) => ( + + ))} + ); } @@ -1217,25 +1185,14 @@ export function RunTimeline({ : `Run ${readable(view.run.status)}.`}

{view.localPrompt !== null && !hasDurableUserMessage ? ( -
- -
-
- You -
-
- {view.localPrompt} -
-
- -
-
-
+ + } + /> ) : null} {userTimelineItems.map((item) => item.type === "update" ? ( @@ -1252,41 +1209,31 @@ export function RunTimeline({ ) : null} {showLiveStatus ? : null} {view.output !== "" ? ( -
- -
-
-

Colossus

- - {isGenerating - ? "Working" - : partialResponse - ? "Partial response" - : "Response"} - -
-
- {isGenerating ? ( - view.output - ) : view.run.mode === "research" ? ( - - ) : ( - - )} - {isGenerating ? ( -
-
+ ) : view.run.mode === "research" ? ( + + ) : ( + + )} +
) : null} {!hasTerminalFeedItem && view.run.terminal !== null ? ( diff --git a/apps/desktop/src/components/SessionWorkspace.test.ts b/apps/desktop/src/components/SessionWorkspace.test.ts index cd76fedb5..197ce82ee 100644 --- a/apps/desktop/src/components/SessionWorkspace.test.ts +++ b/apps/desktop/src/components/SessionWorkspace.test.ts @@ -15,6 +15,12 @@ import { SessionWorkspaceTabs, } from "./SessionWorkspace"; +function classTokens(markup: string): string[][] { + return [...markup.matchAll(/class="([^"]*)"/g)].map((match) => + match[1]!.split(/\s+/), + ); +} + function runView(runId: string, title: string, minute: number): RunView { const run: Run = { runId, @@ -156,7 +162,9 @@ describe("SessionWorkspace", () => { expect(markup).toContain( '', ); - expect(markup).toContain('class="markdown-content session-plan-preview"'); + expect(classTokens(markup)).toContainEqual( + expect.arrayContaining(["markdown-content", "session-plan-preview"]), + ); expect(markup).toContain("

Simple repository orientation plan

"); expect(markup).toContain("Review project foundations"); expect(markup).not.toContain("### Simple repository orientation plan"); @@ -178,8 +186,8 @@ describe("SessionWorkspace", () => { expect(markup).toContain( `Messages ${snapshot.sourceStartSequence}–${snapshot.sourceEndSequence}`, ); - expect(markup).toContain( - 'class="markdown-content session-snapshot-preview"', + expect(classTokens(markup)).toContainEqual( + expect.arrayContaining(["markdown-content", "session-snapshot-preview"]), ); expect(markup).toContain("

Session context

"); expect(markup).toContain( @@ -199,8 +207,8 @@ describe("SessionWorkspace", () => { expect(detail).toContain( `

Messages ${snapshot.sourceStartSequence}–${snapshot.sourceEndSequence}

`, ); - expect(detail).toContain( - 'class="markdown-content session-snapshot-summary"', + expect(classTokens(detail)).toContainEqual( + expect.arrayContaining(["markdown-content", "session-snapshot-summary"]), ); expect(detail).toContain("

Session context

"); expect(detail).not.toContain("## Session context"); diff --git a/apps/desktop/src/components/WorkComposer.tsx b/apps/desktop/src/components/WorkComposer.tsx index 43a563972..39263f30c 100644 --- a/apps/desktop/src/components/WorkComposer.tsx +++ b/apps/desktop/src/components/WorkComposer.tsx @@ -15,6 +15,7 @@ import { IconWorld, IconX, } from "@tabler/icons-react"; +import { ConversationComposerFrame } from "@colossus/ui/conversation"; import { lazy, Suspense, useEffect, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent, ReactNode, RefObject } from "react"; @@ -338,7 +339,7 @@ export function WorkComposer({ } return ( -
) : null} -
+ ); } diff --git a/apps/desktop/src/components/WorkSidebar.tsx b/apps/desktop/src/components/WorkSidebar.tsx index 86cb2a965..04e6b589c 100644 --- a/apps/desktop/src/components/WorkSidebar.tsx +++ b/apps/desktop/src/components/WorkSidebar.tsx @@ -17,14 +17,20 @@ import { IconPlus, IconPointFilled, IconRestore, - IconSearch, IconSettings, IconTerminal2, IconTopologyStar3, - IconWorld, IconX, } from "@tabler/icons-react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { + WorkspaceSidebarGroupHeading, + WorkspaceSidebarHeading, + WorkspaceSidebarScope, + WorkspaceSidebarSearch, + WorkspaceSidebarThreadContent, + WorkspaceSidebarWorkspace, +} from "@colossus/ui"; import { presentRunStatus, @@ -564,124 +570,133 @@ export function WorkSidebar({
-
- Workspaces -
{ - if (!event.currentTarget.contains(event.relatedTarget)) { - event.currentTarget.removeAttribute("open"); - } - }} - > - { + if (!event.currentTarget.contains(event.relatedTarget)) { + event.currentTarget.removeAttribute("open"); + } + }} > - -
- - {selectedSpace !== undefined ? ( - <> -
- Current Workspace - - - {capabilities.tui ? ( +
-
+ {capabilities.tui ? ( + + ) : null} + {capabilities.shellTerminal ? ( + + ) : null} + + ) : null} + {archivedSpaces.length > 0 ? ( + <> +
+ Archived + {archivedSpaces.map((space) => ( + + ))} + + ) : null} +
+ + } + /> {spaceActionFeedback !== null ? (

) : null} -

-
-
- - -
+ + + chooseSearchScope(scope === "all" ? "all" : "space") + } + /> {searchScope === "all" ? (