diff --git a/.dockerignore b/.dockerignore index e95a640d6..b43ffe4a2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,11 +4,15 @@ .githooks/ .agents/ .codex/ +.local/ +.playwright-cli/ # Local runtime state and credentials must never enter the build context. .colossus/ **/.env **/.env.* +**/development-credentials/ +**/.authority-key.env **/.aws/ **/.ssh/ **/.git-credentials diff --git a/.github/workflows/control-plane-image.yml b/.github/workflows/control-plane-image.yml new file mode 100644 index 000000000..63167e92d --- /dev/null +++ b/.github/workflows/control-plane-image.yml @@ -0,0 +1,110 @@ +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: + 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: + 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/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: [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: 30 + 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 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/premerge.yml b/.github/workflows/premerge.yml index a02d8ae9b..185bf5b48 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' @@ -475,7 +500,7 @@ jobs: if: needs.eligibility.result == 'success' needs: eligibility runs-on: windows-latest-l - timeout-minutes: 65 + timeout-minutes: 75 env: CARGO_INCREMENTAL: "0" CARGO_TARGET_DIR: ${{ github.workspace }}\apps\desktop\src-tauri\target @@ -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/.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/.gitignore b/.gitignore index 65e309128..c1e648481 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ apps/desktop/design-qa-*.jpg apps/desktop/research-settings-qa/ apps/desktop/artifact-audit/ .env +**/development-credentials/ +**/.authority-key.env .colossus/config.yaml .colossus/config.dev.yaml .colossus/dev-keys.env 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 7079d131e..1965925d9 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" @@ -461,10 +473,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 +487,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 +514,7 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -502,6 +523,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" @@ -524,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" @@ -601,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" @@ -666,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", @@ -1023,7 +1071,7 @@ name = "colossus-api" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-contracts", "colossus-ports", "colossus-testkit", @@ -1055,7 +1103,7 @@ name = "colossus-api-runtime" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-agent", "colossus-api", "colossus-contracts", @@ -1085,7 +1133,7 @@ name = "colossus-audit" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-contracts", "colossus-journal-redb", "colossus-policy", @@ -1118,7 +1166,7 @@ name = "colossus-bundles" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-contracts", "colossus-policy", "ed25519-dalek", @@ -1138,16 +1186,19 @@ 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", + "colossus-journal-redb", "colossus-native-dictation", "colossus-observability", "colossus-plugins", @@ -1190,11 +1241,128 @@ dependencies = [ "zeroize", ] +[[package]] +name = "colossus-cloud" +version = "0.11.7" +dependencies = [ + "async-trait", + "colossus-cloud-protocol", + "colossus-contracts", + "colossus-ports", + "colossus-sdk", + "hex", + "serde", + "serde_json", + "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" +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 = [ + "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", + "colossus-credentials", + "colossus-home", + "colossus-journal-postgres", + "colossus-journal-redb", + "colossus-network", + "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-home", "colossus-windows-native", "directories", "fs4", @@ -1208,6 +1376,36 @@ dependencies = [ "zeroize", ] +[[package]] +name = "colossus-connector" +version = "0.11.7" +dependencies = [ + "async-trait", + "clap", + "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" @@ -1244,7 +1442,7 @@ name = "colossus-credentials" version = "0.11.7" dependencies = [ "apple-native-keyring-store", - "base64", + "base64 0.22.1", "chacha20poly1305", "colossus-contracts", "colossus-home", @@ -1290,7 +1488,7 @@ dependencies = [ name = "colossus-grpc" version = "0.11.7" dependencies = [ - "base64", + "base64 0.22.1", "colossus-api", "colossus-api-proto", "colossus-contracts", @@ -1393,7 +1591,7 @@ dependencies = [ name = "colossus-journal-redb" version = "0.11.7" dependencies = [ - "base64", + "base64 0.22.1", "chacha20poly1305", "colossus-contracts", "colossus-memory", @@ -1430,7 +1628,7 @@ name = "colossus-mcp" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "chacha20poly1305", "colossus-contracts", "colossus-network", @@ -1468,7 +1666,7 @@ name = "colossus-media" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-contracts", "colossus-ports", "colossus-testkit", @@ -1578,7 +1776,7 @@ dependencies = [ name = "colossus-plugins" version = "0.11.7" dependencies = [ - "base64", + "base64 0.22.1", "colossus-contracts", "colossus-journal-redb", "colossus-network", @@ -1611,7 +1809,7 @@ name = "colossus-policy" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-contracts", "colossus-network", "colossus-ports", @@ -1680,7 +1878,7 @@ name = "colossus-provider" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-codex-auth", "colossus-contracts", "colossus-network", @@ -1726,7 +1924,7 @@ name = "colossus-runtime" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-access", "colossus-agent", "colossus-audit", @@ -1790,9 +1988,10 @@ name = "colossus-sandbox" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "capctl", "colossus-contracts", + "colossus-home", "colossus-network", "colossus-policy", "colossus-ports", @@ -1826,7 +2025,7 @@ name = "colossus-sdk" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-api", "colossus-api-proto", "colossus-api-runtime", @@ -2054,7 +2253,7 @@ name = "colossus-worker" version = "0.11.7" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "colossus-api", "colossus-api-proto", "colossus-api-runtime", @@ -2087,7 +2286,7 @@ dependencies = [ name = "colossus-worker-protocol" version = "0.11.7" dependencies = [ - "base64", + "base64 0.22.1", "colossus-contracts", "getrandom 0.4.3", "hex", @@ -2429,14 +2628,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]] @@ -2452,13 +2675,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", ] @@ -2582,6 +2816,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" @@ -2669,6 +2954,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" @@ -2738,6 +3037,7 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -3387,6 +3687,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" @@ -3466,7 +3772,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", @@ -3731,7 +4037,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", @@ -3759,6 +4065,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" @@ -3912,7 +4227,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", @@ -3980,6 +4295,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" @@ -4166,6 +4484,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" @@ -4367,6 +4695,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" @@ -4476,11 +4820,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", @@ -4631,6 +4976,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" @@ -4732,6 +5108,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" @@ -4778,6 +5163,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" @@ -4832,13 +5229,24 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -4924,6 +5332,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" @@ -5034,7 +5453,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", @@ -5290,7 +5709,7 @@ dependencies = [ "bytemuck", "libm", "num-traits", - "ordered-float", + "ordered-float 5.3.0", "palette", "rand 0.10.2", "rand_xoshiro", @@ -5738,7 +6157,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", @@ -5781,7 +6200,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", @@ -5865,6 +6284,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" @@ -6230,7 +6669,7 @@ checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" dependencies = [ "ahash", "annotate-snippets", - "base64", + "base64 0.22.1", "encoding_rs_io", "getrandom 0.3.4", "granit-parser", @@ -6241,6 +6680,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" @@ -6319,6 +6768,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" @@ -6378,7 +6836,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", @@ -6399,7 +6857,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", @@ -6533,7 +6991,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", @@ -6551,7 +7009,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", @@ -6572,7 +7030,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", @@ -6585,7 +7043,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", @@ -6603,7 +7061,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", @@ -6626,7 +7084,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", @@ -6673,7 +7131,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", @@ -6687,7 +7145,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", @@ -6770,6 +7228,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" @@ -6925,7 +7389,7 @@ checksum = "edde6a10743fff00a4e1a8c9ef020bf5f3cbad301b7d2d39f2b07f123c4eac07" dependencies = [ "aho-corasick", "arc-swap", - "base64", + "base64 0.22.1", "bitpacking", "bon", "byteorder", @@ -7027,7 +7491,7 @@ checksum = "dfadb8526b6da90704feb293b0701a6aae62ea14983143344be2dc5ce30f1d82" dependencies = [ "fnv", "nom", - "ordered-float", + "ordered-float 5.3.0", "serde", "serde_json", ] @@ -7424,7 +7888,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "h2", "http", @@ -7524,10 +7988,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", @@ -7552,6 +8025,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 114c3d209..b064082bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,11 @@ [workspace] resolver = "3" members = [ + "crates/colossus-cloud", + "crates/colossus-cloud-postgres", + "crates/colossus-cloud-server", + "crates/colossus-connector", + "crates/colossus-cloud-protocol", "xtask", "crates/colossus-domain", "crates/colossus-contracts", @@ -69,8 +74,20 @@ 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" } +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/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/appearance.rs b/apps/desktop/native-credential-ui/src/appearance.rs index a6611b9ea..a890c56d9 100644 --- a/apps/desktop/native-credential-ui/src/appearance.rs +++ b/apps/desktop/native-credential-ui/src/appearance.rs @@ -18,7 +18,7 @@ pub enum TextSize { Large, } -#[cfg(any(windows, target_os = "macos"))] +#[cfg(any(windows, target_os = "macos", target_os = "linux"))] impl TextSize { pub(crate) const fn root_pixels(self) -> i32 { match self { @@ -42,28 +42,28 @@ pub struct DialogAppearance { } #[derive(Clone, Copy)] -#[cfg(any(windows, target_os = "macos"))] +#[cfg(any(windows, target_os = "macos", target_os = "linux"))] pub(crate) struct Palette { pub surface: u32, pub control: u32, pub text: u32, pub strong: u32, pub muted: u32, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] pub border: u32, pub accent: u32, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] pub accent_hover: u32, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] pub on_accent: u32, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] pub hover: u32, pub danger: u32, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] pub focus: u32, } -#[cfg(any(windows, target_os = "macos"))] +#[cfg(any(windows, target_os = "macos", target_os = "linux"))] impl DialogAppearance { pub(crate) const fn palette(self) -> Palette { match self.color_scheme { @@ -74,20 +74,20 @@ impl DialogAppearance { strong: 0xf7_fa_ff, muted: 0x91_a2_b8, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] border: 0x2b_42_60, accent: 0x43_89_ff, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] accent_hover: 0x55_94_ff, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] on_accent: 0x07_10_1d, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] hover: 0x18_2b_41, danger: 0xff_ad_b3, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] focus: 0x45_c8_ed, }, ColorScheme::System | ColorScheme::Light => Palette { @@ -97,20 +97,20 @@ impl DialogAppearance { strong: 0x10_20_33, muted: 0x52_65_7a, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] border: 0xb8_c7_d8, accent: 0x25_63_d9, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] accent_hover: 0x1d_4f_b8, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] on_accent: 0xff_ff_ff, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] hover: 0xe6_ed_f6, danger: 0x96_2d_3a, - #[cfg(windows)] + #[cfg(any(windows, target_os = "linux"))] focus: 0x08_7d_a1, }, } 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..174776583 --- /dev/null +++ b/apps/desktop/native-credential-ui/src/linux.rs @@ -0,0 +1,256 @@ +//! 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; + +mod styling; + +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; + }; + let _ = create(parent.upcast_ref(), cancelled, completion, appearance); +} + +fn create( + parent: >k::Window, + cancelled: Arc, + completion: Completion, + appearance: DialogAppearance, +) -> Result<(gtk::Dialog, gtk::Entry), PromptError> { + let dialog = gtk::Dialog::builder() + .title("Save a credential") + .transient_for(parent) + .modal(true) + .destroy_with_parent(true) + .default_width(styling::pixels(appearance, 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(); + styling::content(&content, appearance); + 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); + description + .style_context() + .add_class("credential-description"); + let label = gtk::Label::new(Some("_Token")); + label.set_use_underline(true); + label.set_xalign(0.0); + label.style_context().add_class("credential-label"); + 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); + error.style_context().add_class("credential-error"); + content.add(&description); + content.add(&label); + content.add(&entry); + content.add(&error); + if styling::apply(&dialog, appearance).is_err() { + dialog.close(); + completion.finish(Err(PromptError::Unavailable)); + return Err(PromptError::Unavailable); + } + 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(); + Ok((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(); + assert_appearance(&parent); + let (completion, mut received) = Completion::acquire().unwrap(); + let (dialog, entry) = create( + &parent, + Arc::new(AtomicBool::new(false)), + completion, + DialogAppearance::default(), + ) + .unwrap(); + 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, + DialogAppearance::default(), + ) + .unwrap(); + dialog.response(gtk::ResponseType::Cancel); + assert_eq!( + wait_for_completion(&mut received).unwrap_err(), + PromptError::Cancelled + ); + parent.close(); + } + + fn assert_appearance(parent: >k::Window) { + use crate::{ColorScheme, TextSize}; + let parent_color = parent.style_context().color(gtk::StateFlags::NORMAL); + for scheme in [ColorScheme::Light, ColorScheme::Dark] { + let mut sizes = Vec::new(); + let mut widths = Vec::new(); + for text_size in [TextSize::Compact, TextSize::Comfortable, TextSize::Large] { + let appearance = DialogAppearance { + color_scheme: scheme, + text_size, + }; + let (completion, mut received) = Completion::acquire().unwrap(); + let (dialog, entry) = create( + parent, + Arc::new(AtomicBool::new(false)), + completion, + appearance, + ) + .unwrap(); + let context = entry.style_context(); + sizes.push(context.font(gtk::StateFlags::NORMAL).size()); + widths.push(dialog.default_size().0); + let color = context.color(gtk::StateFlags::NORMAL); + let expected = appearance.palette().text; + for (actual, shift) in [(color.red(), 16), (color.green(), 8), (color.blue(), 0)] { + assert!((actual - f64::from((expected >> shift) & 0xff) / 255.0).abs() < 0.005); + } + assert_eq!( + parent.style_context().color(gtk::StateFlags::NORMAL), + parent_color + ); + assert!(!gtk::prelude::EntryExt::is_visible(&entry)); + dialog.response(gtk::ResponseType::Cancel); + assert_eq!( + wait_for_completion(&mut received).unwrap_err(), + PromptError::Cancelled + ); + } + assert!(sizes[0] < sizes[1] && sizes[1] < sizes[2]); + assert!(widths[0] < widths[1] && widths[1] < widths[2]); + } + } +} diff --git a/apps/desktop/native-credential-ui/src/linux/styling.rs b/apps/desktop/native-credential-ui/src/linux/styling.rs new file mode 100644 index 000000000..c182197ab --- /dev/null +++ b/apps/desktop/native-credential-ui/src/linux/styling.rs @@ -0,0 +1,115 @@ +//! Dialog-local GTK appearance; no application settings or credential state change. + +use crate::{ColorScheme, DialogAppearance}; +use gtk::prelude::*; +use std::fmt::Write as _; + +pub(super) fn pixels(appearance: DialogAppearance, logical: i32) -> i32 { + (logical * appearance.text_size.root_pixels() + 8) / 16 +} + +pub(super) fn content(content: >k::Box, appearance: DialogAppearance) { + content.set_spacing(pixels(appearance, 12)); + content.set_margin_start(pixels(appearance, 24)); + content.set_margin_end(pixels(appearance, 24)); + content.set_margin_top(pixels(appearance, 20)); + content.set_margin_bottom(pixels(appearance, 20)); +} + +pub(super) fn apply(dialog: >k::Dialog, appearance: DialogAppearance) -> Result<(), ()> { + if let Some(button) = dialog.widget_for_response(gtk::ResponseType::Accept) { + button.style_context().add_class("suggested-action"); + } + let high_contrast = gtk::Settings::default() + .and_then(|settings| settings.gtk_theme_name()) + .is_some_and(|name| { + let name = name.to_ascii_lowercase(); + name.contains("highcontrast") || name.contains("high-contrast") + }); + let provider = gtk::CssProvider::new(); + provider + .load_from_data(stylesheet(appearance, high_contrast).as_bytes()) + .map_err(|_| ())?; + install(dialog.upcast_ref(), &provider); + Ok(()) +} + +fn install(widget: >k::Widget, provider: >k::CssProvider) { + widget + .style_context() + .add_provider(provider, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION); + if let Some(container) = widget.downcast_ref::() { + // Include native internal children, such as the action-area buttons. + container.forall(|child| install(child, provider)); + } +} + +fn stylesheet(appearance: DialogAppearance, high_contrast: bool) -> String { + let scale = f64::from(appearance.text_size.root_pixels()) / 16.0; + let mut css = format!( + "* {{ font-size: {}px; }} entry {{ font-size: {}px; }}", + 14.0 * scale, + 16.0 * scale + ); + // Keep OS/user colors for System and accessibility themes. The application + // priority also lets explicit GTK user CSS take precedence over app colors. + if appearance.color_scheme == ColorScheme::System || high_contrast { + return css; + } + let palette = appearance.palette(); + let _ = write!( + css, + " +window {{ background-color: #{surface:06x}; color: #{text:06x}; }} +label {{ color: #{text:06x}; }} +label.credential-label {{ color: #{strong:06x}; }} +label.credential-description {{ color: #{muted:06x}; }} +label.credential-error {{ color: #{danger:06x}; }} +entry, button {{ background-image: none; background-color: #{control:06x}; color: #{text:06x}; border-color: #{border:06x}; }} +button:hover {{ background-color: #{hover:06x}; }} +entry:focus, button:focus {{ border-color: #{focus:06x}; outline-color: #{focus:06x}; }} +button.suggested-action {{ background-color: #{accent:06x}; color: #{on_accent:06x}; border-color: #{accent:06x}; }} +button.suggested-action:hover {{ background-color: #{accent_hover:06x}; }} +entry selection {{ background-color: #{accent:06x}; color: #{on_accent:06x}; }} +", + surface = palette.surface, + text = palette.text, + strong = palette.strong, + muted = palette.muted, + danger = palette.danger, + control = palette.control, + border = palette.border, + hover = palette.hover, + focus = palette.focus, + accent = palette.accent, + on_accent = palette.on_accent, + accent_hover = palette.accent_hover, + ); + css +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::TextSize; + + #[test] + fn system_and_high_contrast_preserve_native_colors_with_requested_text_scale() { + let appearance = DialogAppearance { + color_scheme: ColorScheme::Dark, + text_size: TextSize::Large, + }; + let high_contrast = stylesheet(appearance, true); + let system = stylesheet( + DialogAppearance { + color_scheme: ColorScheme::System, + ..appearance + }, + false, + ); + assert_eq!(system, high_contrast); + assert!(system.contains("entry { font-size: 18px; }")); + assert!(!system.contains("color:")); + assert!(!system.contains("background")); + } +} 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-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 9ac0754a4..718e1d46b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -30,9 +30,11 @@ "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": { + "@colossus/ui": "file:../ui", "@shikijs/core": "4.3.1", "@shikijs/engine-javascript": "4.3.1", "@shikijs/langs": "4.3.1", @@ -45,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 45a292c5b..66b795f49 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,11 +991,27 @@ dependencies = [ "tonic-prost-build", ] +[[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" dependencies = [ "base64 0.22.1", + "colossus-home", "colossus-windows-native", "directories", "fs4", @@ -919,6 +1025,35 @@ dependencies = [ "zeroize", ] +[[package]] +name = "colossus-connector" +version = "0.11.7" +dependencies = [ + "async-trait", + "clap", + "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 +1103,7 @@ dependencies = [ "apple-native-keyring-store", "base64 0.22.1", "colossus-codex-auth", + "colossus-connector", "colossus-contracts", "colossus-credentials", "colossus-home", @@ -983,6 +1119,7 @@ dependencies = [ "colossus-windows-native", "colossus-worker-protocol", "directories", + "fs4", "futures-util", "getrandom 0.4.3", "git2", @@ -1139,6 +1276,7 @@ name = "colossus-native-credential-ui" version = "0.1.0" dependencies = [ "colossus-contracts", + "gtk", "objc2", "objc2-app-kit", "objc2-foundation", @@ -1262,6 +1400,7 @@ dependencies = [ "rustls", "security-framework", "serde", + "serde_json", "sha2", "sysinfo", "tempfile", @@ -3164,6 +3303,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 +4378,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 +7594,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..742ab1e1d 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"] } @@ -85,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 4ff378d24..10bc0de50 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -109,6 +109,15 @@ const COMMANDS: &[&str] = &[ "set_terminal_enabled", "connect_colossus", "connection_status", + "cloud_enroll", + "cloud_connect", + "cloud_status", + "control_plane_profiles", + "save_control_plane_profiles", + "cloud_set_workspace_sharing", + "cloud_disconnect", + "cloud_forget", + "cloud_revoke", "create_run", "choose_run_attachment", "read_artifact_content", @@ -217,6 +226,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 @@ -235,7 +256,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 49f37860b..9e08b9ba2 100644 --- a/apps/desktop/src-tauri/capabilities/main-chat.json +++ b/apps/desktop/src-tauri/capabilities/main-chat.json @@ -117,6 +117,15 @@ "allow-set-terminal-enabled", "allow-connect-colossus", "allow-connection-status", + "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", "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..71620f894 --- /dev/null +++ b/apps/desktop/src-tauri/src/cloud_connector.rs @@ -0,0 +1,668 @@ +//! 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, + atomic::{AtomicBool, Ordering}, +}; +use tauri::{AppHandle, State}; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; +use tokio::sync::watch; +use zeroize::Zeroizing; + +mod sharing; +mod status_cache; + +pub(crate) struct CloudSession { + config: ConnectionConfig, + status: watch::Receiver, + shutdown: watch::Sender, + task: Option>, + alive: Arc, + sharing_recovery_required: bool, + shutdown_confirmed: bool, + sharing_restart_required: bool, +} +impl Drop for CloudSession { + fn drop(&mut self) { + self.alive.store(false, Ordering::Release); + let _ = self.shutdown.send(true); + if let Some(task) = &self.task { + task.abort(); + } + } +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct CloudStatus { + target_id: String, + status: ConnectorStatus, + node_id: Option, + project_id: Option, + endpoint: Option, + host_id: Option, + workspace_id: Option, + shared_sessions: bool, + shared_continuation: bool, + sharing_supported: bool, + #[serde(default)] + sharing_recovery_required: sharing::Requirement, + #[serde(default)] + sharing_restart_required: sharing::Requirement, +} +#[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()), + 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 + }), + sharing_recovery_required: sharing::Requirement::Clear, + sharing_restart_required: sharing::Requirement::Clear, + } +} + +impl CloudSession { + fn snapshot(&self, target: String) -> CloudStatus { + let status = if self.alive.load(Ordering::Acquire) { + *self.status.borrow() + } else if *self.status.borrow() == ConnectorStatus::Revoked { + ConnectorStatus::Revoked + } else { + ConnectorStatus::Disconnected + }; + let mut summary = dto(target, Some(&self.config), status); + summary.sharing_recovery_required = self.sharing_recovery_required.into(); + summary.sharing_restart_required = self.sharing_restart_required.into(); + summary + } +} + +fn disconnected_summary( + target: String, + config: Option<&ConnectionConfig>, + status: ConnectorStatus, +) -> CloudStatus { + let mut summary = dto(target, config, status); + // A saved choice is not a runtime observation. Explicit reconnect reconciles + // it; disconnected metadata must not claim that permission is currently applied. + summary.sharing_recovery_required = summary.sharing_supported.into(); + summary +} +async fn cache_status(status: CloudStatus) -> Result { + let summary = status.clone(); + let retained = tauri::async_runtime::spawn_blocking(move || { + if summary.node_id.is_some() { + status_cache::remember(summary) + } else { + status_cache::forget(summary.target_id) + } + }) + .await; + if !matches!(retained, Ok(Ok(()))) { + // Presentation metadata cannot prevent or reinterpret an authorized lifecycle action. + eprintln!("Control Plane connection summary could not be retained."); + } + Ok(status) +} + +async fn disconnected_status( + target: &str, + config: Option<&ConnectionConfig>, + removed: Option, +) -> ConnectorStatus { + let Some(config) = config else { + return ConnectorStatus::Disconnected; + }; + if config.revoked || removed == Some(ConnectorStatus::Revoked) { + return ConnectorStatus::Revoked; + } + let target = target.to_owned(); + let node = config.node_id.clone(); + // A read/disconnect does not undo a terminal remote observation. This cache + // affects presentation only; reconnect still checks the native enrollment. + tauri::async_runtime::spawn_blocking(move || { + status_cache::retained_disconnected_status(&target, &node) + }) + .await + .ok() + .and_then(Result::ok) + .unwrap_or(ConnectorStatus::Disconnected) +} +pub(crate) async fn global_connections( + state: &AppState, +) -> Result, CommandErrorDto> { + let mut summaries = tauri::async_runtime::spawn_blocking(status_cache::load) + .await + .map_err(|_| failure("Connection summaries are unavailable."))??; + let sessions = state.cloud_connections.lock().await; + for summary in summaries.values_mut() { + if summary.sharing_supported { + summary.sharing_recovery_required = sharing::Requirement::Required; + } + // Restart fencing is process-local. After restart, an explicit connection + // may reconcile the saved choice; the cache supplies no authorization. + summary.sharing_restart_required = sharing::Requirement::Clear; + } + for (target, session) in sessions.iter() { + summaries.insert(target.clone(), session.snapshot(target.clone())); + } + let mut connections = summaries.into_values().collect::>(); + connections.sort_by(|left, right| left.target_id.cmp(&right.target_id)); + Ok(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(title) + .buttons(MessageDialogButtons::OkCancelCustom( + action, + "Cancel".into(), + )) + .blocking_show() + }) + .await + .map_err(|_| failure("Native connection confirmation is unavailable."))?; + if accepted { + Ok(()) + } else { + Err(failure("The native confirmation was cancelled.")) + } +} +async fn reconcile_managed_sharing( + state: &AppState, + target: &str, + config: &ConnectionConfig, + primary: &dyn AgentRunClient, +) -> Result<(), CommandErrorDto> { + // Every explicit Managed Local start reconciles the protected saved choice, + // including after restart or cache loss. Metadata never supplies authority. + let result = sharing::update( + &state.cloud_connections, + target, + config, + sharing::saved_request(config), + |request| primary.set_workspace_sharing(request), + || std::future::ready(Ok(config.clone())), + ) + .await; + if let Err(error) = result { + if let Some(summary) = sharing::summary(&state.cloud_connections, target).await { + cache_status(summary).await?; + } + return Err(error); + } + Ok(()) +} +fn spawn_connector( + connector: RuntimeConnector, + receiver: watch::Receiver, + status: watch::Sender, + terminal_target: String, + terminal_node: String, + terminal_alive: Arc, +) -> tauri::async_runtime::JoinHandle<()> { + tauri::async_runtime::spawn(async move { + let failed = connector.run(receiver, status.clone()).await.is_err(); + let revoked = *status.borrow() == ConnectorStatus::Revoked; + if failed && !revoked { + status.send_replace(ConnectorStatus::Disconnected); + } + if revoked { + let saved = tauri::async_runtime::spawn_blocking(move || { + status_cache::remember_remote_revocation( + terminal_target, + terminal_node, + terminal_alive, + ) + }) + .await; + if !matches!(saved, Ok(Ok(()))) { + eprintln!("Control Plane revocation summary could not be retained."); + } + } + }) +} +async fn start( + state: &AppState, + target: String, + mut config: ConnectionConfig, + key: Zeroizing, + runs: Arc, +) -> Result { + sharing::check_start(&state.cloud_connections, &target).await?; + let lease = state + .selected_target(&target) + .await + .ok_or_else(|| failure("Select the workspace before connecting Control Plane."))?; + 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.")); + } + 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()); + if lease.target.consent == crate::state::TargetConsentContext::ManagedLocal { + reconcile_managed_sharing( + state, + &target, + &config, + lease.target.client.agent_runs().as_ref(), + ) + .await?; + } + 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.snapshot(target.clone()).status, + ConnectorStatus::Connecting + | ConnectorStatus::Connected + | ConnectorStatus::Reconnecting + ) { + return Err(failure( + "Disconnect the existing Control Plane connection first.", + )); + } + connections.remove(&target); + } + if connections.len() >= 16 { + return Err(failure("Desktop Control Plane connection limit reached.")); + } + drop(connections); + // Invalidate the previous session before saving a replacement summary. Its + // terminal callback must never overwrite this enrollment or resurrect forget. + cache_status(dto( + target.clone(), + Some(&config), + ConnectorStatus::Disconnected, + )) + .await?; + let alive = Arc::new(AtomicBool::new(true)); + let task = spawn_connector( + connector, + receiver, + status, + target.clone(), + config.node_id.clone(), + Arc::clone(&alive), + ); + let result = dto(target.clone(), Some(&config), ConnectorStatus::Connecting); + state.cloud_connections.lock().await.insert( + target, + CloudSession { + config, + status: updates, + shutdown, + task: Some(task), + alive, + sharing_recovery_required: false, + shutdown_confirmed: false, + sharing_restart_required: false, + }, + ); + 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; + sharing::ensure_recoverable(&state.cloud_connections, &request.target_id).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 Control Plane 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 (_, 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_or_else( + || "Desktop workspace".into(), + |workspace| workspace.display_name, + ), + 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."))? + .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; + sharing::ensure_recoverable(&state.cloud_connections, &target_id).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, + sharing::reconnect_message( + &config, + lease.target.consent == crate::state::TargetConsentContext::ManagedLocal, + ), + ) + .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(session.snapshot(target_id)); + } + 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); + let status = disconnected_status(&target_id, config.as_ref(), None).await; + cache_status(disconnected_summary(target_id, config.as_ref(), status)).await +} +#[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; + sharing::ensure_recoverable(&state.cloud_connections, &target_id).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 (previous_config, key) = 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.", + previous_config.project_id,previous_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?; + } + let primary = lease.target.client.agent_runs(); + let update = sharing::update( + &state.cloud_connections, + &target_id, + &previous_config, + sharing::request(enabled, allow_continuation), + |request| primary.set_workspace_sharing(request), + || enrollment.set_sharing(enabled, allow_continuation), + ) + .await; + let (config, status) = match update { + Ok(result) => result, + Err(error) => { + if let Some(summary) = sharing::summary(&state.cloud_connections, &target_id).await { + cache_status(summary).await?; + } + return Err(error); + } + }; + // Refresh native inventory only after both writes succeeded. Accepted runtime + // work is independent of the stopped connector and remains running locally. + if matches!( + status, + ConnectorStatus::Connected | ConnectorStatus::Connecting | ConnectorStatus::Reconnecting + ) { + let key = if config.certificate_pem == previous_config.certificate_pem { + key + } else { + let reader = enrollment.clone(); + let (current, key) = tokio::task::spawn_blocking(move || reader.load()) + .await + .map_err(|_| failure("Enrollment vault unavailable."))? + .map_err(failure)? + .ok_or_else(|| failure("Enrollment unavailable."))?; + if current.certificate_pem != config.certificate_pem { + return Err(failure( + "Enrollment changed while updating sharing; reconnect.", + )); + } + key + }; + let runs = lease + .target + .client + .connector_runs() + .ok_or_else(|| failure("Dedicated cloud grant unavailable."))?; + start(&state, target_id, config, key, runs).await + } else { + let summary = sharing::summary(&state.cloud_connections, &target_id) + .await + .ok_or_else(|| failure("Sharing confirmation is unavailable."))?; + cache_status(summary).await + } +} +#[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; + sharing::ensure_recoverable(&state.cloud_connections, &target_id).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 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 || { + 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); + cache_status(dto(target_id, config.as_ref(), ConnectorStatus::Revoked)).await +} +#[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; + sharing::ensure_recoverable(&state.cloud_connections, &target_id).await?; + let session = state.cloud_connections.lock().await.remove(&target_id); + let removed_status = session.as_ref().map(|session| *session.status.borrow()); + 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 = disconnected_status(&target_id, config.as_ref(), removed_status).await; + cache_status(disconnected_summary(target_id, config.as_ref(), status)).await +} +#[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; + sharing::ensure_recoverable(&state.cloud_connections, &target_id).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)?; + cache_status(dto(target_id, None, ConnectorStatus::Disconnected)).await +} diff --git a/apps/desktop/src-tauri/src/cloud_connector/sharing.rs b/apps/desktop/src-tauri/src/cloud_connector/sharing.rs new file mode 100644 index 000000000..57495166c --- /dev/null +++ b/apps/desktop/src-tauri/src/cloud_connector/sharing.rs @@ -0,0 +1,326 @@ +//! Quiesce disclosure before updating the separately durable runtime and enrollment. +use super::{CloudSession, CloudStatus, failure}; +use crate::dto::CommandErrorDto; +use colossus_connector::{ConnectionConfig, ConnectorStatus, WorkspaceSharing}; +use colossus_sdk::{ApiErrorCode, ApiResult, SetWorkspaceSharingRequest, WorkspaceSharingState}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::{ + collections::HashMap, + future::Future, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; +use tokio::sync::{Mutex, watch}; + +type Connections = Mutex>; +const STOP_TIMEOUT: Duration = Duration::from_secs(5); +#[derive(Clone, Copy)] +struct Deadlines { + runtime: Duration, + persistence: Duration, +} +const UPDATE_DEADLINES: Deadlines = Deadlines { + runtime: Duration::from_secs(15), + persistence: Duration::from_secs(30), +}; + +/// Native confirmation requirements retain the existing strict boolean wire shape. +#[derive(Clone, Copy, Default)] +pub(super) enum Requirement { + #[default] + Clear, + Required, +} + +impl Requirement { + pub(super) fn is_required(self) -> bool { + matches!(self, Self::Required) + } +} + +impl From for Requirement { + fn from(required: bool) -> Self { + if required { + Self::Required + } else { + Self::Clear + } + } +} + +impl Serialize for Requirement { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_bool(self.is_required()) + } +} + +impl<'de> Deserialize<'de> for Requirement { + fn deserialize>(deserializer: D) -> Result { + bool::deserialize(deserializer).map(Self::from) + } +} + +pub(super) fn request(enabled: bool, continuation: bool) -> SetWorkspaceSharingRequest { + SetWorkspaceSharingRequest { + recipient_application_id: "app:colossus-desktop-cloud".into(), + enabled, + allow_continuation: enabled && continuation, + } +} + +pub(super) fn saved_request(config: &ConnectionConfig) -> SetWorkspaceSharingRequest { + request( + config + .inventory + .as_ref() + .is_some_and(|inventory| inventory.sharing == WorkspaceSharing::SharedVisibleSessions), + config.shared_continuation, + ) +} + +pub(super) fn saved_choice(config: &ConnectionConfig) -> &'static str { + match saved_request(config) { + SetWorkspaceSharingRequest { enabled: false, .. } => "keep Desktop history private", + SetWorkspaceSharingRequest { + allow_continuation: true, + .. + } => "share Desktop history and allow continuation", + _ => "share Desktop history for viewing", + } +} + +pub(super) fn reconnect_message(config: &ConnectionConfig, managed: bool) -> String { + if managed { + format!( + "Reconnect runtime {} to {}? The last saved sharing choice is to {}. Reconnecting reapplies that choice before synchronizing; it also reconciles any incomplete sharing update.", + config.instance_id, + config.endpoint, + saved_choice(config) + ) + } else { + format!( + "Reconnect runtime {} to {}?", + config.instance_id, config.endpoint + ) + } +} + +fn recovery_error(stage: &str, disabled: bool) -> CommandErrorDto { + CommandErrorDto { + code: "sharing_reconciliation_required".into(), + message: format!( + "{stage} The Control Plane connection is stopped. {} Reconnect to reapply the last saved sharing choice, or save a new choice.", + if disabled { + "Runtime sharing was disabled." + } else { + "Runtime sharing could not be verified." + } + ), + retryable: false, + outcome_unknown: !disabled, + violations: Vec::new(), + } +} + +fn uncertain_error() -> CommandErrorDto { + CommandErrorDto { + code: "sharing_reconciliation_required".into(), + message: "The sharing outcome is uncertain and synchronization is stopped. Restart Desktop before reconnecting or updating sharing.".into(), + retryable: false, + outcome_unknown: true, + violations: Vec::new(), + } +} + +pub(super) async fn ensure_recoverable( + connections: &Connections, + target: &str, +) -> Result<(), CommandErrorDto> { + if connections + .lock() + .await + .get(target) + .is_some_and(|session| session.sharing_restart_required) + { + Err(uncertain_error()) + } else { + Ok(()) + } +} + +async fn mark_uncertain(connections: &Connections, target: &str) { + if let Some(session) = connections.lock().await.get_mut(target) { + session.sharing_restart_required = true; + } +} + +async fn quiesce( + connections: &Connections, + target: &str, + config: &ConnectionConfig, + timeout: Duration, +) -> Result { + ensure_recoverable(connections, target).await?; + let session = connections.lock().await.remove(target); + let mut session = session.unwrap_or_else(|| { + let (shutdown, _) = watch::channel(false); + let (_, status) = watch::channel(ConnectorStatus::Disconnected); + CloudSession { + config: config.clone(), + status, + shutdown, + task: None, + alive: Arc::new(AtomicBool::new(false)), + sharing_recovery_required: true, + shutdown_confirmed: true, + sharing_restart_required: false, + } + }); + let previous = session.snapshot(target.to_owned()).status; + session.sharing_recovery_required = true; + session.alive.store(false, Ordering::Release); + let _ = session.shutdown.send(true); + let stopped = if let Some(mut task) = session.task.take() { + match tokio::time::timeout(timeout, &mut task).await { + Ok(Ok(())) => true, + Ok(Err(_)) => false, + Err(_) => { + task.abort(); + session.task = Some(task); + false + } + } + } else { + session.shutdown_confirmed + }; + session.shutdown_confirmed = stopped; + session.sharing_restart_required |= !stopped; + // The retained record has no active task after quiescence. It exposes uncertainty + // immediately, even if the metadata cache itself cannot be written. + connections.lock().await.insert(target.to_owned(), session); + if stopped { + Ok(previous) + } else { + Err(CommandErrorDto { code: "sharing_reconciliation_required".into(), message: "Connector termination could not be confirmed. Sharing was not changed; restart Desktop before changing sharing.".into(), retryable: false, outcome_unknown: true, violations: Vec::new() }) + } +} + +pub(super) async fn update( + connections: &Connections, + target: &str, + config: &ConnectionConfig, + desired: SetWorkspaceSharingRequest, + commit: C, + persist: P, +) -> Result<(ConnectionConfig, ConnectorStatus), CommandErrorDto> +where + C: FnMut(SetWorkspaceSharingRequest) -> CF, + CF: Future>, + P: FnOnce() -> PF, + PF: Future>, +{ + update_with_deadlines( + connections, + target, + config, + desired, + commit, + persist, + UPDATE_DEADLINES, + ) + .await +} + +async fn update_with_deadlines( + connections: &Connections, + target: &str, + config: &ConnectionConfig, + desired: SetWorkspaceSharingRequest, + mut commit: C, + persist: P, + deadlines: Deadlines, +) -> Result<(ConnectionConfig, ConnectorStatus), CommandErrorDto> +where + C: FnMut(SetWorkspaceSharingRequest) -> CF, + CF: Future>, + P: FnOnce() -> PF, + PF: Future>, +{ + let previous = quiesce(connections, target, config, STOP_TIMEOUT).await?; + let committed = tokio::time::timeout(deadlines.runtime, commit(desired)).await; + if !matches!(committed, Ok(Ok(_))) { + let uncertain = committed.is_err() + || matches!(committed, Ok(Err(ref error)) if matches!(error.code, ApiErrorCode::OutcomeUnknown | ApiErrorCode::Unavailable | ApiErrorCode::Cancelled | ApiErrorCode::Internal)); + let disabled = matches!( + tokio::time::timeout(deadlines.runtime, commit(request(false, false))).await, + Ok(Ok(_)) + ); + if uncertain || !disabled { + mark_uncertain(connections, target).await; + return Err(uncertain_error()); + } + return Err(recovery_error( + "The runtime sharing update failed.", + disabled, + )); + } + let saved = match tokio::time::timeout(deadlines.persistence, persist()).await { + Ok(Ok(saved)) => saved, + result => { + // Persistence may have committed before its acknowledgement failed. Do + // not recreate an old enabled grant as a guessed rollback. + let disabled = matches!( + tokio::time::timeout(deadlines.runtime, commit(request(false, false))).await, + Ok(Ok(_)) + ); + if result.is_err() || !disabled { + mark_uncertain(connections, target).await; + return Err(uncertain_error()); + } + return Err(recovery_error( + "The sharing choice could not be saved.", + disabled, + )); + } + }; + if let Some(session) = connections.lock().await.get_mut(target) { + session.config = saved.clone(); + session.sharing_recovery_required = false; + } + Ok((saved, previous)) +} + +pub(super) async fn summary(connections: &Connections, target: &str) -> Option { + connections + .lock() + .await + .get(target) + .map(|session| session.snapshot(target.to_owned())) +} + +pub(super) async fn check_start( + connections: &Connections, + target: &str, +) -> Result<(), CommandErrorDto> { + ensure_recoverable(connections, target).await?; + if connections.lock().await.get(target).is_some_and(|session| { + matches!( + session.snapshot(target.to_owned()).status, + ConnectorStatus::Connecting + | ConnectorStatus::Connected + | ConnectorStatus::Reconnecting + ) + }) { + Err(failure( + "Disconnect the existing Control Plane connection first.", + )) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/desktop/src-tauri/src/cloud_connector/sharing/tests.rs b/apps/desktop/src-tauri/src/cloud_connector/sharing/tests.rs new file mode 100644 index 000000000..57818745d --- /dev/null +++ b/apps/desktop/src-tauri/src/cloud_connector/sharing/tests.rs @@ -0,0 +1,657 @@ +use super::*; +use colossus_connector::{DeploymentKind, EnrollmentStore, RuntimeInventory}; +use colossus_contracts::VaultRecord; +use colossus_ports::{CredentialError, CredentialKey, CredentialVault}; +use std::{ + future::ready, + sync::{Mutex as StdMutex, atomic::AtomicUsize}, +}; + +fn config(enabled: bool) -> ConnectionConfig { + ConnectionConfig { + endpoint: "https://control-plane.example".into(), + project_id: "synthetic-project".into(), + node_id: "synthetic-node".into(), + instance_id: "synthetic-instance".into(), + certificate_pem: "synthetic-certificate".into(), + ca_pem: "synthetic-ca".into(), + capabilities: Vec::new(), + revoked: false, + inventory: Some(RuntimeInventory { + policy: None, + host_id: "synthetic-host".into(), + host_label: "Synthetic host".into(), + platform: "macos".into(), + deployment_kind: DeploymentKind::Desktop, + workspace_id: "synthetic-workspace".into(), + workspace_label: "Synthetic workspace".into(), + sharing: if enabled { + WorkspaceSharing::SharedVisibleSessions + } else { + WorkspaceSharing::CloudOwned + }, + }), + shared_continuation: enabled, + } +} + +struct Vault { + record: StdMutex, + fail_write: bool, + commit_before_failure: bool, + writes: AtomicUsize, +} +impl Vault { + fn new(config: &ConnectionConfig, fail_write: bool, commit_before_failure: bool) -> Arc { + let record = serde_json::json!({ "config": config, "pending": { "client_key_pem": "synthetic-private-key", "csr_pem": "synthetic-csr", "token": "", "enrollment_url": "https://control-plane.example/api/enroll", "instance_id": config.instance_id }, "rotation": null }); + Arc::new(Self { + record: StdMutex::new(VaultRecord::new(serde_json::to_vec(&record).unwrap()).unwrap()), + fail_write, + commit_before_failure, + writes: AtomicUsize::new(0), + }) + } +} +impl CredentialVault for Vault { + fn read(&self, _: &CredentialKey) -> Result, CredentialError> { + Ok(Some( + VaultRecord::new(self.record.lock().unwrap().expose().to_vec()).unwrap(), + )) + } + fn write(&self, _: &CredentialKey, record: &VaultRecord) -> Result<(), CredentialError> { + self.writes.fetch_add(1, Ordering::SeqCst); + if !self.fail_write || self.commit_before_failure { + *self.record.lock().unwrap() = VaultRecord::new(record.expose().to_vec()).unwrap(); + } + if self.fail_write { + Err(CredentialError::Io) + } else { + Ok(()) + } + } + fn delete(&self, _: &CredentialKey) -> Result<(), CredentialError> { + panic!("sharing must not delete enrollment") + } +} + +struct TaskLifetime(Arc); +impl Drop for TaskLifetime { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } +} + +async fn active(config: &ConnectionConfig) -> (Connections, Arc, Arc) { + active_with_stop(config, true).await +} + +async fn active_with_stop( + config: &ConnectionConfig, + cooperative: bool, +) -> (Connections, Arc, Arc) { + let (shutdown, mut receiver) = watch::channel(false); + let (_, status) = watch::channel(ConnectorStatus::Connected); + let alive = Arc::new(AtomicBool::new(true)); + let dropped = Arc::new(AtomicBool::new(false)); + let lifetime = TaskLifetime(Arc::clone(&dropped)); + let (started, started_receiver) = tokio::sync::oneshot::channel(); + let task = tauri::async_runtime::spawn(async move { + let _lifetime = lifetime; + started.send(()).unwrap(); + if cooperative { + let _ = receiver.changed().await; + } else { + std::future::pending::<()>().await; + } + }); + started_receiver.await.unwrap(); + let connections = Mutex::new(HashMap::from([( + "target".into(), + CloudSession { + config: config.clone(), + status, + shutdown, + task: Some(task), + alive: Arc::clone(&alive), + sharing_recovery_required: false, + shutdown_confirmed: false, + sharing_restart_required: false, + }, + )])); + (connections, alive, dropped) +} + +fn state(request: SetWorkspaceSharingRequest) -> WorkspaceSharingState { + WorkspaceSharingState { + recipient_application_id: request.recipient_application_id, + enabled: request.enabled, + allow_continuation: request.allow_continuation, + } +} + +fn assert_paused(connections: &Connections, dropped: &AtomicBool) { + assert!( + dropped.load(Ordering::Acquire), + "connector destructor completed before any permission change" + ); + let sessions = connections.try_lock().unwrap(); + let session = &sessions["target"]; + assert!(session.task.is_none()); + assert!(!session.alive.load(Ordering::Acquire)); + assert!(session.sharing_recovery_required); +} + +#[tokio::test] +async fn successful_update_quiesces_before_grant_and_real_enrollment_write() { + let original = config(false); + let (connections, alive, dropped) = active(&original).await; + let vault = Vault::new(&original, false, false); + let store = EnrollmentStore::new(vault.clone(), "target").unwrap(); + let (saved, previous) = update( + &connections, + "target", + &original, + request(true, true), + |request| { + assert_paused(&connections, &dropped); + ready(Ok(state(request))) + }, + || store.set_sharing(true, true), + ) + .await + .unwrap(); + assert_eq!(previous, ConnectorStatus::Connected); + assert!(!alive.load(Ordering::Acquire)); + assert!(saved_request(&saved).allow_continuation); + assert!(saved_request(&store.load().unwrap().unwrap().0).enabled); + assert_eq!(vault.writes.load(Ordering::SeqCst), 1); + let summary = summary(&connections, "target").await.unwrap(); + assert_eq!(summary.status, ConnectorStatus::Disconnected); + assert!(!summary.sharing_recovery_required.is_required()); +} + +#[tokio::test] +async fn failed_enable_cannot_export_new_sessions_and_compensates_without_restart() { + let original = config(false); + let (connections, _, dropped) = active(&original).await; + let vault = Vault::new(&original, true, false); + let store = EnrollmentStore::new(vault, "target").unwrap(); + let mut calls = Vec::new(); + let mut runtime_enabled = false; + let mut escaped = 0; + let error = update( + &connections, + "target", + &original, + request(true, true), + |request| { + if request.enabled && !dropped.load(Ordering::Acquire) { + escaped += 1; + } + assert_paused(&connections, &dropped); + runtime_enabled = request.enabled; + calls.push((request.enabled, request.allow_continuation)); + ready(Ok(state(request))) + }, + || store.set_sharing(true, true), + ) + .await + .err() + .unwrap(); + assert_eq!(calls, [(true, true), (false, false)]); + assert_eq!(escaped, 0); + assert!(!runtime_enabled); + assert!(!error.outcome_unknown); + assert!(!saved_request(&store.load().unwrap().unwrap().0).enabled); + let summary = summary(&connections, "target").await.unwrap(); + assert_eq!(summary.status, ConnectorStatus::Disconnected); + assert!(summary.sharing_recovery_required.is_required()); +} + +#[tokio::test] +async fn failed_disable_never_restores_an_old_grant_and_explicit_reconnect_reconciles_saved_choice() +{ + let original = config(true); + let (connections, _, dropped) = active(&original).await; + let vault = Vault::new(&original, true, false); + let store = EnrollmentStore::new(vault, "target").unwrap(); + let mut calls = Vec::new(); + let mut runtime_enabled = true; + update( + &connections, + "target", + &original, + request(false, true), + |request| { + assert_paused(&connections, &dropped); + calls.push((request.enabled, request.allow_continuation)); + runtime_enabled = request.enabled; + ready(Ok(state(request))) + }, + || store.set_sharing(false, true), + ) + .await + .err() + .unwrap(); + assert_eq!(calls, [(false, false), (false, false)]); + assert!(!runtime_enabled); + let retained = store.load().unwrap().unwrap().0; + assert!(saved_request(&retained).enabled); + let summary_before = summary(&connections, "target").await.unwrap(); + assert!( + summary_before.shared_sessions, + "retained choice, not an actual permission claim" + ); + assert!(summary_before.sharing_recovery_required.is_required()); + assert_eq!( + saved_choice(&retained), + "share Desktop history and allow continuation" + ); + update( + &connections, + "target", + &retained, + saved_request(&retained), + |request| { + assert_paused(&connections, &dropped); + runtime_enabled = request.enabled; + ready(Ok(state(request))) + }, + || ready(Ok(retained.clone())), + ) + .await + .unwrap(); + assert!( + runtime_enabled, + "only the subsequent explicitly confirmed saved-choice reconciliation restores sharing" + ); + assert!( + !summary(&connections, "target") + .await + .unwrap() + .sharing_recovery_required + .is_required() + ); +} + +#[tokio::test] +async fn failed_write_acknowledgement_and_compensation_remain_unknown() { + let original = config(false); + let (connections, _, dropped) = active(&original).await; + let vault = Vault::new(&original, true, true); + let store = EnrollmentStore::new(vault, "target").unwrap(); + let mut calls = 0; + let error = update( + &connections, + "target", + &original, + request(true, true), + |request| { + assert_paused(&connections, &dropped); + calls += 1; + if calls == 1 { + ready(Ok(state(request))) + } else { + ready(Err(colossus_sdk::ApiError::failed_precondition( + colossus_sdk::ApiErrorReason::InternalInvariant, + "synthetic compensation failure", + ))) + } + }, + || store.set_sharing(true, true), + ) + .await + .err() + .unwrap(); + assert!( + saved_request(&store.load().unwrap().unwrap().0).enabled, + "write committed before acknowledgement failed" + ); + assert!(error.outcome_unknown); + assert!(!error.retryable); + assert!( + summary(&connections, "target") + .await + .unwrap() + .sharing_recovery_required + .is_required() + ); +} + +#[tokio::test] +async fn runtime_failure_does_not_persist_and_always_attempts_disable() { + let original = config(false); + let (connections, _, dropped) = active(&original).await; + let vault = Vault::new(&original, false, false); + let store = EnrollmentStore::new(vault.clone(), "target").unwrap(); + let mut calls = Vec::new(); + let error = update( + &connections, + "target", + &original, + request(true, true), + |request| { + assert_paused(&connections, &dropped); + calls.push(request.enabled); + if calls.len() == 1 { + ready(Err(colossus_sdk::ApiError::failed_precondition( + colossus_sdk::ApiErrorReason::InternalInvariant, + "synthetic known runtime rejection", + ))) + } else { + ready(Ok(state(request))) + } + }, + || store.set_sharing(true, true), + ) + .await + .err() + .unwrap(); + assert_eq!(calls, [true, false]); + assert_eq!(vault.writes.load(Ordering::SeqCst), 0); + assert!(!error.outcome_unknown); + assert!( + summary(&connections, "target") + .await + .unwrap() + .sharing_recovery_required + .is_required() + ); +} + +#[tokio::test] +async fn fresh_private_enrollment_clears_prior_sharing_before_connector_start() { + let fresh = config(false); + let connections = Mutex::new(HashMap::new()); + let mut runtime_enabled = true; + update( + &connections, + "target", + &fresh, + saved_request(&fresh), + |request| { + assert_eq!( + request.recipient_application_id, + "app:colossus-desktop-cloud" + ); + runtime_enabled = request.enabled; + assert!(!request.allow_continuation); + ready(Ok(state(request))) + }, + || ready(Ok(fresh.clone())), + ) + .await + .unwrap(); + assert!(!runtime_enabled); + assert!(connections.lock().await["target"].task.is_none()); + assert!( + !summary(&connections, "target") + .await + .unwrap() + .sharing_recovery_required + .is_required() + ); +} + +#[tokio::test] +async fn stop_timeout_blocks_permission_changes_even_after_forced_parent_abort() { + let original = config(false); + let (connections, _, _) = active_with_stop(&original, false).await; + let error = quiesce(&connections, "target", &original, Duration::from_millis(10)) + .await + .err() + .unwrap(); + assert!(error.outcome_unknown); + let mut commits = 0; + for _ in 0..2 { + update( + &connections, + "target", + &original, + request(true, true), + |request| { + commits += 1; + ready(Ok(state(request))) + }, + || ready(Ok(original.clone())), + ) + .await + .err() + .unwrap(); + } + assert_eq!( + commits, 0, + "parent abort is not proof that its child watches were joined" + ); + assert!( + summary(&connections, "target") + .await + .unwrap() + .sharing_recovery_required + .is_required() + ); +} + +#[test] +fn disconnected_saved_metadata_does_not_claim_current_runtime_sharing() { + let shared = config(true); + let summary = super::super::disconnected_summary( + "target".into(), + Some(&shared), + ConnectorStatus::Disconnected, + ); + assert!( + summary.shared_sessions, + "retained saved intent remains available for explicit confirmation" + ); + assert!(summary.sharing_recovery_required.is_required()); +} + +#[test] +fn reconnect_confirmation_reconciles_only_managed_local_authority() { + let shared = config(true); + let managed = reconnect_message(&shared, true); + assert!(managed.contains("share Desktop history and allow continuation")); + assert!(managed.contains("Reconnecting reapplies")); + let external = reconnect_message(&shared, false); + assert_eq!( + external, + "Reconnect runtime synthetic-instance to https://control-plane.example?" + ); +} + +fn short_deadlines() -> Deadlines { + Deadlines { + runtime: Duration::from_millis(10), + persistence: Duration::from_millis(10), + } +} + +#[tokio::test] +async fn timed_out_runtime_commit_stays_unknown_after_disable_ack_and_late_server_commit() { + let original = config(false); + let (connections, _, dropped) = active(&original).await; + let runtime_enabled = Arc::new(AtomicBool::new(false)); + let delayed_state = Arc::clone(&runtime_enabled); + let (release, delayed) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + delayed.await.unwrap(); + delayed_state.store(true, Ordering::Release); + }); + let mut calls = 0; + let error = update_with_deadlines( + &connections, + "target", + &original, + request(true, true), + |request| { + assert_paused(&connections, &dropped); + calls += 1; + let first = calls == 1; + let runtime_enabled = Arc::clone(&runtime_enabled); + async move { + if first { + std::future::pending::>().await + } else { + runtime_enabled.store(false, Ordering::Release); + Ok(state(request)) + } + } + }, + || ready(Ok(original.clone())), + short_deadlines(), + ) + .await + .err() + .unwrap(); + assert_eq!(calls, 2); + assert!( + error.outcome_unknown, + "a disable ACK cannot order an older timed-out RPC" + ); + assert!(!runtime_enabled.load(Ordering::Acquire)); + release.send(()).unwrap(); + server.await.unwrap(); + assert!( + runtime_enabled.load(Ordering::Acquire), + "the old server request may complete late" + ); + let snapshot = summary(&connections, "target").await.unwrap(); + assert!( + snapshot.sharing_restart_required.is_required() + && snapshot.sharing_recovery_required.is_required() + ); + assert!( + ensure_recoverable(&connections, "target").await.is_err(), + "disconnect/forget/revoke/enroll share the same restart fence" + ); + assert!(check_start(&connections, "target").await.is_err()); + let mut retry_calls = 0; + update( + &connections, + "target", + &original, + request(false, false), + |request| { + retry_calls += 1; + ready(Ok(state(request))) + }, + || ready(Ok(original.clone())), + ) + .await + .err() + .unwrap(); + assert_eq!(retry_calls, 0); +} + +#[tokio::test] +async fn persistence_timeout_remains_unconfirmed_even_when_disable_succeeds() { + let original = config(false); + let (connections, _, dropped) = active(&original).await; + let mut requests = Vec::new(); + let error = update_with_deadlines( + &connections, + "target", + &original, + request(true, true), + |request| { + assert_paused(&connections, &dropped); + requests.push(request.enabled); + ready(Ok(state(request))) + }, + std::future::pending::>, + short_deadlines(), + ) + .await + .err() + .unwrap(); + assert_eq!(requests, [true, false]); + assert!(error.outcome_unknown); + assert!( + summary(&connections, "target") + .await + .unwrap() + .sharing_restart_required + .is_required() + ); + assert!(ensure_recoverable(&connections, "target").await.is_err()); +} + +#[tokio::test] +async fn pending_compensation_is_bounded_and_requires_restart_without_retry() { + let original = config(false); + let (connections, _, dropped) = active(&original).await; + let mut calls = 0; + let error = update_with_deadlines( + &connections, + "target", + &original, + request(true, true), + |request| { + assert_paused(&connections, &dropped); + calls += 1; + let pending = calls == 2; + async move { + if pending { + std::future::pending::>().await + } else { + Ok(state(request)) + } + } + }, + || ready(Err("synthetic persistence failure")), + short_deadlines(), + ) + .await + .err() + .unwrap(); + assert_eq!(calls, 2); + assert!(error.outcome_unknown && !error.retryable); + assert!( + summary(&connections, "target") + .await + .unwrap() + .sharing_restart_required + .is_required() + ); +} + +#[tokio::test] +async fn lost_transport_acknowledgement_is_not_a_definitive_disable_even_after_compensation() { + let original = config(false); + let (connections, _, _) = active(&original).await; + let mut calls = 0; + let error = update( + &connections, + "target", + &original, + request(true, false), + |request| { + calls += 1; + if calls == 1 { + ready(Err(colossus_sdk::ApiError { + code: ApiErrorCode::Unavailable, + ..colossus_sdk::ApiError::failed_precondition( + colossus_sdk::ApiErrorReason::InternalInvariant, + "synthetic lost transport acknowledgement", + ) + })) + } else { + ready(Ok(state(request))) + } + }, + || ready(Ok(original.clone())), + ) + .await + .err() + .unwrap(); + assert_eq!(calls, 2); + assert!(error.outcome_unknown); + assert!( + summary(&connections, "target") + .await + .unwrap() + .sharing_restart_required + .is_required() + ); +} diff --git a/apps/desktop/src-tauri/src/cloud_connector/status_cache.rs b/apps/desktop/src-tauri/src/cloud_connector/status_cache.rs new file mode 100644 index 000000000..b81c17f0e --- /dev/null +++ b/apps/desktop/src-tauri/src/cloud_connector/status_cache.rs @@ -0,0 +1,236 @@ +//! Nonsecret enrollment summaries for connection lists; never execution authority. +use super::{CloudStatus, failure}; +use crate::dto::CommandErrorDto; +use colossus_connector::ConnectorStatus; +use colossus_home::{ColossusHome, ConfinedRoot}; +use fs4::fs_std::FileExt; +use std::{ + collections::BTreeMap, + io::{Read, Write}, + path::Path, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +const MAX_BYTES: u64 = 512 * 1024; + +pub(super) fn load() -> Result, CommandErrorDto> { + access(None, ColossusHome::resolve_and_ensure) +} + +pub(super) fn remember(status: CloudStatus) -> Result<(), CommandErrorDto> { + access( + Some((status.target_id.clone(), Some(status))), + ColossusHome::resolve_and_ensure, + ) + .map(|_| ()) +} + +pub(super) fn forget(target: String) -> Result<(), CommandErrorDto> { + access(Some((target, None)), ColossusHome::resolve_and_ensure).map(|_| ()) +} + +pub(super) fn retained_disconnected_status( + target: &str, + node: &str, +) -> Result { + access(None, ColossusHome::resolve_and_ensure) + .map(|statuses| disconnected_status(&statuses, target, node)) +} + +fn disconnected_status( + statuses: &BTreeMap, + target: &str, + node: &str, +) -> ConnectorStatus { + if statuses.get(target).is_some_and(|status| { + status.node_id.as_deref() == Some(node) && status.status == ConnectorStatus::Revoked + }) { + ConnectorStatus::Revoked + } else { + ConnectorStatus::Disconnected + } +} + +/// Retain an observed terminal status only for this still-owned enrollment. +/// The cache lock orders this with forget/replacement; no absent row is created. +pub(super) fn remember_remote_revocation( + target: String, + node: String, + alive: Arc, +) -> Result<(), CommandErrorDto> { + access_change( + Some(Change::Revoked { + target, + node, + alive, + }), + ColossusHome::resolve_and_ensure, + ) + .map(|_| ()) +} + +enum Change { + Remember(String, CloudStatus), + Forget(String), + Revoked { + target: String, + node: String, + alive: Arc, + }, +} + +fn unavailable() -> CommandErrorDto { + failure("Saved Control Plane connection summaries are unavailable.") +} + +fn valid(status: &CloudStatus) -> bool { + let bounded = |value: &str| { + !value.is_empty() && value.len() <= 256 && !value.chars().any(char::is_control) + }; + bounded(&status.target_id) + && status.node_id.as_deref().is_some_and(bounded) + && status.project_id.as_deref().is_some_and(bounded) + && status.host_id.as_deref().is_none_or(bounded) + && status.workspace_id.as_deref().is_none_or(bounded) + && matches!( + status.status, + ConnectorStatus::Disconnected | ConnectorStatus::Revoked + ) + && status.endpoint.as_deref().is_some_and(|endpoint| { + endpoint.len() <= 2048 + && url::Url::parse(endpoint).is_ok_and(|url| { + let loopback = matches!( + url.host_str(), + Some("localhost" | "127.0.0.1" | "[::1]" | "::1") + ); + (url.scheme() == "https" || url.scheme() == "http" && loopback) + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() + }) + }) +} + +fn access( + update: Option<(String, Option)>, + resolve_home: impl FnOnce() -> Result, +) -> Result, CommandErrorDto> { + access_change( + update.map(|(target, status)| match status { + Some(status) => Change::Remember(target, status), + None => Change::Forget(target), + }), + resolve_home, + ) +} + +fn access_change( + change: Option, + resolve_home: impl FnOnce() -> Result, +) -> Result, CommandErrorDto> { + let home = resolve_home().map_err(|_| unavailable())?; + let root = ConfinedRoot::bind( + home.confined_root() + .prepare_directory(Path::new("desktop-control-plane")) + .map_err(|_| unavailable())?, + ) + .map_err(|_| unavailable())?; + let lock = root + .open_file(Path::new("connections.lock")) + .map_err(|_| unavailable())?; + lock.file().lock_exclusive().map_err(|_| unavailable())?; + let result = (|| { + let retained = root + .open_file(Path::new("connections.json")) + .map_err(|_| unavailable())?; + retained.revalidate(&root).map_err(|_| unavailable())?; + let mut bytes = Vec::new(); + Read::by_ref(&mut retained.file()) + .take(MAX_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| unavailable())?; + if bytes.len() as u64 > MAX_BYTES { + return Err(unavailable()); + } + let mut statuses: BTreeMap = if bytes.is_empty() { + BTreeMap::new() + } else { + serde_json::from_slice(&bytes).map_err(|_| unavailable())? + }; + if statuses.len() > 128 + || statuses + .iter() + .any(|(target, status)| target != &status.target_id || !valid(status)) + { + return Err(unavailable()); + } + if let Some(change) = change { + match change { + Change::Remember(target, mut status) => { + status.status = if status.status == ConnectorStatus::Revoked { + ConnectorStatus::Revoked + } else { + ConnectorStatus::Disconnected + }; + if !valid(&status) || status.target_id != target { + return Err(unavailable()); + } + statuses.insert(target, status); + } + Change::Forget(target) => { + statuses.remove(&target); + } + Change::Revoked { + target, + node, + alive, + } => { + // This check belongs under connections.lock. Drop invalidates the + // token before forget/replacement tries to acquire that same lock. + if !alive.load(Ordering::Acquire) { + return Ok(statuses); + } + let Some(existing) = statuses.get_mut(&target) else { + return Ok(statuses); + }; + if existing.node_id.as_deref() != Some(node.as_str()) + || existing.target_id != target + { + return Ok(statuses); + } + existing.status = ConnectorStatus::Revoked; + } + } + if statuses.len() > 128 { + return Err(unavailable()); + } + let bytes = serde_json::to_vec(&statuses).map_err(|_| unavailable())?; + if bytes.len() as u64 > MAX_BYTES { + return Err(unavailable()); + } + let name = format!("connections-{}.tmp", uuid::Uuid::new_v4()); + let temporary = root + .open_file(Path::new(&name)) + .map_err(|_| unavailable())?; + temporary + .file() + .write_all(&bytes) + .map_err(|_| unavailable())?; + temporary.file().sync_all().map_err(|_| unavailable())?; + retained.revalidate(&root).map_err(|_| unavailable())?; + temporary.revalidate(&root).map_err(|_| unavailable())?; + std::fs::rename(temporary.path(), retained.path()).map_err(|_| unavailable())?; + root.sync_directory().map_err(|_| unavailable())?; + } + Ok(statuses) + })(); + let _ = FileExt::unlock(lock.file()); + result +} + +#[cfg(test)] +mod tests; diff --git a/apps/desktop/src-tauri/src/cloud_connector/status_cache/tests.rs b/apps/desktop/src-tauri/src/cloud_connector/status_cache/tests.rs new file mode 100644 index 000000000..fafec2949 --- /dev/null +++ b/apps/desktop/src-tauri/src/cloud_connector/status_cache/tests.rs @@ -0,0 +1,326 @@ +use super::*; +use std::fs; + +fn home() -> (tempfile::TempDir, ColossusHome) { + #[cfg(windows)] + let directory = + tempfile::tempdir_in(std::env::var_os("LOCALAPPDATA").expect("Windows private parent")) + .expect("test directory"); + #[cfg(not(windows))] + let directory = tempfile::tempdir().expect("test directory"); + let parent = fs::canonicalize(directory.path()).expect("canonical parent"); + let home = ColossusHome::ensure_at(parent.join("home")).expect("private test home"); + (directory, home) +} + +fn status(state: ConnectorStatus) -> CloudStatus { + CloudStatus { + target_id: "owned-workspace".into(), + status: state, + node_id: Some("owned-node".into()), + project_id: Some("owned-project".into()), + endpoint: Some("https://control-plane.example".into()), + host_id: Some("owned-host".into()), + workspace_id: Some("owned-workspace".into()), + shared_sessions: false, + shared_continuation: false, + sharing_supported: true, + sharing_recovery_required: super::super::sharing::Requirement::Clear, + sharing_restart_required: super::super::sharing::Requirement::Clear, + } +} + +#[test] +fn saved_connections_survive_reopen_without_claiming_live_connectivity() { + let (_guard, home) = home(); + access( + Some(( + "owned-workspace".into(), + Some(status(ConnectorStatus::Connected)), + )), + || Ok(home.clone()), + ) + .expect("remember public fields"); + let reopened = ColossusHome::ensure_at(home.root()).expect("reopened home"); + let saved = access(None, || Ok(reopened)).expect("saved summary without credential store"); + assert_eq!( + saved["owned-workspace"].status, + ConnectorStatus::Disconnected + ); + assert_eq!( + saved["owned-workspace"].project_id.as_deref(), + Some("owned-project") + ); + let bytes = + fs::read(home.root().join("desktop-control-plane/connections.json")).expect("public cache"); + let encoded = String::from_utf8(bytes).expect("UTF8 metadata"); + let mut wire: serde_json::Value = serde_json::from_str(&encoded).expect("wire metadata"); + let row = wire["owned-workspace"] + .as_object_mut() + .expect("status object"); + assert_eq!(row["sharingRecoveryRequired"], false); + assert_eq!(row["sharingRestartRequired"], false); + row.remove("sharingRecoveryRequired"); + row.remove("sharingRestartRequired"); + let legacy: CloudStatus = serde_json::from_value(serde_json::Value::Object(row.clone())) + .expect("legacy status defaults"); + assert!(!legacy.sharing_recovery_required.is_required()); + assert!(!legacy.sharing_restart_required.is_required()); + row.insert( + "sharingRecoveryRequired".into(), + serde_json::json!("required"), + ); + assert!( + serde_json::from_value::(serde_json::Value::Object(row.clone())).is_err(), + "wire flags remain strict booleans" + ); + for forbidden in ["credential", "bearer", "privateKey", "certificate"] { + assert!(!encoded.contains(forbidden)); + } +} + +#[test] +fn revocation_and_forgetting_update_the_public_summary() { + let (_guard, home) = home(); + access( + Some(( + "owned-workspace".into(), + Some(status(ConnectorStatus::Revoked)), + )), + || Ok(home.clone()), + ) + .expect("revoked summary"); + assert_eq!( + access(None, || Ok(home.clone())).expect("revoked read")["owned-workspace"].status, + ConnectorStatus::Revoked + ); + access(Some(("owned-workspace".into(), None)), || Ok(home.clone())).expect("forget summary"); + assert!(access(None, || Ok(home)).expect("empty summary").is_empty()); +} + +#[test] +fn remote_revocation_updates_matching_existing_summary_and_survives_reopen() { + let (_guard, home) = home(); + access( + Some(( + "owned-workspace".into(), + Some(status(ConnectorStatus::Disconnected)), + )), + || Ok(home.clone()), + ) + .expect("retained enrollment"); + access_change( + Some(Change::Revoked { + target: "owned-workspace".into(), + node: "owned-node".into(), + alive: Arc::new(AtomicBool::new(true)), + }), + || Ok(home.clone()), + ) + .expect("remote terminal status"); + let reopened = ColossusHome::ensure_at(home.root()).expect("reopened home"); + assert_eq!( + access(None, || Ok(reopened)).expect("retained terminal state")["owned-workspace"].status, + ConnectorStatus::Revoked + ); + let saved = access(None, || Ok(home.clone())).expect("read after restart"); + let preserved = disconnected_status(&saved, "owned-workspace", "owned-node"); + assert_eq!( + preserved, + ConnectorStatus::Revoked, + "read-only settings refresh retains terminal observation" + ); + let mut disconnected = status(ConnectorStatus::Disconnected); + disconnected.status = preserved; + access(Some(("owned-workspace".into(), Some(disconnected))), || { + Ok(home.clone()) + }) + .expect("later disconnect persists retained terminal state"); + assert_eq!( + access(None, || Ok(home.clone())).expect("later reopen")["owned-workspace"].status, + ConnectorStatus::Revoked + ); + assert_eq!( + disconnected_status(&saved, "owned-workspace", "replacement-node"), + ConnectorStatus::Disconnected, + "different enrollment never inherits old revocation" + ); +} + +#[test] +fn terminal_callback_cannot_resurrect_forget_or_overwrite_a_replacement() { + let (_guard, home) = home(); + let live = Arc::new(AtomicBool::new(true)); + access( + Some(( + "owned-workspace".into(), + Some(status(ConnectorStatus::Disconnected)), + )), + || Ok(home.clone()), + ) + .expect("retained enrollment"); + access(Some(("owned-workspace".into(), None)), || Ok(home.clone())).expect("forget"); + access_change( + Some(Change::Revoked { + target: "owned-workspace".into(), + node: "owned-node".into(), + alive: Arc::clone(&live), + }), + || Ok(home.clone()), + ) + .expect("absent enrollment never inserted even before token invalidation"); + assert!( + access(None, || Ok(home.clone())) + .expect("forgotten cache") + .is_empty() + ); + + let mut replacement = status(ConnectorStatus::Disconnected); + replacement.node_id = Some("replacement-node".into()); + access(Some(("owned-workspace".into(), Some(replacement))), || { + Ok(home.clone()) + }) + .expect("replacement enrollment"); + access_change( + Some(Change::Revoked { + target: "owned-workspace".into(), + node: "owned-node".into(), + alive: Arc::clone(&live), + }), + || Ok(home.clone()), + ) + .expect("different node not overwritten"); + assert_eq!( + access(None, || Ok(home.clone())).expect("replacement state")["owned-workspace"].status, + ConnectorStatus::Disconnected + ); + + // Reconnect may retain the same node. The old session token still distinguishes it. + live.store(false, Ordering::Release); + access( + Some(( + "owned-workspace".into(), + Some(status(ConnectorStatus::Disconnected)), + )), + || Ok(home.clone()), + ) + .expect("same-node replacement session"); + access_change( + Some(Change::Revoked { + target: "owned-workspace".into(), + node: "owned-node".into(), + alive: live, + }), + || Ok(home.clone()), + ) + .expect("dropped old session cannot mark replacement revoked"); + assert_eq!( + access(None, || Ok(home)).expect("same-node replacement state")["owned-workspace"].status, + ConnectorStatus::Disconnected + ); +} + +#[test] +fn terminal_callback_checks_liveness_after_waiting_for_cache_lock() { + let (_guard, home) = home(); + access( + Some(( + "owned-workspace".into(), + Some(status(ConnectorStatus::Disconnected)), + )), + || Ok(home.clone()), + ) + .expect("retained enrollment"); + let root = ConfinedRoot::bind(home.root().join("desktop-control-plane")).expect("cache root"); + let lock = root + .open_existing_file_read_write(Path::new("connections.lock")) + .expect("lock"); + lock.file().lock_exclusive().expect("hold cache lock"); + let live = Arc::new(AtomicBool::new(true)); + let (entered, waiting) = std::sync::mpsc::channel(); + let worker_home = home.clone(); + let worker_live = Arc::clone(&live); + let task = std::thread::spawn(move || { + access_change( + Some(Change::Revoked { + target: "owned-workspace".into(), + node: "owned-node".into(), + alive: worker_live, + }), + || { + entered.send(()).expect("notify before cache lock"); + Ok(worker_home) + }, + ) + }); + waiting + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("terminal callback started"); + live.store(false, Ordering::Release); + FileExt::unlock(lock.file()).expect("release cache lock"); + task.join() + .expect("callback thread") + .expect("invalidated callback is a no-op"); + assert_eq!( + access(None, || Ok(home.clone())).expect("no stale terminal write")["owned-workspace"] + .status, + ConnectorStatus::Disconnected + ); + access(Some(("owned-workspace".into(), None)), || Ok(home.clone())).expect("serialized forget"); + assert!( + access(None, || Ok(home)) + .expect("forgotten summary") + .is_empty() + ); +} + +#[test] +fn forged_connected_states_and_secret_bearing_fields_are_rejected() { + let (_guard, home) = home(); + access(None, || Ok(home.clone())).expect("prepare public cache"); + let path = home.root().join("desktop-control-plane/connections.json"); + let forged = BTreeMap::from([("owned-workspace", status(ConnectorStatus::Connected))]); + fs::write(&path, serde_json::to_vec(&forged).expect("forged JSON")) + .expect("write forged cache"); + assert!(access(None, || Ok(home.clone())).is_err()); + let mut forged = serde_json::to_value(BTreeMap::from([( + "owned-workspace", + status(ConnectorStatus::Disconnected), + )])) + .expect("summary JSON"); + forged["owned-workspace"]["bearer"] = serde_json::json!("synthetic-forbidden-field"); + fs::write(&path, serde_json::to_vec(&forged).expect("forged JSON")) + .expect("write forbidden field"); + assert!(access(None, || Ok(home.clone())).is_err()); + fs::write( + &path, + vec![b' '; usize::try_from(MAX_BYTES).expect("bounded cache size fits usize") + 1], + ) + .expect("oversized cache"); + assert!(access(None, || Ok(home)).is_err()); +} + +#[cfg(unix)] +#[test] +fn symlinked_cache_directory_cannot_redirect_metadata_writes() { + let (_guard, home) = home(); + let outside = tempfile::tempdir().expect("outside test directory"); + std::os::unix::fs::symlink(outside.path(), home.root().join("desktop-control-plane")) + .expect("directory symlink"); + assert!( + access( + Some(( + "owned-workspace".into(), + Some(status(ConnectorStatus::Disconnected)) + )), + || Ok(home) + ) + .is_err() + ); + assert!( + fs::read_dir(outside.path()) + .expect("outside contents") + .next() + .is_none() + ); +} diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index ccc6658ad..000bbaef7 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -206,6 +206,7 @@ pub(crate) async fn create_run( mut request: CreateRunInput, ) -> Result { let branch = request.branch_link(); + let thread_fork = request.is_thread_fork(); let settings = SettingsStore::open_application()?.load()?; if branch.is_some() { require_selected_space(&settings, &target_id)?; @@ -283,7 +284,12 @@ pub(crate) async fn create_run( .bind_runs(&target, vec![response.run.run_id.clone()]) .await; let run: RunDto = response.run.into(); - register_or_advance_aside(&target_id, branch, source.as_ref(), &run)?; + register_or_advance_aside( + &target_id, + if thread_fork { None } else { branch }, + source.as_ref(), + &run, + )?; index_released_runs(&target_id, std::slice::from_ref(&run)); Ok(run) } 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..0d9ac531b --- /dev/null +++ b/apps/desktop/src-tauri/src/control_plane_profiles.rs @@ -0,0 +1,191 @@ +//! 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 Catalog { + revision: u64, + profiles: Vec, + default_profile: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProfileSnapshot { + #[serde(flatten)] + catalog: Catalog, + connections: Vec, + connection_status_unavailable: bool, +} + +fn snapshot( + catalog: Catalog, + connections: Result, CommandErrorDto>, +) -> ProfileSnapshot { + let connection_status_unavailable = connections.is_err(); + ProfileSnapshot { + catalog, + connections: connections.unwrap_or_default(), + connection_status_unavailable, + } +} + +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: Catalog = if bytes.is_empty() { + Catalog::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(snapshot( + catalog, + cloud_connector::global_connections(&state).await, + )) +} + +#[tauri::command] +pub(crate) async fn save_control_plane_profiles( + state: State<'_, AppState>, + catalog: Catalog, +) -> Result { + let catalog = tauri::async_runtime::spawn_blocking(move || access(Some(catalog))) + .await + .map_err(|_| failure())??; + Ok(snapshot( + catalog, + 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..8dce9ec17 --- /dev/null +++ b/apps/desktop/src-tauri/src/control_plane_profiles/tests.rs @@ -0,0 +1,187 @@ +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) -> Catalog { + Catalog { + 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: Catalog) { + 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 unavailable_enrollment_metadata_does_not_reinterpret_a_committed_bookmark_save() { + let (_guard, home) = home(); + let saved = access_with_home(Some(catalog(0, "https://saved.example")), || Ok(home)) + .expect("bookmark committed"); + let response = + serde_json::to_value(snapshot(saved, Err(failure()))).expect("public command response"); + assert_eq!(response["revision"], 1); + assert_eq!(response["profiles"][0]["endpoint"], "https://saved.example"); + assert_eq!(response["connectionStatusUnavailable"], true); + assert_eq!(response["connections"], serde_json::json!([])); + assert!(response.get("connection_status_unavailable").is_none()); +} + +#[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/desktop_credentials.rs b/apps/desktop/src-tauri/src/desktop_credentials.rs index aefa7bf89..b6b18bf57 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, @@ -157,8 +161,27 @@ impl DesktopCredentials { return Ok(Arc::clone(store)); } let confined = ConfinedRoot::bind(root).map_err(|_| unavailable())?; - let vault = - PlatformCredentialVault::new(confined, "desktop-manual").map_err(credential_error)?; + let vault = if std::env::var_os(colossus_credentials::DEVELOPMENT_AUTHORITY_VARIABLE) + .is_some() + { + // Release selection fails before any key file is opened. The native + // host alone reads the opted-in home-bound authority; no renderer or + // process environment contains its wrapping key. + if !cfg!(debug_assertions) { + return Err(unavailable()); + } + let home = ConfinedRoot::bind(settings.home_root()?).map_err(|_| unavailable())?; + let authority = colossus_credentials::DevelopmentAuthority::selected(&home, &[]) + .map_err(credential_error)? + .ok_or_else(unavailable)?; + let keys = authority + .store(colossus_credentials::DevelopmentStoreScope::DesktopVault) + .map_err(credential_error)?; + PlatformCredentialVault::with_key_store(confined, "desktop-manual", Arc::new(keys)) + .map_err(credential_error)? + } else { + PlatformCredentialVault::new(confined, "desktop-manual").map_err(credential_error)? + }; let store = Arc::new(Self { root: root.to_owned(), vault: Arc::new(vault), diff --git a/apps/desktop/src-tauri/src/dto.rs b/apps/desktop/src-tauri/src/dto.rs index e6fd0e6fa..c06e94d38 100644 --- a/apps/desktop/src-tauri/src/dto.rs +++ b/apps/desktop/src-tauri/src/dto.rs @@ -1549,6 +1549,16 @@ pub(crate) struct CreateRunInput { #[serde(rename_all = "camelCase", deny_unknown_fields)] struct RunBranchInput { source_run_id: String, + #[serde(default)] + kind: RunBranchKindInput, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum RunBranchKindInput { + #[default] + Aside, + Thread, } impl CreateRunInput { @@ -1570,6 +1580,12 @@ impl CreateRunInput { .map(|branch| branch.source_run_id.clone()) } + pub(crate) fn is_thread_fork(&self) -> bool { + self.branch + .as_ref() + .is_some_and(|branch| branch.kind == RunBranchKindInput::Thread) + } + pub(crate) fn into_sdk(self) -> Result { validate_text(&self.prompt, "prompt", false)?; if let Some(session_id) = self.session_id.as_deref() { @@ -1615,7 +1631,7 @@ impl CreateRunInput { if branch.is_some() && (self.session_id.is_some() || plan_action.is_some()) { return Err(CommandErrorDto::invalid( "branch", - "An Aside must start a separate session and cannot continue a Plan.", + "A conversation branch must start a separate session and cannot continue a Plan.", )); } Ok(CreateRunRequest { @@ -2063,6 +2079,27 @@ mod tests { assert_eq!(request.max_turns, 0); } + #[test] + fn thread_fork_keeps_the_canonical_branch_boundary_and_is_not_an_aside() { + let input: CreateRunInput = serde_json::from_value(json!({ + "prompt": "Explore another approach", "role": "primary", "mode": "execute", + "branch": { "sourceRunId": "owned-source", "kind": "thread" }, + "maxTurns": 0, "idempotencyKey": "thread-fork" + })) + .expect("thread fork input"); + assert!(input.is_thread_fork()); + assert_eq!(input.branch_link().as_deref(), Some("owned-source")); + let request = input.into_sdk().expect("canonical branch request"); + assert!(request.session_id.is_none()); + assert!(request.plan_action.is_none()); + let branch = request.branch.expect("branch retained"); + assert_eq!(branch.source_message_count, 0); + assert_eq!( + branch.context_mode, + RunBranchContextMode::SourceRunConversation + ); + } + #[test] fn aside_branch_resolves_its_boundary_natively() { let input: CreateRunInput = serde_json::from_value(json!({ diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index cb806ef40..0a78855bb 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,3 +1,10 @@ +mod cloud_connector; +mod control_plane_profiles; +use cloud_connector::{ + 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; @@ -307,6 +314,15 @@ pub fn run() { set_terminal_enabled, connect_colossus, connection_status, + cloud_enroll, + cloud_connect, + cloud_status, + control_plane_profiles, + save_control_plane_profiles, + cloud_set_workspace_sharing, + 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 e7334d856..e908ff54e 100644 --- a/apps/desktop/src-tauri/src/managed_runtime.rs +++ b/apps/desktop/src-tauri/src/managed_runtime.rs @@ -964,6 +964,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)] @@ -1361,6 +1362,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) @@ -1374,7 +1389,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/App.tsx b/apps/desktop/src/App.tsx index 2584bc55e..e49e59523 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -80,7 +80,6 @@ import { ExecutionBoundaryBanner, executionBoundaryBannerVisible, } from "./components/ExecutionBoundaryBanner"; -import { OperationsSurface } from "./components/OperationsSurface"; import { pluginSelectionKey } from "./plugins"; import { usePluginSkills } from "./use-plugin-skills"; import type { AsideDraft } from "./components/AsidePanel"; @@ -96,6 +95,16 @@ import { pasteIntoComposerDraft, } from "./composer-paste"; import { WorkSidebar } from "./components/WorkSidebar"; +import { + canForkThread, + createThreadForkDraft, + defaultForkTitle, + readThreadForkDrafts, + storeThreadForkDrafts, + threadForkBranch, + threadForkDraftRun, +} from "./thread-fork"; +import type { ThreadForkDraft } from "./thread-fork"; import { ToastRegion, useToastQueue } from "./components/ToastRegion"; import type { SpaceActionFeedback, @@ -224,6 +233,12 @@ const OnboardingSurface = lazy(() => })), ); +const OperationsSurface = lazy(() => + import("./components/OperationsSurface").then((module) => ({ + default: module.OperationsSurface, + })), +); + const FIXTURE_QUERY = new URLSearchParams(window.location.search); const FIXTURE_SCENARIO = FIXTURE_QUERY.get("fixture"); const FIXTURE_MODE = @@ -878,6 +893,7 @@ interface RunSubmission { idempotencyKey: string; sessionId?: string; planRevision?: PlanRevisionTarget; + branch?: CreateRunRequest["branch"]; } type RunSubmissionResult = @@ -934,12 +950,24 @@ export default function App() { ); const [asideHistory, setAsideHistory] = useState([]); const [asideBusy, setAsideBusy] = useState(false); + const forkDraftFocus = useRef(null); + const [threadForkDrafts, setThreadForkDrafts] = + useState(readThreadForkDrafts); + const [forkPreviewViews, setForkPreviewViews] = useState< + ReadonlyMap + >(new Map()); const [asideError, setAsideError] = useState(null); const [asideReadOnly, setAsideReadOnly] = useState(false); const appShellRef = useRef(null); const [initialWorkSidebarWidth] = useState(readStoredWorkSidebarWidth); const workSidebarWidthRef = useRef(initialWorkSidebarWidth); const [desktop, setDesktop] = useState(INITIAL_DESKTOP); + const activeForkDraft = threadForkDrafts.find( + (draft) => + draft.id === chat.activeRunId && + draft.materializedSessionId === undefined && + draft.spaceId === desktop.selectedSpaceId, + ); const [conversationSkills, setConversationSkills] = useState< Record >({}); @@ -1006,8 +1034,18 @@ export default function App() { const [showOnboarding, setShowOnboarding] = useState(false); const [surface, setSurface] = useState("work"); const [settingsStartTab, setSettingsStartTab] = useState< - "runtime" | "providers" | "plugins" | "terminal" | "dictation" + | "runtime" + | "providers" + | "models" + | "plugins" + | "terminal" + | "dictation" + | "cloud" + | "control-plane" >("runtime"); + const [settingsSpaceId, setSettingsSpaceId] = useState( + undefined, + ); const [workNavigationOpen, setWorkNavigationOpen] = useState(false); const [workspaceFileOpenRequest, setWorkspaceFileOpenRequest] = useState(null); @@ -1149,6 +1187,10 @@ export default function App() { "managed_local", ); targetRoutes.current.bindRuns(chat.views.keys(), route); + targetRoutes.current.bindRuns( + chat.recentRuns.map((run) => run.runId), + route, + ); } } const connectingRef = useRef(false); @@ -1287,10 +1329,147 @@ export default function App() { }); } + function updateForkDrafts( + update: (drafts: readonly ThreadForkDraft[]) => readonly ThreadForkDraft[], + ) { + setThreadForkDrafts((current) => { + const next = update(current); + storeThreadForkDrafts(next); + return next; + }); + } + + async function beginThreadFork(run: Run) { + const spaceId = desktopRef.current.selectedSpaceId; + if ( + !canForkThread(run) || + spaceId === null || + submitInFlight.current || + connectingRef.current + ) + return; + try { + const title = defaultForkTitle( + resolveThreadTitle(spaceId, run.sessionId, run.title), + ); + const draft = createThreadForkDraft(run, spaceId, title); + updateForkDrafts((current) => [draft, ...current].slice(0, 64)); + dictation?.controller.reset(); + setPrompt(""); + setAttachments([]); + setComposerError(null); + await openForkDraft(draft); + } catch (error: unknown) { + setActionError(commandError(error)); + } + } + + async function loadForkPreview(draft: ThreadForkDraft, route: TargetRoute) { + try { + let preview = chatRef.current; + let sourceSessionId = draft.sourceSessionId; + let sourceCreatedAt = draft.sourceCreatedAt; + if (FIXTURE_MODE) { + const source = preview.recentRuns.find( + (run) => run.runId === draft.sourceRunId, + ); + if (source !== undefined) + preview = chatReducer(preview, { type: "upsert_run", run: source }); + } else { + const details = await getRun(route.targetId, { + runId: draft.sourceRunId, + }); + if (targetRoutes.current?.isCurrent(route) !== true) return; + sourceSessionId = details.run.sessionId; + sourceCreatedAt = details.run.createdAt; + preview = chatReducer(preview, { type: "hydrate_run", details }); + const history = await listRuns(route.targetId, { + sessionId: sourceSessionId, + pageToken: "", + }); + for (const run of history.runs + .filter( + (run) => + run.runId !== draft.sourceRunId && + run.createdAt <= sourceCreatedAt, + ) + .slice(0, MAX_CONVERSATION_RUNS - 1)) { + if (targetRoutes.current?.isCurrent(route) !== true) return; + const historical = await getRun(route.targetId, { runId: run.runId }); + preview = chatReducer(preview, { + type: "hydrate_run", + details: historical, + }); + } + } + if (targetRoutes.current?.isCurrent(route) !== true) return; + const views = selectConversationViews(preview, sourceSessionId).filter( + (view) => view.run.createdAt <= sourceCreatedAt, + ); + setForkPreviewViews((current) => { + const next = new Map(current); + next.delete(draft.id); + next.set(draft.id, views); + while (next.size > 8) { + const oldest = next.keys().next().value; + if (oldest === undefined) break; + next.delete(oldest); + } + return next; + }); + } catch (error: unknown) { + if ( + targetRoutes.current?.isCurrent(route) === true && + (chatRef.current.activeRunId === draft.id || + chatRef.current.views.get(chatRef.current.activeRunId ?? "")?.run + .sessionId === draft.materializedSessionId) + ) + setRunLoadError(commandError(error).message); + } + } + + async function openForkDraft(draft: ThreadForkDraft) { + const route = targetRoutes.current?.capture() ?? null; + if ( + route === null || + draft.spaceId !== desktopRef.current.selectedSpaceId || + submitInFlight.current || + connectingRef.current + ) + return; + setPlanRevision(null); + setWorkNavigationOpen(false); + setSurface("work"); + setRole(draft.role); + setRunLoadError(""); + setActionError(null); + forkDraftFocus.current = draft.id; + dispatch({ type: "select_run", runId: draft.id }); + await loadForkPreview(draft, route); + } + useEffect(() => { chatRef.current = chat; }, [chat]); + useEffect(() => { + const draftId = forkDraftFocus.current; + if (draftId === null) return; + if (chat.activeRunId !== draftId || surface !== "work") { + forkDraftFocus.current = null; + return; + } + if (workNavigationOpen) return; + // The compact drawer must release the workspace's inert state first. + const frame = requestAnimationFrame(() => { + if (chatRef.current.activeRunId === draftId) { + composerRef.current?.focus(); + forkDraftFocus.current = null; + } + }); + return () => cancelAnimationFrame(frame); + }, [chat.activeRunId, surface, workNavigationOpen]); + useEffect(() => { desktopRef.current = desktop; }, [desktop]); @@ -1999,6 +2178,15 @@ export default function App() { if (submitInFlight.current || connectingRef.current) { return; } + const forkDraft = threadForkDrafts.find( + (draft) => + draft.id === run.runId && + draft.spaceId === desktopRef.current.selectedSpaceId, + ); + if (forkDraft !== undefined) { + await openForkDraft(forkDraft); + return; + } if (FIXTURE_MODE) { setPlanRevision(null); setWorkNavigationOpen(false); @@ -2007,6 +2195,14 @@ export default function App() { dispatch({ type: "select_run", runId: run.runId }); setRunLoadError(""); setActionError(null); + const origin = threadForkDrafts.find( + (draft) => + draft.materializedSessionId === run.sessionId && + draft.spaceId === desktopRef.current.selectedSpaceId, + ); + const route = targetRoutes.current?.capture() ?? null; + if (origin !== undefined && route !== null) + void loadForkPreview(origin, route); return; } const route = targetRoutes.current?.routeForRun(run.runId) ?? null; @@ -2015,6 +2211,12 @@ export default function App() { return; } targetRoutes.current.bindRun(run.runId, route); + const origin = threadForkDrafts.find( + (draft) => + draft.materializedSessionId === run.sessionId && + draft.spaceId === desktopRef.current.selectedSpaceId, + ); + if (origin !== undefined) void loadForkPreview(origin, route); setPlanRevision(null); setWorkNavigationOpen(false); setSurface("work"); @@ -2175,6 +2377,9 @@ export default function App() { ...(submission.sessionId === undefined ? {} : { sessionId: submission.sessionId }), + ...(submission.branch === undefined + ? {} + : { branch: submission.branch }), ...(submission.planRevision === undefined ? {} : { @@ -2590,7 +2795,10 @@ export default function App() { return; } - const sessionId = continuationView?.run.sessionId; + const sessionId = + activeForkDraft === undefined + ? continuationView?.run.sessionId + : undefined; if (sessionId) setThreadQueuePaused(route.targetId, sessionId, false); const effectiveMode: RunMode = planRevision === null ? mode : "plan"; if (effectiveMode === "research" && researchSources.length === 0) { @@ -2612,6 +2820,8 @@ export default function App() { ? [researchDepth, ...researchSources] : []), maxTurns, + activeForkDraft?.sourceRunId ?? "", + activeForkDraft?.id ?? "", planRevision?.sourceRunId ?? "", planRevision?.planId ?? "", planRevision?.revision ?? 0, @@ -2638,11 +2848,42 @@ export default function App() { maxTurns, idempotencyKey: attempt.key, ...(sessionId === undefined ? {} : { sessionId }), + ...(activeForkDraft === undefined + ? {} + : { branch: threadForkBranch(activeForkDraft) }), ...(planRevision === null ? {} : { planRevision }), }, route, ); if (result.type === "accepted") { + if (activeForkDraft !== undefined) { + const forkTitle = resolveThreadTitle( + activeForkDraft.spaceId, + activeForkDraft.id, + activeForkDraft.title, + ); + setStoredThreadNames((current) => { + const next = setThreadName( + current, + activeForkDraft.spaceId, + result.run.sessionId, + forkTitle, + ); + storeThreadNames(next); + return next; + }); + if (pinnedThreadSessionIds.has(activeForkDraft.id)) { + updateThreadPin(activeForkDraft.spaceId, activeForkDraft.id, false); + updateThreadPin(activeForkDraft.spaceId, result.run.sessionId, true); + } + updateForkDrafts((current) => + current.map((draft) => + draft.id === activeForkDraft.id + ? { ...draft, materializedSessionId: result.run.sessionId } + : draft, + ), + ); + } createAttempt.current = null; setPrompt(""); setPlanRevision(null); @@ -4229,6 +4470,25 @@ export default function App() { } async function handleArchiveThread(run: Run) { + const forkDraft = threadForkDrafts.find( + (draft) => + draft.id === run.runId && + draft.spaceId === desktopRef.current.selectedSpaceId, + ); + if (forkDraft !== undefined) { + if (connectingRef.current || submitInFlight.current) return; + updateForkDrafts((current) => + current.filter((draft) => draft.id !== forkDraft.id), + ); + setForkPreviewViews((current) => { + const next = new Map(current); + next.delete(forkDraft.id); + return next; + }); + if (chatRef.current.activeRunId === forkDraft.id) newWork(); + updateThreadPin(forkDraft.spaceId, forkDraft.id, false); + return; + } if ( connectingRef.current || submitInFlight.current || @@ -4704,6 +4964,12 @@ export default function App() { const activeView = chat.activeRunId === null ? undefined : chat.views.get(chat.activeRunId); const activeRun = activeView?.run; + const activeForkOrigin = threadForkDrafts.find( + (draft) => + draft.spaceId === desktop.selectedSpaceId && + draft.materializedSessionId !== undefined && + draft.materializedSessionId === activeRun?.sessionId, + ); useEffect(() => { delegateRequest.current = null; dispatchDelegate({ type: "reset" }); @@ -4857,8 +5123,11 @@ export default function App() { submitting, ]); const conversationViews = useMemo( - () => selectConversationViews(chat, activeRun?.sessionId ?? null), - [activeRun?.sessionId, chat], + () => + activeForkDraft === undefined + ? selectConversationViews(chat, activeRun?.sessionId ?? null) + : (forkPreviewViews.get(activeForkDraft.id) ?? []), + [activeRun?.sessionId, activeForkDraft, forkPreviewViews, chat], ); const asideView = asideChat.activeRunId === null @@ -5030,17 +5299,23 @@ export default function App() { const generatedTitle = openingRun?.title ?? conversationViews[0]?.run.title ?? activeRun?.title; const title = - activeRun === undefined || generatedTitle === undefined - ? "New work" - : safeDisplayLabel( - resolveThreadTitle( - desktop.selectedSpaceId, - activeRun.sessionId, - generatedTitle, - ), - agentRoleLabel(activeRun.role), - 160, - ); + activeForkDraft !== undefined + ? resolveThreadTitle( + activeForkDraft.spaceId, + activeForkDraft.id, + activeForkDraft.title, + ) + : activeRun === undefined || generatedTitle === undefined + ? "New work" + : safeDisplayLabel( + resolveThreadTitle( + desktop.selectedSpaceId, + activeRun.sessionId, + generatedTitle, + ), + agentRoleLabel(activeRun.role), + 160, + ); const closeWorkNavigation = useCallback( () => setWorkNavigationOpen(false), [], @@ -5054,7 +5329,10 @@ export default function App() { const selectSurface = useCallback((nextSurface: WorkspaceSurface) => { setWorkNavigationOpen(false); - if (nextSurface === "settings") setSettingsStartTab("runtime"); + if (nextSurface === "settings") { + setSettingsStartTab("runtime"); + setSettingsSpaceId(undefined); + } setSurface(nextSurface); }, []); @@ -5102,7 +5380,7 @@ export default function App() { configuration: desktop.managedModelConfiguration, }} onOpenModelSettings={() => { - setSettingsStartTab("providers"); + setSettingsStartTab("models"); setWorkNavigationOpen(false); setSurface("settings"); }} @@ -5264,7 +5542,16 @@ export default function App() { {onboardingActive || surface === "settings" ? null : ( + draft.spaceId === desktop.selectedSpaceId && + draft.materializedSessionId === undefined, + ) + .map(threadForkDraftRun), + ...chat.recentRuns, + ]} spaces={desktop.spaces} selectedSpaceId={desktop.selectedSpaceId} surface={surface} @@ -5273,7 +5560,7 @@ export default function App() { terminalEnabled={desktop.terminalEnabled} terminalConsentPending={desktop.terminalConsentPending === true} terminalAvailable={terminalAvailable} - activeSessionId={activeRun?.sessionId ?? null} + activeSessionId={activeForkDraft?.id ?? activeRun?.sessionId ?? null} pinnedSessionIds={pinnedThreadSessionIds} resolveThreadTitle={resolveThreadTitle} query={workQuery} @@ -5317,6 +5604,9 @@ export default function App() { onRestoreSpace={(spaceId) => void handleRestoreSpace(spaceId)} onArchiveThread={(run) => void handleArchiveThread(run)} onRenameThread={handleRenameThread} + onForkThread={ + desktop.selectedSpaceId === null ? undefined : beginThreadFork + } onToggleThreadPinned={handleToggleThreadPinned} onRestoreThread={(result) => void handleRestoreThread(result)} onSelectSurface={selectSurface} @@ -5413,6 +5703,12 @@ export default function App() { }} title={title} view={activeView} + forkDraft={activeForkDraft !== undefined} + inheritedViews={ + activeForkOrigin === undefined + ? [] + : (forkPreviewViews.get(activeForkOrigin.id) ?? []) + } conversationViews={conversationViews} connection={connection} connecting={connecting} @@ -5558,70 +5854,97 @@ export default function App() { onCloseAside={closeAside} /> ) : ( - { - setSettingsStartTab("plugins"); - setSurface("settings"); - }} - onReturnToWork={() => selectSurface("work")} - pluginSelections={pluginSelections} - onUsePluginSkill={(id) => { - setConversationSkills((current) => ({ - ...current, - [selectionKey]: [ - ...new Set([...(current[selectionKey] ?? []), id]), - ], - })); - setSurface("work"); - requestAnimationFrame(() => composerRef.current?.focus()); - }} - surface={surface} - connection={connection} - desktop={desktop} - connecting={connecting} - updateChecking={updateChecking} - updateMessage={updateMessage} - runs={chat.recentRuns} - artifacts={allArtifacts} - demoParticipants={FIXTURE_MODE ? DEMO_PARTICIPANTS : null} - workNavigationOpen={workNavigationOpen} - onOpenWorkNavigation={openWorkNavigation} - onConnect={() => void connect(desktop.selectedTargetId ?? undefined)} - onOpenRun={(run) => void openRun(run)} - onSelectTarget={(targetId) => void handleSelectTarget(targetId)} - onAddExternalTarget={() => void handleAddExternalTarget()} - onRemoveExternalTarget={(targetId) => - void handleRemoveExternalTarget(targetId) - } - onChooseWorkspace={() => void handleChooseWorkspace()} - onConfigureManaged={() => setShowOnboarding(true)} - onRestartManaged={() => void handleRestartManaged()} - onSetTerminalEnabled={(enabled) => - void handleSetTerminalEnabled(enabled) + +

Loading page…

+ } - onOpenTerminal={(kind) => void handleOpenTerminal(kind)} - onExportDiagnostics={() => { - void exportDiagnostics().catch((error: unknown) => { - setActionError( - error instanceof CommandFailure - ? error.detail - : FALLBACK_ACTION_ERROR, - ); - }); - }} - onCheckForUpdates={() => void handleCheckDesktopUpdate()} - onInstallUpdate={() => void handleInstallDesktopUpdate()} - onImportCaBundle={() => void handleImportCaBundle()} - onRemoveCaBundle={() => void handleRemoveCaBundle()} - onImportClientIdentity={() => void handleImportClientIdentity()} - onRemoveClientIdentity={() => void handleRemoveClientIdentity()} - /> + > + { + if ( + !desktopRef.current.spaces.some( + (space) => space.spaceId === spaceId, + ) + ) + return; + setSettingsSpaceId(spaceId); + setSettingsStartTab("cloud"); + setSurface("settings"); + }} + onManageControlPlaneProfiles={() => { + setSettingsSpaceId(undefined); + setSettingsStartTab("control-plane"); + setSurface("settings"); + }} + onConfigurePluginConnection={() => { + setSettingsStartTab("plugins"); + setSurface("settings"); + }} + onReturnToWork={() => selectSurface("work")} + pluginSelections={pluginSelections} + onUsePluginSkill={(id) => { + setConversationSkills((current) => ({ + ...current, + [selectionKey]: [ + ...new Set([...(current[selectionKey] ?? []), id]), + ], + })); + setSurface("work"); + requestAnimationFrame(() => composerRef.current?.focus()); + }} + surface={surface} + connection={connection} + desktop={desktop} + connecting={connecting} + updateChecking={updateChecking} + updateMessage={updateMessage} + runs={chat.recentRuns} + artifacts={allArtifacts} + demoParticipants={FIXTURE_MODE ? DEMO_PARTICIPANTS : null} + workNavigationOpen={workNavigationOpen} + onOpenWorkNavigation={openWorkNavigation} + onConnect={() => + void connect(desktop.selectedTargetId ?? undefined) + } + onOpenRun={(run) => void openRun(run)} + onSelectTarget={(targetId) => void handleSelectTarget(targetId)} + onAddExternalTarget={() => void handleAddExternalTarget()} + onRemoveExternalTarget={(targetId) => + void handleRemoveExternalTarget(targetId) + } + onChooseWorkspace={() => void handleChooseWorkspace()} + onConfigureManaged={() => setShowOnboarding(true)} + onRestartManaged={() => void handleRestartManaged()} + onSetTerminalEnabled={(enabled) => + void handleSetTerminalEnabled(enabled) + } + onOpenTerminal={(kind) => void handleOpenTerminal(kind)} + onExportDiagnostics={() => { + void exportDiagnostics().catch((error: unknown) => { + setActionError( + error instanceof CommandFailure + ? error.detail + : FALLBACK_ACTION_ERROR, + ); + }); + }} + onCheckForUpdates={() => void handleCheckDesktopUpdate()} + onInstallUpdate={() => void handleInstallDesktopUpdate()} + onImportCaBundle={() => void handleImportCaBundle()} + onRemoveCaBundle={() => void handleRemoveCaBundle()} + onImportClientIdentity={() => void handleImportClientIdentity()} + onRemoveClientIdentity={() => void handleRemoveClientIdentity()} + /> +
)} ); diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 8ddb2fc86..594b24ef5 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -345,6 +345,7 @@ import type { ConfigureManagedRuntimeRequest, CodexAuthStatus, ConnectionStatus, + ControlPlaneProfiles, CreateRunRequest, DesktopStatus, DesktopReleaseChannel, @@ -497,6 +498,11 @@ export function desktopStatus(): Promise { return call("desktop_status"); } +/** Cached public status and saved bookmarks; does not read the enrollment vault. */ +export function controlPlaneProfiles(): Promise { + return call("control_plane_profiles"); +} + export function codexAuthStatus(): Promise { return call("codex_auth_status"); } 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 new file mode 100644 index 000000000..2dd6f45f0 --- /dev/null +++ b/apps/desktop/src/components/CloudConnectionPane.tsx @@ -0,0 +1,357 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { Button, RadioGroup, TextInput } from "@colossus/ui"; +import { + IconPlugConnected, + IconPlugConnectedX, + IconRefresh, + IconShieldLock, +} from "@tabler/icons-react"; +import "./cloud-connection.css"; +import type { ControlPlaneProfiles } from "./ControlPlaneSettingsPane"; +interface CloudStatus { + targetId: string; + status: + "connecting" | "connected" | "reconnecting" | "disconnected" | "revoked"; + nodeId: string | null; + projectId: string | null; + endpoint: string | null; + hostId?: string | null; + workspaceId?: string | null; + sharedSessions?: boolean; + sharedContinuation?: boolean; + sharingSupported?: boolean; + sharingRecoveryRequired?: boolean; + sharingRestartRequired?: boolean; +} +export function CloudConnectionPane({ targetId }: { targetId: string }) { + const headingId = useId(), + sharingHeadingId = useId(); + const [status, setStatus] = useState(null), + [url, setUrl] = useState(""), + [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; + 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) + : "Control Plane 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 Control Plane connection could not be changed.", + ); + } finally { + busyRef.current = false; + if (operation.current === generation) setBusy(false); + } + } + const active = + status && + ["connected", "connecting", "reconnecting"].includes(status.status); + const sharingBlocked = Boolean(status?.sharingRestartRequired); + useEffect(() => { + setSharing( + status?.sharedSessions + ? status.sharedContinuation + ? "continue" + : "read" + : "private", + ); + }, [status?.sharedSessions, status?.sharedContinuation, targetId]); + return ( +
+
+
+

Workspace connection

+

Control Plane

+

+ Make this runtime available to your project's 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 ? ( + <> +
+
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 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

+ {status.sharingRecoveryRequired ? ( +
+ Sharing needs reconciliation +

+ Synchronization is paused and the sharing state is + unconfirmed.{" "} + {sharingBlocked + ? "Restart Desktop before changing sharing or reconnecting." + : "Save your choice again, or reconnect to apply the last saved choice."}{" "} + Previously shared history remains available in the Control + Plane. +

+
+ ) : null} + +
+

+ 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. +

+ +
+ )} + + ) : ( +
{ + event.preventDefault(); + void action("cloud_enroll", { + request: { targetId, enrollmentUrl: url, token }, + }); + }} + > + + + + {error && ( + + )} +
+ )} + +
+ ); +} diff --git a/apps/desktop/src/components/ControlPlaneConnections.test.ts b/apps/desktop/src/components/ControlPlaneConnections.test.ts new file mode 100644 index 000000000..e518f21ca --- /dev/null +++ b/apps/desktop/src/components/ControlPlaneConnections.test.ts @@ -0,0 +1,142 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { expect, it } from "vitest"; +import { + ControlPlaneConnectionInventory, + controlPlaneState, +} from "./ControlPlaneConnections"; +import type { ControlPlaneProfiles } from "../types"; + +const catalog: ControlPlaneProfiles = { + revision: 1, + defaultProfile: "bookmark", + profiles: [ + { + id: "bookmark", + label: "Saved team endpoint", + endpoint: "https://bookmark.example.test", + }, + ], + connections: [ + { + targetId: "target-a", + status: "connected", + projectId: "project-a", + endpoint: "https://live.example.test", + sharedSessions: false, + }, + { + targetId: "space-b", + status: "disconnected", + projectId: "project-b", + endpoint: "https://offline.example.test", + sharedSessions: false, + }, + { + targetId: "missing", + status: "revoked", + projectId: "project-removed", + endpoint: "https://removed.example.test", + sharedSessions: false, + }, + ], +}; + +it("separates actual enrolled workspace state from endpoint bookmarks and missing workspace navigation", () => { + const markup = renderToStaticMarkup( + createElement(ControlPlaneConnectionInventory, { + catalog, + spaces: [ + { + spaceId: "space-a", + targetId: "target-a", + displayName: "Workspace A", + }, + { + spaceId: "space-b", + targetId: "target-b", + displayName: "Workspace B", + }, + ], + onManageWorkspace: () => undefined, + onManageProfiles: () => undefined, + }), + ); + expect(markup).toContain("Workspace A"); + expect(markup).toContain("Workspace B"); + expect(markup).toContain("Connected"); + expect(markup).toContain("Disconnected"); + expect(markup).toContain("Revoked"); + expect(markup).toContain("project-b"); + expect(markup).toContain("https://offline.example.test"); + expect(markup).toMatch( + /disabled=""[^>]*aria-label="Manage Control Plane for missing"/, + ); + const bookmark = markup.slice( + markup.indexOf('class="control-plane-bookmarks"'), + ); + expect(bookmark).toContain("Default bookmark"); + expect(bookmark).not.toContain("Connected"); +}); + +it("never turns an unrecognized or unavailable status into Connected", () => { + expect(controlPlaneState("connected")).toEqual({ + label: "Connected", + tone: "success", + }); + expect(controlPlaneState("connecting").label).toBe("Connecting"); + expect(controlPlaneState("reconnecting").label).toBe("Reconnecting"); + expect(controlPlaneState("unexpected").label).toBe("Unknown"); + expect(controlPlaneState("connected", true).label).toBe("Unknown"); +}); + +it("surfaces incomplete sharing changes without changing workspace navigation", () => { + const markup = renderToStaticMarkup( + createElement(ControlPlaneConnectionInventory, { + catalog: { + ...catalog, + connections: [ + { ...catalog.connections[1]!, sharingRecoveryRequired: true }, + ], + }, + spaces: [ + { + spaceId: "space-b", + targetId: "target-b", + displayName: "Workspace B", + }, + ], + onManageWorkspace: () => undefined, + onManageProfiles: () => undefined, + }), + ); + expect(markup).toContain("Sharing needs reconciliation"); + expect(markup).toContain("Disconnected"); + expect(markup).toContain('aria-label="Manage Control Plane for Workspace B"'); + expect(markup).not.toMatch( + /disabled=""[^>]*aria-label="Manage Control Plane for Workspace B"/, + ); +}); + +it("marks retained enrollment metadata Unknown while keeping endpoint bookmarks usable", () => { + const markup = renderToStaticMarkup( + createElement(ControlPlaneConnectionInventory, { + catalog: { ...catalog, connectionStatusUnavailable: true }, + spaces: [ + { + spaceId: "space-a", + targetId: "target-a", + displayName: "Workspace A", + }, + ], + onManageWorkspace: () => undefined, + onManageProfiles: () => undefined, + }), + ); + expect(markup).toContain("Unknown"); + expect(markup).not.toContain(">Connected<"); + expect(markup).not.toContain(">Revoked<"); + expect(markup).toContain("Saved team endpoint"); + expect(markup).toContain("Default bookmark"); + expect(markup).toContain("Manage endpoints"); +}); diff --git a/apps/desktop/src/components/ControlPlaneConnections.tsx b/apps/desktop/src/components/ControlPlaneConnections.tsx new file mode 100644 index 000000000..39792bf52 --- /dev/null +++ b/apps/desktop/src/components/ControlPlaneConnections.tsx @@ -0,0 +1,233 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "@colossus/ui"; +import { IconCloud, IconRefresh, IconSettings } from "@tabler/icons-react"; +import { controlPlaneProfiles } from "../api"; +import type { ControlPlaneProfiles, SpaceSummary } from "../types"; +import "./control-plane-connections.css"; + +export function controlPlaneState(status: string, unavailable = false) { + if (unavailable) return { label: "Unknown", tone: "neutral" }; + switch (status) { + case "connected": + return { label: "Connected", tone: "success" }; + case "connecting": + return { label: "Connecting", tone: "warning" }; + case "reconnecting": + return { label: "Reconnecting", tone: "warning" }; + case "disconnected": + return { label: "Disconnected", tone: "neutral" }; + case "revoked": + return { label: "Revoked", tone: "danger" }; + default: + return { label: "Unknown", tone: "neutral" }; + } +} + +/** Display-only inventory. Navigation never selects or starts a runtime. */ +export function ControlPlaneConnectionInventory({ + catalog, + spaces, + unavailable = false, + disabled = false, + onManageWorkspace, + onManageProfiles, +}: { + catalog: ControlPlaneProfiles; + spaces: readonly Pick[]; + unavailable?: boolean; + disabled?: boolean; + onManageWorkspace: (spaceId: string) => void; + onManageProfiles: () => void; +}) { + return ( + <> +
+ {catalog.connections.map((connection) => { + const space = + spaces.find((space) => space.spaceId === connection.targetId) ?? + spaces.find((space) => space.targetId === connection.targetId); + const status = controlPlaneState( + connection.status, + unavailable || Boolean(catalog.connectionStatusUnavailable), + ); + return ( +
+
+
+
+
Project
+
{connection.projectId ?? "Not reported"}
+
Endpoint
+
{connection.endpoint ?? "Not reported"}
+ {connection.sharingRecoveryRequired ? ( + <> +
Sharing
+
+ {connection.sharingRestartRequired + ? "Restart Desktop to reconcile sharing" + : "Sharing needs reconciliation"} +
+ + ) : null} +
+ {!space ? ( +

+ This enrolled workspace is not in the current Desktop + inventory. No other workspace will be selected. +

+ ) : null} +
+ ); + })} + {catalog.connections.length === 0 ? ( +

+ No Control Plane enrollments are currently reported by this Desktop. +

+ ) : null} +
+
+
+
+

Saved endpoints

+

+ Bookmarks help with enrollment; they do not indicate a runtime + connection. +

+
+ +
+ {catalog.profiles.length ? ( +
    + {catalog.profiles.map((profile) => ( +
  • + {profile.label} + {profile.endpoint} + {catalog.defaultProfile === profile.id ? ( + + Default bookmark + + ) : null} +
  • + ))} +
+ ) : ( +

No saved endpoints.

+ )} +
+ + ); +} + +export function ControlPlaneConnections( + props: Omit< + Parameters[0], + "catalog" | "unavailable" + >, +) { + const [catalog, setCatalog] = useState(null); + const [unavailable, setUnavailable] = useState(false); + const [loading, setLoading] = useState(true); + const alive = useRef(false), + pending = useRef(false); + async function refresh() { + if (pending.current) return; + pending.current = true; + try { + const next = await controlPlaneProfiles(); + if (alive.current) { + setCatalog((current) => ({ + ...next, + connections: + next.connectionStatusUnavailable && !next.connections.length + ? (current?.connections ?? []) + : next.connections, + })); + setUnavailable(Boolean(next.connectionStatusUnavailable)); + } + } catch { + if (alive.current) setUnavailable(true); + } finally { + pending.current = false; + if (alive.current) setLoading(false); + } + } + useEffect(() => { + alive.current = true; + void refresh(); + const timer = setInterval(() => void refresh(), 5000); + return () => { + alive.current = false; + clearInterval(timer); + }; + }, []); + return ( +
+
+
+

Enrolled workspace runtimes

+

Control Plane

+
+ +
+ {loading && !catalog ? ( +

+ Reading Control Plane enrollment status… +

+ ) : null} + {unavailable ? ( +

+ Control Plane status could not be refreshed. Any retained enrollment + state is unknown until a successful read. +

+ ) : null} + {catalog ? ( + + ) : unavailable ? ( + + ) : null} +
+ ); +} diff --git a/apps/desktop/src/components/ControlPlaneSettingsPane.tsx b/apps/desktop/src/components/ControlPlaneSettingsPane.tsx new file mode 100644 index 000000000..8d05d7fdb --- /dev/null +++ b/apps/desktop/src/components/ControlPlaneSettingsPane.tsx @@ -0,0 +1,269 @@ +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"; +import "./cloud-connection.css"; +import type { ControlPlaneProfile, ControlPlaneProfiles } from "../types"; +export type { ControlPlaneProfiles } from "../types"; + +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: ControlPlaneProfile[], + 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 ( +
+
+
+

Shared resources

+

Control Plane

+
+
+

+ {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 || catalog.connectionStatusUnavailable ? ( +
+

Enrolled workspace runtimes

+ {catalog.connectionStatusUnavailable ? ( +

+ Enrollment status is unavailable. Saved endpoints remain usable. +

+ ) : null} +
    + {catalog.connections.map((connection) => ( +
  • + {connection.projectId ?? "Workspace"} + + {catalog.connectionStatusUnavailable + ? "Unknown" + : connection.status}{" "} + ·{" "} + {catalog.connectionStatusUnavailable + ? "Sharing status unknown" + : connection.sharingRestartRequired + ? "Restart Desktop to reconcile sharing" + : connection.sharingRecoveryRequired + ? "Sharing needs reconciliation" + : 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.test.ts b/apps/desktop/src/components/ManagedSettingsPane.test.ts index 7e2c304bb..e3e07c1f3 100644 --- a/apps/desktop/src/components/ManagedSettingsPane.test.ts +++ b/apps/desktop/src/components/ManagedSettingsPane.test.ts @@ -1,5 +1,6 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { RadioGroup } from "@colossus/ui"; import { describe, expect, it, vi } from "vitest"; import { deleteMcpFixture, managedMcpConsumers } from "../mcp-deletion"; import { McpDeleteDialog } from "./McpDeleteDialog"; @@ -267,7 +268,9 @@ function desktop(): DesktopStatus { }; } -function renderPane(): string { +function renderPane( + overrides: Partial[0]> = {}, +): string { return renderToStaticMarkup( createElement(ManagedSettingsPane, { desktop: desktop(), @@ -288,6 +291,7 @@ function renderPane(): string { onImportClientIdentity: vi.fn(), onRemoveClientIdentity: vi.fn(), onExportDiagnostics: vi.fn(), + ...overrides, }), ); } @@ -1602,3 +1606,46 @@ describe("MCP deletion", () => { ).toThrow(/unknown/); }); }); + +it("offers compact settings radio choices without changing the default cards or selection semantics", () => { + const options = [ + { value: "private", label: "Control Plane conversations only" }, + { value: "read", label: "Share Desktop history for viewing" }, + ]; + const compact = renderToStaticMarkup( + createElement(RadioGroup, { + value: "private", + onValueChange: () => undefined, + options, + variant: "compact", + "aria-label": "Desktop conversation sharing", + }), + ); + expect(compact).toContain("ui-radio-group--compact"); + expect(compact).toContain('role="radiogroup"'); + expect(compact).toContain('aria-label="Desktop conversation sharing"'); + expect(compact).toMatch(/aria-checked="true"[^>]*tabindex="0"/); + expect(compact).toMatch(/aria-checked="false"[^>]*tabindex="-1"/); + const cards = renderToStaticMarkup( + createElement(RadioGroup, { + value: "private", + onValueChange: () => undefined, + options, + "aria-label": "Desktop conversation sharing", + }), + ); + expect(cards).not.toContain("ui-radio-group--compact"); + expect(cards).toContain("ui:px-3 ui:py-3"); +}); + +it("opens explicit Control Plane settings without falling through a missing workspace", () => { + expect(renderPane({ initialSpaceTab: "control-plane" })).toContain( + "Loading Control Plane connections", + ); + const missing = renderPane({ + initialSpaceTab: "cloud", + initialSpaceId: "missing-workspace", + }); + expect(missing).not.toContain("cloud-settings-state"); + expect(missing).not.toContain("Make this runtime available"); +}); diff --git a/apps/desktop/src/components/ManagedSettingsPane.tsx b/apps/desktop/src/components/ManagedSettingsPane.tsx index 725b1314d..4828cd2e3 100644 --- a/apps/desktop/src/components/ManagedSettingsPane.tsx +++ b/apps/desktop/src/components/ManagedSettingsPane.tsx @@ -1,3 +1,5 @@ +import { CloudConnectionPane } from "./CloudConnectionPane"; +import { ControlPlaneSettingsPane } from "./ControlPlaneSettingsPane"; import { ModelFeatureControl } from "./ModelFeatureControl"; import type { ModelFeatureMode, ManagedModelCapabilities } from "../types"; import { RememberedCommands } from "./RememberedCommands"; @@ -6,6 +8,7 @@ import { useSetupPackages } from "./setup/useSetupPackages"; import { ImportedProviderActions } from "./setup/ImportedProviderActions"; import { SettingsFrame } from "./SettingsFrame"; import { ModelRoleRouting } from "./ModelRoleRouting"; +import { ModelRoutingOverview } from "./ModelRoutingOverview"; import { RuntimeProfileRow } from "./RuntimeProfileRow"; import { subscribeSettingsUpdates, @@ -220,6 +223,7 @@ type DesktopTab = (typeof DESKTOP_PAGES)[number]["id"]; type SettingsScope = "global" | "space"; type GlobalTab = + | "control-plane" | "providers" | "models" | "credentials" @@ -230,8 +234,11 @@ type GlobalTab = | "defaults" | DesktopTab; type SpaceTab = + | "cloud" | "runtime" | "providers" + | "models" + | "routing" | "mcp" | "plugins" | "access" @@ -244,7 +251,17 @@ type SpaceTab = interface ManagedSettingsPaneProps { initialSpaceTab?: - "runtime" | "providers" | "plugins" | "terminal" | "dictation" | undefined; + | "runtime" + | "providers" + | "models" + | "routing" + | "plugins" + | "terminal" + | "dictation" + | "cloud" + | "control-plane" + | undefined; + initialSpaceId?: string | undefined; desktop: DesktopStatus; connecting: boolean; updateChecking: boolean; @@ -370,6 +387,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" }, @@ -382,7 +400,10 @@ const GLOBAL_TABS: ReadonlyArray<{ const SPACE_TABS: ReadonlyArray<{ id: SpaceTab; label: string }> = [ { id: "runtime", label: "Runtime" }, + { id: "cloud", label: "Control Plane" }, { id: "providers", label: "Providers" }, + { id: "models", label: "Models" }, + { id: "routing", label: "Routing" }, { id: "mcp", label: "MCP" }, { id: "plugins", label: "Plugins" }, { id: "access", label: "Access" }, @@ -1683,6 +1704,7 @@ function mcpEntry( export function ManagedSettingsPane({ initialSpaceTab = "runtime", + initialSpaceId, onReturnToWork, desktop, connecting, @@ -1708,17 +1730,23 @@ export function ManagedSettingsPane({ ); const [snapshot, setSnapshot] = useState(initial); const [scope, setScope] = useState( - initialSpaceTab === "terminal" || initialSpaceTab === "dictation" + initialSpaceTab === "terminal" || + initialSpaceTab === "dictation" || + initialSpaceTab === "control-plane" ? "global" : "space", ); const [globalTab, setGlobalTab] = useState( - initialSpaceTab === "terminal" || initialSpaceTab === "dictation" + initialSpaceTab === "terminal" || + initialSpaceTab === "dictation" || + initialSpaceTab === "control-plane" ? initialSpaceTab : "mcp", ); const [spaceTab, setSpaceTab] = useState( - initialSpaceTab === "terminal" || initialSpaceTab === "dictation" + initialSpaceTab === "terminal" || + initialSpaceTab === "dictation" || + initialSpaceTab === "control-plane" ? "runtime" : initialSpaceTab, ); @@ -1727,7 +1755,7 @@ export function ManagedSettingsPane({ ReadonlySet >(() => new Set()); const [selectedSpaceId, setSelectedSpaceId] = useState( - desktop.selectedSpaceId ?? initial.spaces[0]?.id ?? "", + initialSpaceId ?? desktop.selectedSpaceId ?? initial.spaces[0]?.id ?? "", ); const [query, setQuery] = useState(""); const [actionBusy, setBusy] = useState(false); @@ -1823,7 +1851,7 @@ export function ManagedSettingsPane({ const selectedSpace = snapshot.spaces.find((candidate) => candidate.id === selectedSpaceId) ?? - snapshot.spaces[0]; + (initialSpaceId === undefined ? snapshot.spaces[0] : undefined); useEffect(() => { if (!isTauriRuntime()) return; @@ -3068,79 +3096,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 ? ( ; + if (tab === "plugins") { return ( ); } - if (tab === "providers") { + if (tab === "providers" || tab === "models" || tab === "routing") { const selectedModels = snapshot.globalConfiguration.models.filter((entry) => draft.selectedModels.includes(entry.id), ); @@ -4965,129 +5006,176 @@ export function SpaceSettingsBody({
-

Pinned global revisions

-

Providers and models

+

+ {tab === "routing" + ? "Workspace routes" + : "Pinned global revisions"} +

+

+ {tab === "providers" + ? "Providers" + : tab === "models" + ? "Models" + : "Routing"} +

+

+ {tab === "providers" + ? "Choose which provider connections this workspace can use." + : tab === "models" + ? "Enable models from the providers selected for this workspace." + : "Assign models to roles and inspect their effective provider routes."} +

- {draft.selectedProviders.length} providers ·{" "} - {draft.selectedModels.length} models + {tab === "providers" + ? `${draft.selectedProviders.length} selected ${draft.selectedProviders.length === 1 ? "provider" : "providers"}` + : `${draft.selectedModels.length} enabled ${draft.selectedModels.length === 1 ? "model" : "models"}`}
-
- {snapshot.globalConfiguration.providers.map((entry) => { - const provider = currentValue(entry); - const enabled = draft.selectedProviders.includes(entry.id); - return ( - - { - const selectedProviders = event.target.checked - ? [...draft.selectedProviders, entry.id] - : draft.selectedProviders.filter( - (id) => id !== entry.id, - ); - const removedModels = new Set( - snapshot.globalConfiguration.models - .filter( - (model) => - currentValue(model).providerProfile === - provider.profile, - ) - .map((model) => model.id), - ); - const selectedModelProfiles = new Set( - snapshot.globalConfiguration.models - .filter( - (model) => - event.target.checked || - !removedModels.has(model.id), - ) - .map((model) => currentValue(model).profile), - ); - setDraft({ - ...draft, - selectedProviders, - selectedModels: event.target.checked - ? draft.selectedModels - : draft.selectedModels.filter( - (id) => !removedModels.has(id), - ), - modelRoles: Object.fromEntries( - Object.entries(draft.modelRoles).filter( - ([, profile]) => selectedModelProfiles.has(profile), - ), - ), - }); - }} - /> - - ); - })} - {snapshot.globalConfiguration.models.map((entry) => { - const model = currentValue(entry); - const providerSelected = - snapshot.globalConfiguration.providers.some( - (provider) => - draft.selectedProviders.includes(provider.id) && - currentValue(provider).profile === model.providerProfile, - ); - const enabled = draft.selectedModels.includes(entry.id); - return ( - - { - const selectedModels = event.target.checked - ? [...draft.selectedModels, entry.id] - : draft.selectedModels.filter((id) => id !== entry.id); - const modelRoles = Object.fromEntries( - Object.entries(draft.modelRoles).filter( - ([, profile]) => - event.target.checked || profile !== model.profile, - ), + {tab !== "routing" ? ( +
+ {tab === "providers" + ? snapshot.globalConfiguration.providers.map((entry) => { + const provider = currentValue(entry); + const enabled = draft.selectedProviders.includes(entry.id); + return ( + + { + const selectedProviders = event.target.checked + ? [...draft.selectedProviders, entry.id] + : draft.selectedProviders.filter( + (id) => id !== entry.id, + ); + const removedModels = new Set( + snapshot.globalConfiguration.models + .filter( + (model) => + currentValue(model).providerProfile === + provider.profile, + ) + .map((model) => model.id), + ); + const selectedModelProfiles = new Set( + snapshot.globalConfiguration.models + .filter( + (model) => + event.target.checked || + !removedModels.has(model.id), + ) + .map((model) => currentValue(model).profile), + ); + setDraft({ + ...draft, + selectedProviders, + selectedModels: event.target.checked + ? draft.selectedModels + : draft.selectedModels.filter( + (id) => !removedModels.has(id), + ), + modelRoles: Object.fromEntries( + Object.entries(draft.modelRoles).filter( + ([, profile]) => + selectedModelProfiles.has(profile), + ), + ), + }); + }} + /> + + ); + }) + : null} + {tab === "models" + ? snapshot.globalConfiguration.models.map((entry) => { + const model = currentValue(entry); + const providerSelected = + snapshot.globalConfiguration.providers.some( + (provider) => + draft.selectedProviders.includes(provider.id) && + currentValue(provider).profile === + model.providerProfile, ); - setDraft({ ...draft, selectedModels, modelRoles }); - }} - /> - - ); - })} -
- ({ - ...currentValue(entry), - label: entry.label, - }))} - disabled={busy} - onChange={(modelRoles) => setDraft({ ...draft, modelRoles })} - /> + const enabled = draft.selectedModels.includes(entry.id); + return ( + + { + const selectedModels = event.target.checked + ? [...draft.selectedModels, entry.id] + : draft.selectedModels.filter( + (id) => id !== entry.id, + ); + const modelRoles = Object.fromEntries( + Object.entries(draft.modelRoles).filter( + ([, profile]) => + event.target.checked || + profile !== model.profile, + ), + ); + setDraft({ ...draft, selectedModels, modelRoles }); + }} + /> + + ); + }) + : null} +
+ ) : null} + {tab === "routing" ? ( + <> + ({ + ...currentValue(entry), + label: entry.label, + }))} + disabled={busy} + onChange={(modelRoles) => setDraft({ ...draft, modelRoles })} + /> + ({ + ...currentValue(entry), + label: entry.label, + }))} + disabled={busy} + onChange={(modelRoles) => setDraft({ ...draft, modelRoles })} + /> + + ) : null}
); diff --git a/apps/desktop/src/components/MarkdownContent.test.ts b/apps/desktop/src/components/MarkdownContent.test.ts index 4172ddd42..13203ac71 100644 --- a/apps/desktop/src/components/MarkdownContent.test.ts +++ b/apps/desktop/src/components/MarkdownContent.test.ts @@ -39,8 +39,8 @@ const ready = true; expect(markup).toContain("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/ModelRoleRouting.tsx b/apps/desktop/src/components/ModelRoleRouting.tsx index 2a53ce4f6..03cb90da4 100644 --- a/apps/desktop/src/components/ModelRoleRouting.tsx +++ b/apps/desktop/src/components/ModelRoleRouting.tsx @@ -2,7 +2,7 @@ import { useId } from "react"; import { DropdownSelect } from "./DropdownSelect"; import "./model-role-routing.css"; -const MODEL_ROLES = [ +export const MODEL_ROLES = [ { id: "primary", label: "Primary", @@ -47,13 +47,69 @@ const MODEL_ROLES = [ }, ] as const; -interface RoleModel { +export interface RoleModel { profile: string; label: string; model: string; providerProfile: string; } +/** One role editor shared by the routing matrix and graph inspector. */ +export function ModelRoleSelect({ + roleId, + roles, + models, + onChange, + disabled = false, + id, + describedBy, + label, +}: { + roleId: string; + roles: Record; + models: RoleModel[]; + onChange: (roles: Record) => void; + disabled?: boolean; + id?: string; + describedBy?: string; + label: string; +}) { + const primary = roleId === "primary"; + const assigned = roles[roleId] ?? ""; + const unavailable = + assigned && !models.some((model) => model.profile === assigned); + return ( + { + const next = { ...roles }; + if (event.target.value) next[roleId] = event.target.value; + else delete next[roleId]; + onChange(next); + }} + > + + {unavailable ? ( + + ) : null} + {models.map((model) => ( + + ))} + + ); +} + export function ModelRoleRouting({ roles, models, @@ -88,8 +144,6 @@ export function ModelRoleRouting({ const effective = models.find( (model) => model.profile === effectiveProfile, ); - const unavailable = - assigned && !models.some((model) => model.profile === assigned); return (
{role.id}
- { - const next = { ...roles }; - if (event.target.value) next[role.id] = event.target.value; - else delete next[role.id]; - onChange(next); - }} - > - - {unavailable ? ( - - ) : null} - {models.map((model) => ( - - ))} - + roleId={role.id} + roles={roles} + models={models} + label={`${role.label} model`} + describedBy={`${id}-${role.id}-description ${id}-${role.id}-effective`} + disabled={disabled} + onChange={onChange} + /> {effective ? `Uses ${effective.model} · ${effective.providerProfile}` diff --git a/apps/desktop/src/components/ModelRoutingGraph.tsx b/apps/desktop/src/components/ModelRoutingGraph.tsx new file mode 100644 index 000000000..6d4bf323b --- /dev/null +++ b/apps/desktop/src/components/ModelRoutingGraph.tsx @@ -0,0 +1,338 @@ +import { + Background, + BackgroundVariant, + Handle, + MarkerType, + Position, + ReactFlow, + type Edge, + type Node, + type NodeProps, + type ReactFlowInstance, +} from "@xyflow/react"; +import { + IconCloud, + IconCpu, + IconFocus2, + IconUserCircle, + IconZoomIn, + IconZoomOut, +} from "@tabler/icons-react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { GraphWorkspace } from "@colossus/ui"; +import { DropdownSelect } from "./DropdownSelect"; +import { + MODEL_ROLES, + ModelRoleSelect, + type RoleModel, +} from "./ModelRoleRouting"; +import { modelRouteGroups } from "./model-routing-map"; +import "@xyflow/react/dist/style.css"; +import "./workflow-logic.css"; + +type RouteNode = Node< + { + kind: "role" | "model" | "provider"; + label: string; + detail: string; + selected: boolean; + onSelect?: () => void; + }, + "route" +>; + +const RoutingNode = memo(function RoutingNode({ data }: NodeProps) { + const Icon = + data.kind === "role" + ? IconUserCircle + : data.kind === "model" + ? IconCpu + : IconCloud; + const content = ( + <> + + + {data.label} + {data.detail} + + ); + return ( + <> + {data.kind !== "role" ? ( + + ) : null} + {data.kind !== "provider" ? ( + + ) : null} + {data.kind === "role" ? ( + + ) : ( +
+ {content} +
+ )} + + ); +}); +const NODE_TYPES = { route: RoutingNode }; +const FIT = { padding: 0.13, minZoom: 0.25, maxZoom: 1 }; + +export function ModelRoutingGraph({ + roles, + models, + onChange, + disabled = false, +}: { + roles: Record; + models: RoleModel[]; + onChange?: ((roles: Record) => void) | undefined; + disabled?: boolean; +}) { + const [selectedRole, setSelectedRole] = useState("primary"); + const flow = useRef | null>(null); + const canvas = useRef(null); + const profile = + roles[selectedRole] || + (selectedRole === "primary" ? "" : roles.primary) || + ""; + const model = models.find((candidate) => candidate.profile === profile); + const role = MODEL_ROLES.find((candidate) => candidate.id === selectedRole)!; + const inherited = selectedRole !== "primary" && !roles[selectedRole]; + const selectRole = useCallback((id: string) => { + setSelectedRole(id); + }, []); + const { nodes, edges } = useMemo(() => { + const groups = modelRouteGroups(roles, models); + const providers = [ + ...new Set(groups.map((group) => group.model?.providerProfile || "")), + ]; + const height = (MODEL_ROLES.length - 1) * 86; + const nodes: RouteNode[] = MODEL_ROLES.map((item, index) => ({ + id: `role:${item.id}`, + type: "route", + position: { x: 0, y: index * 86 }, + width: 210, + height: 72, + data: { + kind: "role", + label: item.label, + detail: + !roles[item.id] && item.id === "primary" + ? "Assignment required" + : item.id !== "primary" && !roles[item.id] + ? "Inherits Primary" + : "Explicit assignment", + selected: selectedRole === item.id, + onSelect: () => selectRole(item.id), + }, + })); + groups.forEach((group, index) => + nodes.push({ + id: `model:${group.profile}`, + type: "route", + position: { + x: 320, + y: + groups.length === 1 + ? height / 2 + : (index * height) / (groups.length - 1), + }, + width: 210, + height: 72, + data: { + kind: "model", + label: + group.model?.label || + (group.profile ? "Unavailable model" : "No model assigned"), + detail: + group.model?.model || group.profile || "Choose a primary model", + selected: group.profile === profile, + }, + }), + ); + providers.forEach((provider, index) => + nodes.push({ + id: `provider:${provider}`, + type: "route", + position: { + x: 640, + y: + providers.length === 1 + ? height / 2 + : (index * height) / (providers.length - 1), + }, + width: 210, + height: 72, + data: { + kind: "provider", + label: provider || "Unresolved", + detail: provider ? "Configured connection" : "No available provider", + selected: provider === (model?.providerProfile || ""), + }, + }), + ); + const edges: Edge[] = []; + for (const group of groups) { + for (const item of group.roles) { + const active = item.id === selectedRole; + const color = active ? "var(--blue)" : "var(--border-strong)"; + edges.push({ + id: `role-edge:${item.id}`, + source: `role:${item.id}`, + target: `model:${group.profile}`, + type: "smoothstep", + markerEnd: { type: MarkerType.ArrowClosed, color }, + style: { + stroke: color, + strokeWidth: active ? 2.5 : 1.5, + strokeDasharray: item.inherited ? "5 4" : undefined, + }, + }); + } + const active = group.profile === profile; + const color = active ? "var(--blue)" : "var(--border-strong)"; + edges.push({ + id: `provider-edge:${group.profile}`, + source: `model:${group.profile}`, + target: `provider:${group.model?.providerProfile || ""}`, + type: "smoothstep", + markerEnd: { type: MarkerType.ArrowClosed, color }, + style: { stroke: color, strokeWidth: active ? 2.5 : 1.5 }, + }); + } + return { nodes, edges }; + }, [ + roles, + models, + selectedRole, + profile, + model?.providerProfile, + selectRole, + ]); + const fit = useCallback(() => { + void flow.current?.fitView(FIT); + }, []); + useEffect(() => { + if (!canvas.current) return; + const observer = new ResizeObserver(fit); + observer.observe(canvas.current); + return () => observer.disconnect(); + }, [fit]); + useEffect(() => { + const frame = requestAnimationFrame(fit); + return () => cancelAnimationFrame(frame); + }, [roles, models, fit]); + return ( + void flow.current?.zoomIn()} + onZoomOut={() => void flow.current?.zoomOut()} + onFit={fit} + zoomInIcon={} + zoomOutIcon={} + fitIcon={} + hint="Dashed: inherits Primary · Drag to pan" + inspectorLabel="Route details" + inspector={ + <> + ROUTE DETAILS + +

{role.label}

+

+ {!profile + ? "Choose a Primary model to resolve this route." + : inherited + ? "Inherits the Primary assignment." + : "Uses its own model assignment."} +

+ {onChange ? ( + + ) : null} +
+
Model
+
+ {model?.label || (profile ? "Unavailable model" : "Not assigned")} +
+
Provider
+
{model?.providerProfile || "Unresolved"}
+
+

{role.description}

+ + } + > + + nodes={nodes} + edges={edges} + nodeTypes={NODE_TYPES} + onInit={(instance) => { + flow.current = instance; + void instance.fitView(FIT); + }} + onNodeClick={() => undefined} + fitView + fitViewOptions={FIT} + minZoom={0.25} + maxZoom={1.8} + nodesDraggable={false} + nodesConnectable={false} + nodesFocusable={false} + edgesFocusable={false} + elementsSelectable={false} + panOnDrag + zoomOnScroll={false} + zoomOnDoubleClick={false} + preventScrolling={false} + aria-label="Model routing graph" + > + + +
+ ); +} diff --git a/apps/desktop/src/components/ModelRoutingOverview.test.ts b/apps/desktop/src/components/ModelRoutingOverview.test.ts new file mode 100644 index 000000000..8ed38f5aa --- /dev/null +++ b/apps/desktop/src/components/ModelRoutingOverview.test.ts @@ -0,0 +1,72 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { ModelRoutingOverview, modelRouteGroups } from "./ModelRoutingOverview"; + +const models = [ + { + profile: "general", + label: "General", + model: "general-model", + providerProfile: "company", + }, + { + profile: "fast", + label: "Fast", + model: "fast-model", + providerProfile: "local", + }, +]; + +describe("model routing map", () => { + it("moves inherited routes with Primary and preserves explicit assignments", () => { + const before = modelRouteGroups( + { primary: "general", research_planner: "fast" }, + models, + ); + expect( + before.find((group) => group.profile === "general")?.roles, + ).toHaveLength(6); + expect(before.find((group) => group.profile === "fast")?.roles).toEqual([ + { id: "research_planner", label: "Research planner", inherited: false }, + ]); + const after = modelRouteGroups( + { primary: "fast", research_planner: "general" }, + models, + ); + expect(after.find((group) => group.profile === "fast")?.roles).toHaveLength( + 6, + ); + expect( + after.find((group) => group.profile === "general")?.roles[0]?.inherited, + ).toBe(false); + }); + + it("shows an unavailable explicit target instead of silently using Primary", () => { + const groups = modelRouteGroups( + { primary: "general", risk_evaluator: "removed" }, + models, + ); + const missing = groups.find((group) => group.profile === "removed"); + expect(missing?.model).toBeUndefined(); + expect(missing?.roles).toEqual([ + { id: "risk_evaluator", label: "Risk evaluator", inherited: false }, + ]); + const markup = renderToStaticMarkup( + createElement(ModelRoutingOverview, { + roles: { primary: "general", risk_evaluator: "removed" }, + models, + }), + ); + expect(markup).toContain("Routing map"); + }); + + it("leaves inherited routes unresolved when Primary has no model", () => { + const groups = modelRouteGroups({ research_worker: "fast" }, models); + expect(groups.find((group) => group.profile === "")?.roles).toHaveLength(6); + expect(groups.find((group) => group.profile === "")?.model).toBeUndefined(); + expect(groups.find((group) => group.profile === "fast")?.roles[0]?.id).toBe( + "research_worker", + ); + }); +}); diff --git a/apps/desktop/src/components/ModelRoutingOverview.tsx b/apps/desktop/src/components/ModelRoutingOverview.tsx new file mode 100644 index 000000000..cec154f73 --- /dev/null +++ b/apps/desktop/src/components/ModelRoutingOverview.tsx @@ -0,0 +1,44 @@ +import { lazy, Suspense, useState } from "react"; +import type { RoleModel } from "./ModelRoleRouting"; +import "./model-routing-overview.css"; + +export { modelRouteGroups } from "./model-routing-map"; + +const ModelRoutingGraph = lazy(() => + import("./ModelRoutingGraph").then((module) => ({ + default: module.ModelRoutingGraph, + })), +); + +/** A configuration projection; selecting a route never invokes a provider. */ +export function ModelRoutingOverview({ + roles, + models, + onChange, + disabled = false, +}: { + roles: Record; + models: RoleModel[]; + onChange?: (roles: Record) => void; + disabled?: boolean; +}) { + const [expanded, setExpanded] = useState(false); + return ( +
setExpanded(event.currentTarget.open)} + > + Routing map + {expanded ? ( + Loading routing map…

}> + +
+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/components/OperationsSurface.test.ts b/apps/desktop/src/components/OperationsSurface.test.ts index 5cefad5a1..2eeed9756 100644 --- a/apps/desktop/src/components/OperationsSurface.test.ts +++ b/apps/desktop/src/components/OperationsSurface.test.ts @@ -84,7 +84,7 @@ function desktop(overrides: Partial = {}): DesktopStatus { } function renderSurface( - surface: "fleet" | "settings", + surface: "fleet" | "settings" | "connections", status = desktop(), ): string { return renderToStaticMarkup( @@ -101,6 +101,8 @@ function renderSurface( workNavigationOpen: false, onOpenWorkNavigation: vi.fn(), onConnect: vi.fn(), + onManageControlPlaneWorkspace: vi.fn(), + onManageControlPlaneProfiles: vi.fn(), onOpenRun: vi.fn(), onSelectTarget: vi.fn(), onAddExternalTarget: vi.fn(), @@ -130,6 +132,14 @@ function openingButtonTag(markup: string, label: string): string { } describe("OperationsSurface runtime targets", () => { + it("retains local and external routing while loading a separate Control Plane inventory", () => { + const markup = renderSurface("connections"); + expect(markup).toContain("Folder-backed runtimes"); + expect(markup).toContain("External daemons"); + expect(markup).toContain("Lab fleet"); + expect(markup).toContain('aria-label="Control Plane connections"'); + expect(markup).toContain("Reading Control Plane enrollment status"); + }); it("keeps Workspace navigation reachable from every responsive operations view", () => { const markup = renderSurface("fleet"); diff --git a/apps/desktop/src/components/OperationsSurface.tsx b/apps/desktop/src/components/OperationsSurface.tsx index 14dce6623..58e117220 100644 --- a/apps/desktop/src/components/OperationsSurface.tsx +++ b/apps/desktop/src/components/OperationsSurface.tsx @@ -28,6 +28,7 @@ import type { import type { AgentParticipant } from "./AgentFlow"; import { AgentFlow } from "./AgentFlow"; import { ManagedSettingsPane } from "./ManagedSettingsPane"; +import { ControlPlaneConnections } from "./ControlPlaneConnections"; import { PluginsSurface } from "./PluginsSurface"; import { WorkflowsSurface } from "./WorkflowsSurface"; import { SchedulesSurface } from "./SchedulesSurface"; @@ -37,7 +38,18 @@ interface OperationsSurfaceProps { scheduleInspection?: { scheduleId: string; showRun: boolean } | null | undefined; initialSettingsTab?: - "runtime" | "providers" | "plugins" | "terminal" | "dictation" | undefined; + | "runtime" + | "providers" + | "models" + | "plugins" + | "terminal" + | "dictation" + | "cloud" + | "control-plane" + | undefined; + initialSettingsSpaceId?: string | undefined; + onManageControlPlaneWorkspace: (spaceId: string) => void; + onManageControlPlaneProfiles: () => void; onConfigurePluginConnection?: () => void; pluginSelections?: readonly string[]; onUsePluginSkill?: (id: string) => void; @@ -372,6 +384,8 @@ function ConnectionsView({ onSelectTarget, onAddExternalTarget, onRemoveExternalTarget, + onManageControlPlaneWorkspace, + onManageControlPlaneProfiles, }: Pick< OperationsSurfaceProps, | "desktop" @@ -379,6 +393,8 @@ function ConnectionsView({ | "onSelectTarget" | "onAddExternalTarget" | "onRemoveExternalTarget" + | "onManageControlPlaneWorkspace" + | "onManageControlPlaneProfiles" >) { const externalTargets = desktop.targets.filter( (target) => target.kind === "external_daemon", @@ -428,6 +444,12 @@ function ConnectionsView({ ))}
+
@@ -525,6 +547,7 @@ function effectiveManagedConfiguration(desktop: DesktopStatus): string { function SettingsView({ initialSettingsTab, + initialSettingsSpaceId, onReturnToWork, connection, desktop, @@ -550,6 +573,7 @@ function SettingsView({ }: Pick< OperationsSurfaceProps, | "initialSettingsTab" + | "initialSettingsSpaceId" | "onReturnToWork" | "connection" | "desktop" @@ -600,6 +624,7 @@ function SettingsView({
{ "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/ThreadActionsMenu.tsx b/apps/desktop/src/components/ThreadActionsMenu.tsx new file mode 100644 index 000000000..441c19e77 --- /dev/null +++ b/apps/desktop/src/components/ThreadActionsMenu.tsx @@ -0,0 +1,128 @@ +import { useRef } from "react"; +import { + IconArchive, + IconDots, + IconGitFork, + IconLoader2, + IconPencil, + IconPin, +} from "@tabler/icons-react"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@colossus/ui/components/ui/dropdown-menu"; +import type { Run } from "../types"; +import { isTerminalStatus } from "../types"; +import { canForkThread, isThreadForkDraft } from "../thread-fork"; +import "./thread-fork.css"; + +export function ThreadActionsMenu({ + run, + title, + pinned, + disabled, + localDisabled, + archiving, + onRename, + onPin, + onArchive, + onFork, +}: { + run: Run; + title: string; + pinned: boolean; + disabled: boolean; + localDisabled: boolean; + archiving: boolean; + onRename: () => void; + onPin: () => void; + onArchive: () => void; + onFork?: (() => void) | undefined; +}) { + const forkSelected = useRef(false); + return ( + + + + + { + if (forkSelected.current) { + event.preventDefault(); + forkSelected.current = false; + } + }} + > + + + + + { + forkSelected.current = true; + onFork?.(); + }} + aria-label={`Fork ${title}`} + title={ + isThreadForkDraft(run) + ? "Send a message in this fork before forking it again" + : !canForkThread(run) + ? "Finish or cancel this thread before forking" + : undefined + } + > + + + + + + + + + + {archiving ? ( + + + + + ); +} 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.test.ts b/apps/desktop/src/components/WorkSidebar.test.ts index 769403d37..28bb50043 100644 --- a/apps/desktop/src/components/WorkSidebar.test.ts +++ b/apps/desktop/src/components/WorkSidebar.test.ts @@ -388,13 +388,11 @@ describe("WorkSidebar", () => { expect(markup.indexOf(">Pinned<")).toBeLessThan( markup.indexOf("Needs attention"), ); - expect(markup).toContain('aria-label="Unpin Improve the Work sidebar"'); - expect(markup).toContain('aria-pressed="true"'); - expect(markup).toContain('aria-label="Pin Needs attention"'); + expect(markup).toContain('aria-label="Thread actions for Needs attention"'); expect(markup).toContain( 'aria-label="Thread actions for Improve the Work sidebar"', ); - expect(markup).toContain('aria-label="Rename Improve the Work sidebar"'); + expect(markup).toContain('aria-haspopup="menu"'); }); it("uses a saved thread name throughout the sidebar", () => { @@ -435,38 +433,16 @@ describe("WorkSidebar", () => { }); expect(markup).toContain("Desktop naming polish"); - expect(markup).toContain('aria-label="Rename Desktop naming polish"'); + expect(markup).toContain( + 'aria-label="Thread actions for Desktop naming polish"', + ); }); - it("keeps local pin controls available while the app connects", () => { + it("keeps thread actions represented while the runtime connects", () => { const markup = renderSidebar({ disabled: true }); - - expect( - openingButtonTag(markup, "Pin Improve the Work sidebar"), - ).not.toContain("disabled"); - expect( - openingButtonTag(markup, "Archive Improve the Work sidebar"), - ).toContain("disabled"); - }); - - it("offers archiving only after a thread reaches a terminal state", () => { - const running = renderSidebar(); - expect( - openingButtonTag(running, "Archive Improve the Work sidebar"), - ).toContain("disabled"); - - const completed = renderSidebar({ - runs: [ - { - ...RUN, - status: "completed", - finishedAt: RUN.updatedAt, - }, - ], - }); expect( - openingButtonTag(completed, "Archive Improve the Work sidebar"), - ).not.toContain("disabled"); + openingButtonTag(markup, "Thread actions for Improve the Work sidebar"), + ).toBeDefined(); }); it("shows archived threads in global search with an explicit restore action", () => { diff --git a/apps/desktop/src/components/WorkSidebar.tsx b/apps/desktop/src/components/WorkSidebar.tsx index 86cb2a965..25da763a1 100644 --- a/apps/desktop/src/components/WorkSidebar.tsx +++ b/apps/desktop/src/components/WorkSidebar.tsx @@ -7,6 +7,7 @@ import { IconCheck, IconChevronDown, IconFolder, + IconGitFork, IconLibrary, IconLoader2, IconDots, @@ -17,14 +18,20 @@ import { IconPlus, IconPointFilled, IconRestore, - IconSearch, IconSettings, IconTerminal2, IconTopologyStar3, - IconWorld, IconX, } from "@tabler/icons-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { + WorkspaceSidebarGroupHeading, + WorkspaceSidebarHeading, + WorkspaceSidebarScope, + WorkspaceSidebarSearch, + WorkspaceSidebarThreadContent, + WorkspaceSidebarWorkspace, +} from "@colossus/ui"; import { presentRunStatus, @@ -49,7 +56,12 @@ import type { SpaceSearchResult, SpaceSummary, } from "../types"; -import { isTerminalStatus } from "../types"; +import { isThreadForkDraft } from "../thread-fork"; +const ThreadActionsMenu = lazy(() => + import("./ThreadActionsMenu").then((module) => ({ + default: module.ThreadActionsMenu, + })), +); import { ObscurityLabsMark } from "./ObscurityLabsMark"; import type { WorkspaceSurface } from "./ProductRail"; @@ -116,6 +128,7 @@ interface WorkSidebarProps { onArchiveSpace: (spaceId: string) => void; onRestoreSpace: (spaceId: string) => void; onArchiveThread: (run: Run) => void; + onForkThread?: ((run: Run) => void) | undefined; onRenameThread: (run: Run, name: string) => void; onToggleThreadPinned: (run: Run) => void; onRestoreThread: (result: SpaceSearchResult) => void; @@ -223,6 +236,7 @@ export function WorkSidebar({ onArchiveSpace, onRestoreSpace, onArchiveThread, + onForkThread, onRenameThread, onToggleThreadPinned, onRestoreThread, @@ -564,124 +578,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 ? ( - - ) : null} - {capabilities.shellTerminal ? ( +
-
+ {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" ? (